From fbbf6c135237e7c2381fece40d15b8b0de1df3cb Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 3 Jan 2026 05:03:09 +0000 Subject: [PATCH 01/56] Updates contributors list --- .github/README.md | 57 ++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/.github/README.md b/.github/README.md index b986cc9..63bc961 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5569,6 +5569,13 @@ Huge thanks to the following sponsors, for their ongoing support 💖 HeliXZz + + + Zero-Tail +
+ ZeroTail +
+ undefined @@ -5576,13 +5583,6 @@ Huge thanks to the following sponsors, for their ongoing support 💖 Undefined - - - OlliVHH -
- HamburgerJung -
- frankdez93 @@ -5618,6 +5618,13 @@ Huge thanks to the following sponsors, for their ongoing support 💖
Fab 💖 ↀ◡ↀ
+ + + + JDB321Sailor +
+ JDB321Sailor +
@@ -5706,20 +5713,20 @@ This project exists thanks to all the people who've helped build and maintain it Kerbless - - - ksharizard -
- Kshamendra -
- - titanism
Titanism
+ + + + + ksharizard +
+ Kshamendra +
@@ -5771,6 +5778,13 @@ This project exists thanks to all the people who've helped build and maintain it Ash Scott + + + edent +
+ Terence Eden +
+ tschlotfeldt @@ -5791,15 +5805,15 @@ This project exists thanks to all the people who've helped build and maintain it
Ward
- + + Wesley-Ryan
Wesley-Ryan
- - + thezacharytaylor @@ -5834,13 +5848,6 @@ This project exists thanks to all the people who've helped build and maintain it
Cole
- - - - jxhn -
- Jxhn -
From bfb447a178f96107142c577cfc23394e6112fd6b Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 10 Jan 2026 05:02:46 +0000 Subject: [PATCH 02/56] Updates contributors list --- .github/README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/README.md b/.github/README.md index 63bc961..fe22244 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5571,11 +5571,18 @@ Huge thanks to the following sponsors, for their ongoing support 💖 - Zero-Tail + Zero-Tail
ZeroTail
+ + + 4aka +
+ Arch Incorp +
+ undefined @@ -5589,15 +5596,15 @@ Huge thanks to the following sponsors, for their ongoing support 💖
Frankdez93
- + + BigoudOps
John BigoudOps
- - + hudsonrock-partnerships @@ -5619,6 +5626,13 @@ Huge thanks to the following sponsors, for their ongoing support 💖 Fab 💖 ↀ◡ↀ + + + 0x41647269656E +
+ 0x41647269656E +
+ JDB321Sailor From d349357adcacb9274573beeeb1f1a866ced9cd09 Mon Sep 17 00:00:00 2001 From: emilynomura1 <49768143+emilynomura1@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:43:38 -0800 Subject: [PATCH 03/56] Adds Proton Authenticator to 2-Factor Authentication --- awesome-privacy.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..7981546 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -257,6 +257,20 @@ categories: devices (and access them via a web interface) in a secure, end-to-end encrypted fashion. It can also be used offline on a single device with no account necessary. + - name: Proton Authenticator + url: https://proton.me/authenticator + icon: https://raw.githubusercontent.com/protonpass/android-pass/refs/heads/main/metadata/en-US/images/icon.png + openSource: true + github: protonpass/android-authenticator + tosdrId: 491 + iosApp: https://apps.apple.com/us/app/proton-authenticator/id6741758667 + androidApp: https://play.google.com/store/apps/details?id=proton.android.authenticator + description: | + Proton Authenticator is yet another addition to the Proton application suite. It is + free, open source, and available for both iOS and Android. You do not need a Proton + account to use Proton Authenticator. Existing 2FA codes can be imported from other + popular apps such as Google Authenticator and LastPass. + furtherInfo: > Check which websites support multi-factor authentication: [2fa.directory](https://2fa.directory/) notableMentions: > From fc9a785e65030f19f416a25732728fa7b461712b Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 17 Jan 2026 05:02:41 +0000 Subject: [PATCH 04/56] Updates contributors list --- .github/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/README.md b/.github/README.md index fe22244..9fc7996 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5578,7 +5578,7 @@ Huge thanks to the following sponsors, for their ongoing support 💖 - 4aka + 4aka
Arch Incorp
@@ -5614,7 +5614,7 @@ Huge thanks to the following sponsors, for their ongoing support 💖 - LambdaTest-Inc + LambdaTest-Inc
LambdaTest
@@ -5628,7 +5628,7 @@ Huge thanks to the following sponsors, for their ongoing support 💖 - 0x41647269656E + 0x41647269656E
0x41647269656E
From 0b098de2ea46e17f8c47659cb552ca69d6a016d9 Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 31 Jan 2026 05:14:48 +0000 Subject: [PATCH 05/56] Updates contributors list --- .github/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/README.md b/.github/README.md index 9fc7996..7ca2fb6 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5632,13 +5632,6 @@ Huge thanks to the following sponsors, for their ongoing support 💖
0x41647269656E - - - - JDB321Sailor -
- JDB321Sailor -
From 7225c8d1b737ebe3b87a7df81a212371760d7698 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 3 Feb 2026 15:41:13 -0600 Subject: [PATCH 06/56] Add Bitwarden Authenticator to awesome-privacy.yml Added Bitwarden Authenticator with details for iOS and Android apps. --- awesome-privacy.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..60dc079 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -257,6 +257,21 @@ categories: devices (and access them via a web interface) in a secure, end-to-end encrypted fashion. It can also be used offline on a single device with no account necessary. + - name: Bitwarden Authenticator + url: https://bitwarden.com/products/authenticator/ + icon: https://avatars.githubusercontent.com/u/15990069 + openSource: true + github: bitwarden/ios + tosdrId: 5256 + iosApp: https://apps.apple.com/app/bitwarden-authenticator/id6497335175 + androidApp: com.bitwarden.authenticator + description: | + Bitwarden Authenticator is a free and open-source app which stores and generates + time-based codes for multi-factor authentication. It can be used with an online + account to backup and sync your tokens across your devices (and access them via + a web interface) in a secure, end-to-end encrypted fashion. It can also be used + offline on a single device with no account necessary. + furtherInfo: > Check which websites support multi-factor authentication: [2fa.directory](https://2fa.directory/) notableMentions: > From f1e159ee8609f5408161ec9aa0f39d35eadc2a62 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 3 Feb 2026 16:09:35 -0600 Subject: [PATCH 07/56] Add furtherInfo section to awesome-privacy.yml Added further information about alternative front-ends for popular platforms. --- awesome-privacy.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..9488e3f 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -4966,7 +4966,10 @@ categories: # tosdrId: 3996 wordOfWarning: | When proxies are involved - only use reputable services, and **never** enter any personal information - + furtherInfo: | + *[alternative-front-ends](https://github.com/mendel5/alternative-front-ends) provides information on + dozens of alternative front-end options for popular platforms, such as YouTube, Twitter, Reddit, + TikTok, and more.* - name: Media sections: From 6719979ff761fdd1709b7a1ad0e16ab20c924418 Mon Sep 17 00:00:00 2001 From: Pasha Suprunchuk <57152612+suprunchuk@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:05:48 +0300 Subject: [PATCH 08/56] updated the link to Firefox --- awesome-privacy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..9fd7981 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -393,7 +393,7 @@ categories: Brave also has Tor built-in, when you open up a private tab/ window. - name: Firefox - url: https://www.mozilla.org/firefox + url: https://www.firefox.com/ icon: https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg openSource: true tosdrId: 188 From a2469c9295b91dbaae742051bb182cf3475e4746 Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 7 Feb 2026 05:16:22 +0000 Subject: [PATCH 09/56] Updates contributors list --- .github/README.md | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/README.md b/.github/README.md index 7ca2fb6..5769ca5 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5576,13 +5576,6 @@ Huge thanks to the following sponsors, for their ongoing support 💖 ZeroTail - - - 4aka -
- Arch Incorp -
- undefined @@ -5596,15 +5589,15 @@ Huge thanks to the following sponsors, for their ongoing support 💖
Frankdez93
- - + BigoudOps
John BigoudOps
- + + hudsonrock-partnerships @@ -5619,19 +5612,19 @@ Huge thanks to the following sponsors, for their ongoing support 💖 LambdaTest + + + hesreallyhim +
+ Really Him +
+ gl0bal01
Fab 💖 ↀ◡ↀ
- - - - 0x41647269656E -
- 0x41647269656E -
From dc90e5bdf4776285520cd3bf66f1749a82ed74da Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 14 Feb 2026 05:17:52 +0000 Subject: [PATCH 10/56] Updates contributors list --- .github/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/README.md b/.github/README.md index 5769ca5..38cfde1 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5614,7 +5614,7 @@ Huge thanks to the following sponsors, for their ongoing support 💖 - hesreallyhim + hesreallyhim
Really Him
From ecfccc11206926527aae200c21b770caf2f1cb17 Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 21 Feb 2026 05:16:40 +0000 Subject: [PATCH 11/56] Updates contributors list --- .github/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/README.md b/.github/README.md index 38cfde1..f61fd3b 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5625,6 +5625,13 @@ Huge thanks to the following sponsors, for their ongoing support 💖
Fab 💖 ↀ◡ↀ + + + + clj00321 +
+ Christian Løvgren Jensen +
From de9b3d1a18f32d3ab053adb5aa4a734c4dbbcc4d Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 22 Feb 2026 15:09:09 +0000 Subject: [PATCH 12/56] Updates the repo mirroring workflow --- .github/workflows/mirror.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/mirror.yml diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..c7c0a62 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,16 @@ +# Syncs repo to the Codeberg mirror +name: 🪞 Mirror +on: + schedule: [{ cron: '0 5 * * 0' }] + push: { tags: ['v*'] } + workflow_dispatch: +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: lissy93/repo-mirror-action@main + with: + ssh_key: ${{ secrets.CODEBERG_SSH }} + host: git@codeberg.org + user: alicia + repo: awesome-privacy From 0ad29adfa6e32fa640cfa24e7e684488f0ab9500 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 22 Feb 2026 15:09:21 +0000 Subject: [PATCH 13/56] Removes obsolete GH Actions --- .github/workflows/check-domain.yml | 42 --------- .github/workflows/compile-pdf.yml | 44 ---------- .github/workflows/credits.yml | 34 -------- .github/workflows/pr-labeler.yml | 25 ------ .github/workflows/spell-check.yml | 20 ----- .github/workflows/sync-mirror.yml | 17 ---- .github/workflows/ticket-check.yml | 40 --------- .github/workflows/validate-pr.yml | 90 -------------------- .github/workflows/welcome-non-stargazers.yml | 18 ---- 9 files changed, 330 deletions(-) delete mode 100644 .github/workflows/check-domain.yml delete mode 100644 .github/workflows/compile-pdf.yml delete mode 100644 .github/workflows/credits.yml delete mode 100644 .github/workflows/pr-labeler.yml delete mode 100644 .github/workflows/spell-check.yml delete mode 100644 .github/workflows/sync-mirror.yml delete mode 100644 .github/workflows/ticket-check.yml delete mode 100644 .github/workflows/validate-pr.yml delete mode 100644 .github/workflows/welcome-non-stargazers.yml diff --git a/.github/workflows/check-domain.yml b/.github/workflows/check-domain.yml deleted file mode 100644 index 542dc77..0000000 --- a/.github/workflows/check-domain.yml +++ /dev/null @@ -1,42 +0,0 @@ -# Checks domain and SSL status, then raises an issue if either is expiring soon -name: 🌎 Check Domain Expiry -on: - workflow_dispatch: - schedule: - - cron: '0 5 * * 6' # Every Saturday morning. -jobs: - check-domain: - runs-on: ubuntu-latest - name: Check domain - strategy: - matrix: - domain: - - https://awesome-privacy.xyz - steps: - - name: Check domain SSL and registry expire date - id: check-domain - uses: codex-team/action-check-domain@v1 - with: - url: ${{ matrix.domain }} - - name: Raise issue if domain expiring soon - if: ${{ steps.check-domain.outputs.paid-till-days-left && steps.check-domain.outputs.paid-till-days-left < 30 }} - uses: rishabhgupta/git-action-issue@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - assignees: Lissy93 - title: '[WEBSITE] Domain Expiring Soon' - body: > - **Priority Notice** - Domain, ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.paid-till-days-left }} days. - @Lissy93 - Please take action immediately to prevent any downtime - - name: Raise issue if SSL Cert expiring soon - if: ${{ steps.check-domain.outputs.ssl-expire-days-left && steps.check-domain.outputs.ssl-expire-days-left < 14 }} - uses: rishabhgupta/git-action-issue@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - assignees: Lissy93 - title: '[WEBSITE] SSL Cert Expiring Soon' - body: > - **Priority Notice** - The SSL Certificate for ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.ssl-expire-days-left }} days, on ${{ steps.check-domain.outputs.ssl-expire-date }}. - @Lissy93 - Please take action immediately to prevent any downtime diff --git a/.github/workflows/compile-pdf.yml b/.github/workflows/compile-pdf.yml deleted file mode 100644 index 81b202d..0000000 --- a/.github/workflows/compile-pdf.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Generates and saved a PDF document from the main markdown file -# Easier to read on certain devices, or for users with accesibility needs - -name: 📁 Compile PDF Document -on: - workflow_dispatch: # Manual dispatch - schedule: - - cron: '0 5 * * 6' # Every Saturday morning. -jobs: - # Job #1 - Generate an embedded SVG asset, showing all contributors - compile-pdf: - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v2 - - name: Make PDF 📄 - uses: baileyjm02/markdown-to-pdf@v1.1.0 - with: - input_dir: . - output_dir: .github/assets/ - build_pdf: true - build_html: false - table_of_contents: false - - name: Upload Artifact 📤 - uses: actions/upload-artifact@v3 - with: - name: awesome-privacy-pdf - path: .github/assets/README.pdf - - name: Commit file ✅ - run: | - git config --local user.email "alicia-gh-bot@mail.as93.net" - git config --local user.name "liss-bot" - git add .github/assets/*.pdf - if ! git diff-index --quiet HEAD; then - git commit -m "Generate PDF file" - else - echo "Nothing to do" - fi - - name: Push changes ➡️ - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - branch: ${{ github.ref }} - diff --git a/.github/workflows/credits.yml b/.github/workflows/credits.yml deleted file mode 100644 index 84aad69..0000000 --- a/.github/workflows/credits.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Inserts list of contributors and community members into ./docs/credits.md -name: 📊 Generate Contributor Credits -on: - workflow_dispatch: # Manual dispatch - schedule: - - cron: '0 5 * * 6' # Every Saturday morning. -jobs: - # Job #1 - Inserts sponsors into README - insert-sponsors: - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v2 - - name: Generate Sponsors in Credits 💖 - uses: JamesIves/github-sponsors-readme-action@1.0.5 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - file: '.github/README.md' - # Job #2 - Inserts contributors into README - insert-credits: - runs-on: ubuntu-latest - name: Inserts contributors into credits.md - steps: - - name: Contribute List - Credits Page - uses: akhilmhdh/contributors-readme-action@v2.2 - env: - GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - image_size: 80 - readme_path: .github/README.md - columns_per_row: 6 - commit_message: 'Updates contributors list' - committer_username: liss-bot - committer_email: liss-bot@d0h.co diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml deleted file mode 100644 index 761e606..0000000 --- a/.github/workflows/pr-labeler.yml +++ /dev/null @@ -1,25 +0,0 @@ -# Applies labels based on the pull request category -name: 🏷️ PR Labeler -on: - pull_request: - types: [opened, edited] -jobs: - label-pr: - runs-on: ubuntu-latest - permissions: write-all - steps: - - name: Apply Labels - if: "! contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc')" - uses: Naturalclar/issue-action@v2.0.2 - with: - title-or-body: both - github-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - parameters: > - [ - {"keywords": ["Addition"], "labels": ["Addition"] }, - {"keywords": ["Amendment"], "labels": ["Amendment"] }, - {"keywords": ["Removal"], "labels": ["Removal"] }, - {"keywords": ["Spelling or Grammar"], "labels": ["Grammar"] }, - {"keywords": ["Website Update"], "labels": ["Website"] }, - {"keywords": ["Misc"], "labels": ["Misc"] } - ] diff --git a/.github/workflows/spell-check.yml b/.github/workflows/spell-check.yml deleted file mode 100644 index 9ddb46d..0000000 --- a/.github/workflows/spell-check.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Spell check newly added content, when PR opened and, put typo list as comment -name: ✏️ Spell Check -on: [pull_request] -jobs: - misspell: - name: runner / misspell - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v2 - - name: Run Spell Check 📝 - uses: reviewdog/action-misspell@v1 - with: - github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - locale: US - level: info - reporter: github-pr-review - path: . - filter_mode: added - fail_on_error: false diff --git a/.github/workflows/sync-mirror.yml b/.github/workflows/sync-mirror.yml deleted file mode 100644 index 672ab86..0000000 --- a/.github/workflows/sync-mirror.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Pushes the contents of the repo to the Codeberg mirror -name: 🪞 Mirror to Codeberg -on: - workflow_dispatch: # Manual dispatch - schedule: - - cron: '0 5 * * 6' -jobs: - codeberg: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - uses: pixta-dev/repository-mirroring-action@v1 - with: - target_repo_url: "git@codeberg.org:alicia/awesome-privacy.git" - ssh_private_key: ${{ secrets.CODEBERG_SSH }} diff --git a/.github/workflows/ticket-check.yml b/.github/workflows/ticket-check.yml deleted file mode 100644 index 12759a3..0000000 --- a/.github/workflows/ticket-check.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Checks newly opened issues contain enough info, and follow the required format -name: 🎫 Issue Validator -on: - issues: - types: [opened, edited] -jobs: - check-title: - runs-on: ubuntu-latest - permissions: write-all - steps: - - name: Check Default Title - if: "endsWith(github.event.issue.title, '')" - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Please ensure that your ticket has an appropriate title - - name: Check Title Contains Categroy - if: "!(startsWith(github.event.issue.title, '[') && contains(github.event.issue.title, ']'))" - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Please ensure that your ticket's title is preceded with a category. - For example, `[ADDITION]`, `[AMENDMENT]`, `[REMOVAL]` or `[QUESTION]`. - - name: Check Quality Checklist - if: "contains(github.event.issue.body, '[ ]') || !(contains(github.event.issue.body, '[X]') || contains(github.event.issue.body, '[x]'))" - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Please ensure that you've followed the issue template fully. - It's important that you complete the quality & transparency checklist. - - - - diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml deleted file mode 100644 index 743cff5..0000000 --- a/.github/workflows/validate-pr.yml +++ /dev/null @@ -1,90 +0,0 @@ -# Checks that PR title conform to contributing standards (or at least !== Update README.md) -name: ⛳ Validate PR -on: - pull_request: - types: [opened, edited, synchronize, reopened] - -permissions: - pull-requests: write - -env: - BASE_MSG: >+ - Thanks for contributing to Awesome-Privacy! Your pull request will be reviewed shortly. - - In the meantime, please be sure that you have read, and complied with the guidelines outlined in the - [Contributing Docs](https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md). - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - name: Validate Title is not Default - if: "contains(github.event.pull_request.title, 'Update README.md')" - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.pull_request.number }} - body: | - Hi @${{ github.actor }}, - Please update your pull request, to include a more descriptive title. - - - name: Validate Checklist is Completed - if: > - contains(github.event.pull_request.body, '[ ]') || - !(contains(github.event.pull_request.body, '[X]') || contains(github.event.pull_request.body, '[x]')) - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.pull_request.number }} - body: | - Hello @${{ github.actor }} 👋 - ${{ env.BASE_MSG }} - ⚠️ It looks like you've not complete the quality and transparency checklist. - - - name: Validate Affiliation Section is Present - if: > - !contains(github.event.pull_request.body, 'Affiliation') - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.pull_request.number }} - body: | - Hello @${{ github.actor }} 👋 - ${{ env.BASE_MSG }} - ⚠️ You must indicate if you are affiliated with any software modified by this PR. - If not applicable, you may set this field to N/A. - - - name: Validate Category - if: > - contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc') || - !( - contains(github.event.pull_request.body, 'Addition') || - contains(github.event.pull_request.body, 'Amendment') || - contains(github.event.pull_request.body, 'Removal') || - contains(github.event.pull_request.body, 'Spelling or Grammar') || - contains(github.event.pull_request.body, 'Website Update') || - contains(github.event.pull_request.body, 'Misc') - ) - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.pull_request.number }} - body: | - Hello @${{ github.actor }} 👋 - ${{ env.BASE_MSG }} - ⚠️ You must specify a category - Either: `Addition`, `Amendment`, `Removal`, `Spelling or Grammar`, `Website Update`, or `Misc`. - - - name: Validate Supporting Material is Present - if: > - !contains(github.event.pull_request.body, 'Supporting Material') - uses: peter-evans/create-or-update-comment@v2 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.pull_request.number }} - body: | - Hello @${{ github.actor }} 👋 - ${{ env.BASE_MSG }} - ⚠️ If applicable, please ensure you've provided supporting material. - - diff --git a/.github/workflows/welcome-non-stargazers.yml b/.github/workflows/welcome-non-stargazers.yml deleted file mode 100644 index 57091ec..0000000 --- a/.github/workflows/welcome-non-stargazers.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: ⭐ Hello non-Stargazers -on: - issues: - types: [opened] -jobs: - check-user: - if: ${{ github.event.comment.author_association != 'CONTRIBUTOR' }} - runs-on: ubuntu-latest - name: Add comment to issues opened by non-stargazers - steps: - - name: comment - uses: qxip/please-star-light@v4 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - autoclose: false - message: | - If you're enjoying Awesome-Privacy, consider dropping us a ⭐<br> - _<sub>🤖 I'm a bot, and this message was automated</sub>_ From 2fec6f757d0939d6e3b469ccb8c1e691816dadc2 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Sun, 22 Feb 2026 15:30:18 +0000 Subject: [PATCH 14/56] Improves the validation script. - Updates Python deps - Only run pip install on first time - Corrections to the schema - Adds missing URL field to Picocrypt to avoid failure - Improves the script, with neater output, proper error codes, colors, etc --- Makefile | 2 +- awesome-privacy.yml | 151 ++++++++++++++++---------------- lib/requirements.txt | 5 +- lib/schema.json | 44 +++++----- lib/validate-awesome-privacy.py | 145 +++++++++++++++++------------- 5 files changed, 188 insertions(+), 159 deletions(-) diff --git a/Makefile b/Makefile index d22402a..d773886 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ WEB_DIR := web # Targets for lib/ install_lib_deps: - $(PYTHON) -m pip install -r $(LIB_DIR)/requirements.txt + $(PYTHON) -m pip install -q -r $(LIB_DIR)/requirements.txt gen_readme: install_lib_deps $(PYTHON) $(LIB_DIR)/awesome-privacy-readme-gen.py diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..1a02f4a 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -135,7 +135,7 @@ categories: Free for self-hosted data (or $3/ month hosted). Be aware that 1Password is not fully open source, but they do regularly publish results of their independent [security audits](https://support.1password.com/security-assessments), - and they have a solid reputation for transparently disclosing and fixing vulnerabilities + and they have a solid reputation for transparently disclosing and fixing vulnerabilities furtherInfo: > **Other Open Source PM**: [Buttercup](https://buttercup.pw), [Clipperz](https://clipperz.is), [Pass](https://www.passwordstore.org), [Padloc](https://padloc.app), [TeamPass](https://teampass.net), @@ -252,9 +252,9 @@ categories: iosApp: https://apps.apple.com/us/app/ente-auth/id6444121398 androidApp: io.ente.auth description: | - Ente Auth is a free and open-source app which stores and generates TOTP tokens. - It can be used with an online account to backup and sync your tokens across your - devices (and access them via a web interface) in a secure, end-to-end encrypted + Ente Auth is a free and open-source app which stores and generates TOTP tokens. + It can be used with an online account to backup and sync your tokens across your + devices (and access them via a web interface) in a secure, end-to-end encrypted fashion. It can also be used offline on a single device with no account necessary. furtherInfo: > @@ -265,7 +265,7 @@ categories: [Etopa](https://play.google.com/store/apps/details?id=de.ltheinrich.etopa) *(Android)*<br> For KeePass users, [TrayTop](https://keepass.info/plugins.html#traytotp) is a plugin for managing TOTP's - offline and compatible with Windows, Mac and Linux. - + ############################# ###### File Encryption ###### ############################# @@ -309,9 +309,10 @@ categories: - name: Picocrypt github: Picocrypt/Picocrypt icon: https://avatars.githubusercontent.com/u/171401041 + url: '' description: | Picocrypt is a very small (hence Pico), very simple, yet very secure encryption tools - that you can use to protect your files. It's designed to be the go-to tool for encryption, + that you can use to protect your files. It's designed to be the go-to tool for encryption, with a focus on security, simplicity, and reliability. wordOfWarning: > @@ -440,7 +441,7 @@ categories: After installing, check the privacy & security settings, and update the configuration to something that you are comfortable with. 12Bytes maintains a comprehensive guide on [Firefox Configuration for Privacy and Performance](https://codeberg.org/12bytes/firefox-config-guide) - + ############################ ###### Search Engines ###### ############################ @@ -524,8 +525,8 @@ categories: and self-hostable, although using a [public instance](https://searx.space) has the benefit of not singling out your queries to the engines used. A fork of the original [Searx](https://searx.github.io/searx/). - - + + - name: Communication sections: ################################# @@ -581,10 +582,10 @@ categories: subreddit: SimpleXChat description: | Simplex is gaining popularity as a secure and private messaging app renowned - for its robust encryption protocol without user IDs or phone numbers and this improves your privacy. + for its robust encryption protocol without user IDs or phone numbers and this improves your privacy. Simplex offers instant messaging, supports media attachments and voice and video calls. - Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's - preferences for convenience, security, and accessibility. + Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's + preferences for convenience, security, and accessibility. Learn more about the [Security Policy](https://simplex.chat/security/). - name: XMPP @@ -615,7 +616,7 @@ categories: an open specification and Simple pragmatic RESTful HTTP/JSON API it makes it easy to integrates with existing 3rd party IDs to authenticate and discover users, as well as to build apps on top of it. - notableMentions: + notableMentions: - name: Chat Secure url: https://chatsecure.org - name: KeyBase @@ -649,7 +650,7 @@ categories: be used to communicate any sensitive data. [Wire](https://wire.com/) has also been removed, due to a [recent acquisition](https://blog.privacytools.io/delisting-wire/) - + ########################### ###### P2P Messaging ###### ########################### @@ -724,7 +725,7 @@ categories: url: https://github.com/Bitmessage/PyBitmessage - name: RetroShare url: https://retroshare.cc - + ############################# ###### Encrypted Email ###### ############################# @@ -792,11 +793,11 @@ categories: custom domains (starting at $3/month). Tuta [does not use OpenPGP](https://tuta.com/blog/posts/differences-email-encryption/) like other encrypted mail providers, instead they use a standardized, hybrid method - consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048 + consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048 or ECC (x25519) and Kyber-1024). This causes compatibility issues when communicating with contacts using PGP. But it does allow them to encrypt much more of the header data (body, attachments, subject lines, and sender names etc) which PGP mail providers cannot do. The recent upgrades - to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks + to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks posed by quantum computers. - name: Mailfence url: https://mailfence.com?src=digitald @@ -1104,7 +1105,7 @@ categories: url: https://www.smspool.net icon: https://i.ibb.co/2t4MBFj/apple-touch-icon.png description: | - Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers. + Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers. We support over 50+ countries and support over 300+ services. androidApp: com.smspool.app iosApp: https://apps.apple.com/app/smspool/id6474617801 @@ -1452,7 +1453,7 @@ categories: Displays a country flag depicting the location of the current website's server, which can be useful to know at a glance. Click icon for more tools such as site safety checks, whois, validation etc **Download**: [Firefox](https://addons.mozilla.org/en-US/firefox/addon/flagfox/) - + - name: Lightbeam url: https://mozilla.github.io/lightbeam/ github: mozilla/lightbeam-we @@ -1523,7 +1524,7 @@ categories: url: https://addons.mozilla.org/en-US/firefox/addon/crxviewer description: > A handy extension for viewing the source code of another browser extension, - which is a useful tool for verifying the code does what it says + which is a useful tool for verifying the code does what it says wordOfWarning: | - Having many extensions installed raises entropy, causing your fingerprint to be more unique, hence making tracking easier. - Much of the functionality of the above addons can be applied without installing anything, by configuring browser settings yourself. For Firefox this is done in the user.js @@ -2043,7 +2044,7 @@ categories: See [Streisand](https://github.com/StreisandEffect/streisand), to learn more, and get started with running a VPN. [Digital Ocean](https://m.do.co/c/3838338e7f79) provides flexible, secure and easy Linux VMs, (from $0.007/hour or $5/month), - Here is a [1-click install script](http://dovpn.carlfriess.com/)for + Here is a [1-click install script](http://dovpn.carlfriess.com/)for on [Digital Ocean](https://m.do.co/c/3838338e7f79), by Carl Friess. Recently distributed self-hosted solutions for running your own VPNs have @@ -2203,8 +2204,8 @@ categories: Tor, I2P and Freenet are all anonymity networks - but they work very differently and each is good for specific purposes. So a good and viable solution would be to use all of them, for different tasks. *You can read more about how I2P compares to Tor, [here](https://blokt.com/guides/what-is-i2p-vs-tor-browser)* - - + + ##################### ###### Proxies ###### ##################### @@ -2323,7 +2324,7 @@ categories: Mullvads public DNS with QNAME minimization and basic ad blocking. It has been audited by the security experts at Assured. You can use this privacy-enhancing service even if you don't use Mullvad. - + ######################### ###### DNS Clients ###### ######################### @@ -2643,7 +2644,7 @@ categories: Some VPNs have ad-tracking blocking features, such as [TrackStop with PerfectPrivacy](https://www.perfect-privacy.com/en/features/trackstop?a_aid=securitychecklist). - + [Private Internet Access](https://www.privateinternetaccess.com/), [CyberGhost](https://www.cyberghostvpn.com/), [PureVPN](https://www.anrdoezrs.net/click-9242873-13842740), @@ -2882,7 +2883,7 @@ categories: [5 eyes](https://en.wikipedia.org/wiki/Five_Eyes) (Australia, Canada, New Zealand, US and UK) and [other international cooperatives](https://en.wikipedia.org/wiki/Five_Eyes#Other_international_cooperatives) who have legal right to view your data. - + ############################### ###### Domain Registrars ###### ############################### @@ -2920,7 +2921,7 @@ categories: followWith: Web description: | Free DNS hosting provider designed with security in mind, and running - on purely open source software. deSEC is backed and funded by SSE. + on purely open source software. deSEC is backed and funded by SSE. ########################### @@ -3007,7 +3008,7 @@ categories: All notes are saved individually as .md files, making them easy to manage. No mobile app, built-in cloud-sync, encryption or web UI. But due to the structure of the files, it is easy to use your own cloud sync provider, and additional features are provided through extensions. - + - name: Joplin url: https://joplinapp.org github: laurent22/joplin @@ -3081,7 +3082,7 @@ categories: notableMentions: | If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com) - is a utility that encrypts your entire notebook, before it is uploaded to the cloud. + is a utility that encrypts your entire notebook, before it is uploaded to the cloud. [Org Mode](https://orgmode.org) is a mode for [GNU Emacs](https://www.gnu.org/software/emacs/) dedicated to working with the Org markup format. Org can be thought of as @@ -3331,7 +3332,7 @@ categories: using [Web Torrent](https://webtorrent.io). For specifically transferring images, [Up1](https://github.com/Upload/Up1) is a good self-hosted option, with client-side encryption. - + Finally [PsiTransfer](https://github.com/psi-4ward/psitransfer) is a feature-rich, self-hosted file drop, using streams. @@ -3380,7 +3381,7 @@ categories: description: | Simple bookmark manager written in Go, intended to be a clone of Pocket, it has both a simple and clean web interface as well as a CLI. Shiori has easy import/ export, is portable and has webpage archiving features. - + notableMentions: | [Ymarks](https://ymarks.org) is a C-based self-hosted bookmark synchronization server and [Chrome](https://chrome.google.com/webstore/detail/ymarks/gefignhaigoigfjfbjjobmegihhaacfi) extension. @@ -3438,7 +3439,7 @@ categories: notableMentions: | [Apache OpenMeetings](https://openmeetings.apache.org) provides self-hosted video-conferencing, chat rooms, file server and tools for meetings. - + [together.brave.com](https://together.brave.com) is Brave's Jitsi Fork. For remote learning, [BigBlueButton](https://bigbluebutton.org) is self-hosted conference call software, @@ -3495,10 +3496,10 @@ categories: it is not open source, and although there is a free version, a license is required to access all features. VMWare performs very well when running on a server, with hundreds of hosts and users. - + For Mac users, [Parallels](https://www.parallels.com/uk/) is a popular option which performs really well, but again is not open source. - + For Windows users, there's [Hyper-V](https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v), which is a native Windows product, developed by Microsoft. @@ -3567,7 +3568,7 @@ categories: description: | iOS app for encrypting/ decrypting text. Has native keyboard integration, keychain support and app integrations which makes it quick to use in any app. - + - name: FlowCrypt url: https://flowcrypt.com @@ -3646,10 +3647,10 @@ categories: Alternatively, with [ImageMagic](https://imagemagick.org) installed, just run `convert -strip path/to/image.png` to remove all metadata. - + If you have [GIMP](https://www.gimp.org) installed, then just go to `File --> Export As --> Export --> Advanced Options --> Uncheck the "Save EXIF data" option`. - Often you need to perform meta data removal programmatically, as part of a script or automation process. + Often you need to perform meta data removal programmatically, as part of a script or automation process. - GoLang: [go-exif](https://github.com/dsoprea/go-exif) by @dsoprea - JS: [exifr](https://github.com/MikeKovarik/exifr) by @MikeKovarik - Python: [Piexif](https://github.com/hMatoba/Piexif) by @hMatoba @@ -3665,7 +3666,7 @@ categories: [not remove it](https://uk.norton.com/internetsecurity-privacy-is-my-personal-data-really-gone-when-its-deleted-from-a-device.html) from the disk, and recovering deleted files is a [simple task](https://www.lifewire.com/how-to-recover-deleted-files-2622870). - + Therefore, to protect your privacy, you should erase/ overwrite data from the disk, before you destroy, sell or give away a hard drive. services: @@ -3754,12 +3755,12 @@ categories: Or [badblocks](https://linux.die.net/man/8/badblocks) which is intended to search for all bad blocks, but can also be used to write zeros to a disk, by running `sudo badblocks -wsv /dev/sdd`. - + An effective method of erasing an SSD, it to use [hdparm](https://en.wikipedia.org/wiki/Hdparm) to issue a [secure erase](https://en.wikipedia.org/wiki/Parallel_ATA#HDD_passwords_and_security) command, to your target storage device, for this, see step-by-step instructions via: [wiki.kernel.org](https://ata.wiki.kernel.org/index.php/ATA_Secure_Erase). - + Finally, [srm](https://www.systutorials.com/docs/linux/man/1-srm/) can be use to securely remove files or directories, just run `srm -zsv /path/to/file` for a single pass over. @@ -3777,7 +3778,7 @@ categories: If you are an Android user, your device has Google built-in at its core. [Google tracks you](https://digitalcontentnext.org/blog/2018/08/21/google-data-collection-research/), collecting a wealth of information, and logging your every move. - + A [custom ROM](https://en.wikipedia.org/wiki/List_of_custom_Android_distributions), is an open source, usually Google-free mobile OS that can be flashed to your device. services: @@ -3786,7 +3787,7 @@ categories: github: GrapheneOS/hardened_malloc icon: https://grapheneos.org/apple-touch-icon.png description: | - GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay. + GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay. GrapheneOS is a young project, and currently only supports Pixel devices, partially due to their strong hardware security. - name: CalyxOS @@ -3795,8 +3796,8 @@ categories: github: CalyxOS/calyxos tosdrId: 2558 description: | - CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users. - Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently + CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users. + Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePlus 9 test builds available. Developed by the Calyx Foundation. - name: DivestOS @@ -3805,8 +3806,8 @@ categories: github: Divested-Mobile/DivestOS-Build tosdrId: 2550 description: | - DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of - discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained + DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of + discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained solely by Tad (SkewedZeppelin) since 2014. - name: LineageOS @@ -3815,17 +3816,17 @@ categories: github: LineageOS/android tosdrId: 7188 description: | - A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained, + A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained, supports a wide range of devices, and comes bundled with Privacy Guard. notableMentions: | [Replicant OS](https://www.replicant.us/) is a fully-featured distro, with an emphasis on freedom, privacy and security. - + [OmniRom](https://www.omnirom.org/), [Resurrection Remix OS](https://resurrectionremix.com/) and [Paranoid Android](http://paranoidandroid.co/) are also popular options. - + Alternatively, [Ubuntu Touch](https://ubports.com/) is a Linux (Ubuntu)- based OS. It is secure by design and runs on almost any device, - but it does fall short when it comes to the app store. @@ -3992,7 +3993,7 @@ categories: some technical knowledge to get started with, but once setup should perform just as any other Windows 10 system. Note that you should only download the LTSC ISO from the Microsoft's - [official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise) + [official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise) #### Improve the Security and Privacy of your current OS @@ -4221,8 +4222,8 @@ categories: and check reviews/ forums. Create a system restore point, before making any significant changes to your OS (such as disabling core features). - - From a security and privacy perspective, Linux may be a better option. + + From a security and privacy perspective, Linux may be a better option. notableMentions: | See also these lists: @@ -4295,7 +4296,7 @@ categories: that offer free solutions, even if you pay for the premium package. This includes (but not limited to) Avast, AVG, McAfee and Kasperky. For AV to be effective, it needs intermate access to all areas of your PC, - so it is important to go with a trusted vendor, and monitor its activity closely. + so it is important to go with a trusted vendor, and monitor its activity closely. - name: Development @@ -4350,7 +4351,7 @@ categories: [reputation](https://srlabs.de/bites/smart-spies) when it comes to protecting consumers privacy, there have been [many recent breaches](https://www.theverge.com/2019/10/21/20924886/alexa-google-home-security-vulnerability-srlabs-phishing-eavesdropping). - + For that reason it is recommended not to have these devices in your house. The following are open source AI voice assistants, that aim to provide a human voice interface while also protecting your privacy and security @@ -4386,7 +4387,7 @@ categories: [LinTO](https://linto.ai), [Jovo](https://www.jovo.tech) and [Snips](https://snips.ai) are private-by-design voice assistant frameworks that can be built on by developers, or used by enterprises. - + [Jasper](https://jasperproject.github.io), [Stephanie](https://github.com/SlapBot/stephanie-va) and [Hey Athena](https://github.com/rcbyron/hey-athena-client) are Python-based voice assistant, but neither is under active development anymore. @@ -4403,7 +4404,7 @@ categories: description: | An open source privacy-respecting Home Assistant, compatible with a wide range of devices including Raspberry Pi, desktop computers, or NAS systems. - Actively developed, with good french community and various integrations + Actively developed, with good french community and various integrations (Zigbee, Philips, Camera, Tuya, MQTT, Telegram, ...). url: https://gladysassistant.com/ github: gladysassistant/gladys @@ -4446,7 +4447,7 @@ categories: notableMentions: | Other privacy-focused cryptocurrencies include: [PIVX](https://pivx.org), - [Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/). + [Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/). wordOfWarning: | Not all cryptocurrencies are anonymous, and without using a privacy-focused coin, a record of your transaction will live on a publicly available distributed ledger, forever. @@ -4583,7 +4584,7 @@ categories: [BitMex](https://www.bitmex.com/) has more advanced trading features, but ID verification is required for higher value trades involving Fiat currency. - + For buying and selling alt-coins, [Binance](https://www.binance.com/en/register?ref=X2BHKID1) has a wide range of currencies, ~and ID verification is not needed for small-value trades~ but ID verification is required in most countries. @@ -4720,7 +4721,7 @@ categories: Over the past decade, social networks have revolutionized the way we communicate and bought the world closer together - but it came at the [cost of our privacy](https://en.wikipedia.org/wiki/Privacy_concerns_with_social_networking_services). - + Social networks are built on the principle of sharing - but you, the user should be able to choose with whom you share what, and that is what the following sites aim to do. @@ -4751,12 +4752,12 @@ categories: - name: nostr description: | - nostr stands for Notes and other stuff transmitted by relays. - It is an open protocol, not merely a platform. - This distinction enables truly censorship-resistant and global value-for-value publishing on the web. - With the power to replace data-greedy applications like Twitter and Instagram, - nostr offers a promising alternative for users seeking a more private and secure online experience - without algorithmic manipulations. ".... I feel like I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr. + nostr stands for Notes and other stuff transmitted by relays. + It is an open protocol, not merely a platform. + This distinction enables truly censorship-resistant and global value-for-value publishing on the web. + With the power to replace data-greedy applications like Twitter and Instagram, + nostr offers a promising alternative for users seeking a more private and secure online experience + without algorithmic manipulations. ".... I feel like I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr. url: https://github.com/nostr-protocol/nostr github: https://github.com/nostr-protocol @@ -4875,7 +4876,7 @@ categories: then [Listed.to](https://listed.to) is a public blogging platform with strong privacy features. It lets you publish posts directly through the Standard Notes app or web interface. - + Other minimalistic platforms include [Notepin.co](https://notepin.co) and [Pen.io](http://pen.io). Want to write a simple text post and promote it yourself? @@ -4957,7 +4958,7 @@ categories: url: https://newpipe.schabi.org description: An open source, privacy-respecting YouTube client for Android. # tosdrId: 2568 - - name: FreeTube + - name: FreeTube url: https://freetubeapp.io description: | An open source YouTube client for Windows, MacOS and Linux, providing @@ -5087,7 +5088,7 @@ categories: - name: Video Editors alternativeTo: ['adobe premiere pro', 'final cut pro', 'davinci resolve', 'imovie', 'sony vegas pro'] - services: + services: - name: Shotcut followWith: Windows, Mac OS, Linux description: | @@ -5152,7 +5153,7 @@ categories: github: NatronGitHub/Natron icon: https://natrongithub.github.io/img/Natron_icon.svg openSource: true - + - name: Audio Editors & Recorders alternativeTo: ['adobe audition', 'garageband', 'fl studio', 'ableton live'] services: @@ -5163,16 +5164,16 @@ categories: great free alternative to Adobe Audition. Features recording from real and virtual devices, import/export to a wide range of formats, high-quality processing - advanced multi-track editing, noise reduction, pitch correction, + advanced multi-track editing, noise reduction, pitch correction, audio restoration and much more. - It's easily extendable via community plugins, and + It's easily extendable via community plugins, and also supports cusotm macros and many scripting options url: https://www.audacityteam.org github: audacity/audacity icon: https://www.audacityteam.org/_astro/Audacity_Logo.63b57726.svg openSource: true tosdrId: 4516 - + - name: Casting & Streaming alternativeTo: ['xsplit', 'streamlabs obs', 'twitch studio', 'wirecast'] services: @@ -5189,11 +5190,11 @@ categories: icon: https://obsproject.com/assets/images/new_icon_small-r.png openSource: true tosdrId: 4227 - + - name: Screenshot Tools alternativeTo: ['snagit', 'greenshot', 'lightshot', 'gyazo', 'sharex'] services: [] - + - name: 3D Graphics alternativeTo: ['blender', 'autodesk maya', 'cinema 4d', '3ds max', 'sketchup'] services: @@ -5219,7 +5220,7 @@ categories: url: https://wings3d.com github: dgud/wings icon: https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Wings3d.png/120px-Wings3d.png - + - name: Animation alternativeTo: ['adobe after effects', 'animate cc', 'toon boom harmony', 'moho (anime studio)', 'pencil2d'] services: diff --git a/lib/requirements.txt b/lib/requirements.txt index 5a0d1aa..3e298b1 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,5 +1,2 @@ PyYAML==6.0.1 -requests==2.31.0 -jsonschema -pyyaml -termcolor +jsonschema==4.23.0 diff --git a/lib/schema.json b/lib/schema.json index 19ab787..d77f6a3 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -22,23 +22,23 @@ "name": { "type": "string" }, "description": { "type": "string" }, "url": { "type": "string" }, - "github": { "type": "string", "nullable": true }, - "icon": { "type": "string", "nullable": true }, - "followWith": { "type": "string", "nullable": true }, - "securityAudited": { "type": "boolean", "nullable": true }, - "openSource": { "type": "boolean", "nullable": true }, - "acceptsCrypto": { "type": "boolean", "nullable": true }, - "tosdrId": { "type": "number", "nullable": true }, - "iosApp": { "type": "string", "nullable": true }, - "androidApp": { "type": "string", "nullable": true }, - "discordInvite": { "type": "string", "nullable": true }, - "subreddit": { "type": "string", "nullable": true } + "github": { "type": ["string", "null"] }, + "icon": { "type": ["string", "null"] }, + "followWith": { "type": ["string", "null"] }, + "securityAudited": { "type": ["boolean", "null"] }, + "openSource": { "type": ["boolean", "null"] }, + "acceptsCrypto": { "type": ["boolean", "null"] }, + "tosdrId": { "type": ["number", "null"] }, + "iosApp": { "type": ["string", "null"] }, + "androidApp": { "type": ["string", "null"] }, + "discordInvite": { "type": ["string", "null"] }, + "subreddit": { "type": ["string", "null"] } }, "required": ["name", "description", "url"], "additionalProperties": false } }, - "intro": { "type": "string", "nullable": true }, + "intro": { "type": ["string", "null"] }, "notableMentions": { "oneOf": [ { @@ -54,16 +54,20 @@ "additionalProperties": false } }, - { "type": "string" } - ], - "nullable": true + { "type": "string" }, + { "type": "null" } + ] }, - "furtherInfo": { "type": "string", "nullable": true }, - "wordOfWarning": { "type": "string", "nullable": true }, + "furtherInfo": { "type": ["string", "null"] }, + "wordOfWarning": { "type": ["string", "null"] }, "alternativeTo": { - "type": "array", - "items": { "type": "string" }, - "nullable": true + "oneOf": [ + { + "type": "array", + "items": { "type": "string" } + }, + { "type": "null" } + ] } }, "required": ["name", "services"], diff --git a/lib/validate-awesome-privacy.py b/lib/validate-awesome-privacy.py index 83b26cd..ea7fa35 100644 --- a/lib/validate-awesome-privacy.py +++ b/lib/validate-awesome-privacy.py @@ -1,85 +1,112 @@ import json import os import sys -import logging + import yaml -from termcolor import colored from jsonschema import Draft7Validator -# Configure Logging -LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() -logging.basicConfig(level=LOG_LEVEL) -logger = logging.getLogger(__name__) +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +SCHEMA_PATH = os.path.join(PROJECT_ROOT, "lib/schema.json") + +# Exit codes +EXIT_VALID = 0 +EXIT_VALIDATION_ERRORS = 1 +EXIT_RUNTIME_ERROR = 2 + +MAX_ERRORS = 20 + +# ANSI color helpers (disabled when NO_COLOR is set or stderr is not a TTY) +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) +dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s) -# Determine the project root based on the script's location -project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -awesome_privacy_path = os.path.join(project_root, 'awesome-privacy.yml') -schema_path = os.path.join(project_root, 'lib/schema.json') +def resolve_path(data, path_parts): + """Walk the data along path_parts, replacing indices with 'name' values.""" + segments = [] + current = data + for part in path_parts: + if isinstance(current, dict) and part in current: + current = current[part] + if isinstance(current, dict) and "name" in current: + segments.append(current["name"]) + elif not isinstance(part, int): + pass # skip dict keys like 'categories', 'sections', 'services' + elif isinstance(current, list) and isinstance(part, int) and part < len(current): + current = current[part] + if isinstance(current, dict) and "name" in current: + segments.append(current["name"]) + else: + segments.append(str(part)) + else: + segments.append(str(part)) + break + return " > ".join(segments) if segments else "(root)" -# Log method, accepts a message and optional log level -# and prints the output to the terminal in right color -def loggy(message: str, level: str = 'debug'): - if level == "info": - logger.info(colored(message, 'blue')) - elif level == "warning": - logger.warning(colored(message, 'yellow')) - elif level == "error": - logger.error(colored(message, 'red')) - elif level == "success": - logger.info(colored(message, 'green')) - elif level == "debug": - logger.debug(colored(message, 'grey')) - - -# Loads a given YAML file and returns the data -def load_yaml(yaml_path: str): - loggy(f"Loading YAML from {yaml_path}", "info") +def load_yaml(path): try: - with open(yaml_path, 'r') as file: - return yaml.safe_load(file) + with open(path, "r") as f: + return yaml.safe_load(f) + except FileNotFoundError: + print(red(f"File not found: {path}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) except yaml.YAMLError as e: - loggy(f"Failed to load YAML: {e}", "error") - sys.exit(1) + print(red(f"Failed to parse YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) -# Loads a given JSON Schema file and returns the data -def load_schema(schema_path: str): - loggy(f"Loading JSON Schema from {schema_path}", "info") +def load_schema(path): try: - with open(schema_path, 'r') as file: - return json.load(file) + with open(path, "r") as f: + return json.load(f) + except FileNotFoundError: + print(red(f"File not found: {path}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) except json.JSONDecodeError as e: - loggy(f"Failed to load JSON Schema: {e}", "error") - sys.exit(1) + print(red(f"Failed to parse JSON schema: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) -# Validates the given YAML data against the given JSON Schema -def validate_yaml(data, schema): - loggy("Beginning validation", "info") +def validate(data, schema): validator = Draft7Validator(schema) - errors = sorted(validator.iter_errors(data), key=lambda e: e.path) - if errors: - for error in errors: - error_location = "->".join(map(str, error.path)) - loggy(f"Validation error: {error.message} (at {error_location})", "warning") - return False - return True + errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path)) + formatted = [] + for error in errors: + location = resolve_path(data, list(error.path)) + formatted.append(f"{location}: {error.message}") + return formatted -# Main method def main(): - loggy("Starting...", "info") - yaml_data = load_yaml(awesome_privacy_path) - schema = load_schema(schema_path) + data = load_yaml(DATA_PATH) + schema = load_schema(SCHEMA_PATH) + errors = validate(data, schema) - if validate_yaml(yaml_data, schema): - loggy("Validation successful!", "success") - sys.exit(0) - else: - loggy("Validation failed.", "error") - sys.exit(1) + if errors: + shown = errors[:MAX_ERRORS] + for msg in shown: + print(red("ERROR") + " " + msg, file=sys.stderr) + if len(errors) > MAX_ERRORS: + print(dim(f"...and {len(errors) - MAX_ERRORS} more"), file=sys.stderr) + print(red(f"Validation failed: {len(errors)} error(s)"), file=sys.stderr) + sys.exit(EXIT_VALIDATION_ERRORS) + + # Gather stats + categories = data.get("categories", []) + num_categories = len(categories) + num_sections = sum(len(c.get("sections", [])) for c in categories) + num_services = sum( + len(s.get("services", [])) + for c in categories + for s in c.get("sections", []) + ) + print(green(f"Valid! {num_categories} categories, {num_sections} sections, {num_services} services")) + sys.exit(EXIT_VALID) if __name__ == "__main__": From adc0df53a5baf977ec1a881e79385ecbfd8ccedb Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Sun, 22 Feb 2026 17:01:12 +0000 Subject: [PATCH 15/56] Adds PR checks --- .github/workflows/pr-check.yml | 125 +++++++ .github/workflows/pr-comment.yml | 105 ++++++ .../__pycache__/check-links.cpython-310.pyc | Bin 0 -> 5842 bytes .../check-readme-edits.cpython-310.pyc | Bin 0 -> 4080 bytes .../check-template.cpython-310.pyc | Bin 0 -> 5359 bytes .../check-yaml-diff.cpython-310.pyc | Bin 0 -> 9772 bytes .../detect-changes.cpython-310.pyc | Bin 0 -> 1836 bytes .../__pycache__/warn-non-yaml.cpython-310.pyc | Bin 0 -> 2220 bytes lib/checks/check-links.py | 211 +++++++++++ lib/checks/check-readme-edits.py | 123 +++++++ lib/checks/check-template.py | 206 +++++++++++ lib/checks/check-yaml-diff.py | 342 ++++++++++++++++++ lib/checks/detect-changes.py | 50 +++ lib/checks/warn-non-yaml.py | 58 +++ lib/requirements.txt | 1 + lib/schema.json | 69 ++-- 16 files changed, 1269 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/pr-check.yml create mode 100644 .github/workflows/pr-comment.yml create mode 100644 lib/checks/__pycache__/check-links.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-readme-edits.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-template.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc create mode 100644 lib/checks/__pycache__/detect-changes.cpython-310.pyc create mode 100644 lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc create mode 100644 lib/checks/check-links.py create mode 100644 lib/checks/check-readme-edits.py create mode 100644 lib/checks/check-template.py create mode 100644 lib/checks/check-yaml-diff.py create mode 100644 lib/checks/detect-changes.py create mode 100644 lib/checks/warn-non-yaml.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..109df8e --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,125 @@ +name: PR Check + +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check-pr: + name: Check PR + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch base ref + run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install -q -r lib/requirements.txt + + - name: Detect changed files + id: changes + run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Check for direct README edits + id: readme + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Schema validation + id: schema + if: steps.changes.outputs.yaml_changed == 'true' + run: make validate + + - name: YAML diff analysis + id: diff + if: steps.changes.outputs.yaml_changed == 'true' + run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Link validation + if: steps.changes.outputs.yaml_changed == 'true' && steps.diff.outputs.has_service_changes == 'true' + run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + + - name: PR template check + id: template + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: python lib/checks/check-template.py + + - name: Non-YAML changes warning + if: steps.changes.outputs.non_yaml_changed == 'true' + run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Save PR metadata + if: always() + run: | + mkdir -p /tmp/pr-meta + echo "${{ github.event.pull_request.number }}" > /tmp/pr-meta/number.txt + echo "${{ github.run_id }}" > /tmp/pr-meta/run-id.txt + + - name: Build failure comment + if: failure() + run: | + cat > /tmp/pr-meta/comment.md << 'HEADER' + <!-- pr-check-bot --> + > [!CAUTION] + > ## PR check failed + HEADER + + if [ "${{ steps.readme.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Direct README edits + The auto-generated section of the README must not be edited directly. + Please make your changes in `awesome-privacy.yml` instead — the README is regenerated from it. + EOF + fi + + if [ "${{ steps.schema.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Schema validation failed + `awesome-privacy.yml` does not conform to the expected schema. + Please check your YAML against the structure defined in `lib/schema.json`. + EOF + fi + + if [ "${{ steps.diff.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Single-entry rule + Each PR should add, modify, or remove only **one** service at a time. + If you need to change multiple services, please split them into separate PRs. + EOF + fi + + if [ "${{ steps.template.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### PR template incomplete + Your PR description is missing required sections. + Please fill out the **Type**, **Changes**, and **Checklist** sections of the PR template. + EOF + fi + + # Footer with link to logs + printf '\n---\n*See the [workflow logs](%s/%s/actions/runs/%s) for full details.*\n' \ + "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" \ + >> /tmp/pr-meta/comment.md + + - name: Upload PR metadata + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-meta + path: /tmp/pr-meta/ diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 0000000..4034dba --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -0,0 +1,105 @@ +name: PR Comment + +on: + workflow_run: + workflows: ["PR Check"] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + comment: + name: Post PR comment + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Download PR metadata + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: pr-meta + path: pr-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post or update comment + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const marker = '<!-- pr-check-bot -->'; + const conclusion = context.payload.workflow_run.conclusion; + + // Determine the PR number + let prNumber; + const numberFile = 'pr-meta/number.txt'; + if (fs.existsSync(numberFile)) { + prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim()); + } else { + // workflow_run.pull_requests is empty for fork PRs, so + // fall back to searching by head SHA if needed + const prs = context.payload.workflow_run.pull_requests; + if (prs && prs.length > 0) { + prNumber = prs[0].number; + } else { + const headSha = context.payload.workflow_run.head_sha; + const { data: prList } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 10, + }); + const match = prList.find(pr => pr.head.sha === headSha); + if (!match) { + console.log(`No open PR found for SHA ${headSha} — skipping comment.`); + return; + } + prNumber = match.number; + } + } + + // Find existing bot comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(c => c.body.includes(marker)); + + if (conclusion === 'failure') { + const commentFile = 'pr-meta/comment.md'; + if (!fs.existsSync(commentFile)) { + console.log('No comment.md found — skipping comment.'); + return; + } + const body = fs.readFileSync(commentFile, 'utf8').trim(); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + } else if (existing) { + // Checks passed — remove stale failure comment + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } diff --git a/lib/checks/__pycache__/check-links.cpython-310.pyc b/lib/checks/__pycache__/check-links.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fc6b746504ea50ff0d556d17c5e0d41c9b48b6b GIT binary patch literal 5842 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc b/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6eb4ca82fce5980dd4238dec331d8b3f40153493 GIT binary patch literal 4080 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-template.cpython-310.pyc b/lib/checks/__pycache__/check-template.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d49b2561d824ecb8c9fd9a6115aa3b9f6c3ec9e1 GIT binary patch literal 5359 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51c91bfd2bd882b16391f3f81379554355e72e06 GIT binary patch literal 9772 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/detect-changes.cpython-310.pyc b/lib/checks/__pycache__/detect-changes.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89c86010b6f042edb3abb57013bcbbd2161f57d6 GIT binary patch literal 1836 zcmZux-ER{|5Z~SV@cA==q!6{Jx*}A`YHiwwwh{_a1ED}glZb>?SVE`s?Z&=vzH@fh zF}BW9EAxt~KJ@`bvLE?}`mnEk>c4<g(b;tf5%g9wyE{8OH#_s2$*R>7f^X1#8UN`Z z^taxe|BA5r9!PLt6txh=lq9%?%}iRv%vQ@Xv)!^8Ido?32fV<%IbMubky@=1wW;$Q zwaV0`1(++WO1-Cqf5$3Qgb^ishU&%fv(grmERw<>cH^k)AH)e0e$)-q4x@gX$suE@ zFT2d&edxDC!Td0#{=GY!Tg}oQgKKwVd2i76HzOHmsqnLb><^^4;g7;z5}exk(oeHA zI4^0I{(%>x?eR)DWFqUaMxV#WVKi!vdPzj|+39D)>;c^d5-ng79pV`TW{xK$C;SU( z<%E(c`F(X_<<`v3(Mv?F90Rvc>^a8h7YMBlV`{<E(H{C5iEBfU>+x8J17w=&&bF%F zy}y6&(XC+T(f;nEeTBo<_@=_8x}!)YRDq?(G0#$Eb(mCk)@P}5hCG(6ZYw(tdrUdU zVKQK<ya+^~<Eik$E5w(;_t#gFxP3k9vgoO}PEFt&ix`P!f23B7sh|_f?w}nkEG+AH zSAbQpjVpK!Uwvc0;TJ#<0iAsXSZOk0`U@61Od2&W@C4=PkWfowg1Rpt2sxTrb2P!y zK6Ge-dbF71Ll**8Iw2h!^kB#WEx&LkgjQ(vIm*eQH!IH3tTabkXo@G6EYsQ)f|y%d z=t*&6!+t5ZU!oJDrAOb&N^Tvai6g6YnO*=3pf|;E)0H)PQ7+}q6wj9DXi6{w$qGo^ zbCNP$%^mRQqa6G?v9(Q;qD$N3D+|P)fph`cIQ2uoXdSCEWg_BnUjuSnYBV%z8=M{N zkFVU)0EY1Tu=Qo;)A-=P4?AI;3Rx%0<Lqb<b4HcjWl0~h97C41(zx7c==3(SG#QQE zW>Z%H<h&MzeL3LlG+D})?3q-K$*{7bA&u}^iV)Jcwt)74#44~3q~eJ?!8zt6$A6F` zC`<zs!(I_ZnqTo6D83VN4G2|QPhugXtk>VTs*P#Ot7yE|gQqmtlPn4ou>p&>O5@t? z(~>sPx-llc|G%yYZEX$MZ`!qjc1@zE*3VETN5o_RZ$pfWk2imM5d3ie!5#hyD8KJs zalOM*_N>o0>e`;JVe;!GG`{?ve{Z!^<$QBc%kQ^~arON9&GE{I9pz^Ig7QM%>4#h} zRomp9L64<!S8uthgp>wwYGkTpl=NPxk{Go6Jd2nR%Ho4mc^WSP07O-GAMX5k=k|W^ zaA#*<xk6GXN#zI$;DUSoB$i2>GNBx3Ua91t!ZVc>rXy3H$^u2@B-xPhdO^A7pIq7I zuP$d|kVsWM?U1HVh()S>FvCC7Ot*{$GfB5~^e%%;=$u#ZGWM`dE@BrG?Ebsvdc@N* zkX!^>Bm5gsQ(h40dqGgIsX`FYED8dy4RBYp&$W&vMnc*BP<B;;#@sZrLf95Y0tPf_ z;FeCNd8K*anR%@ar|-NqE90`a-plA9VH>&(46bbCIUYFf;cLVup5+z1k4xBo^B<H) B>01B* literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53295211e3ced8b7d90b3ec0a65ca9afa2498c2f GIT binary patch literal 2220 zcmb7F-ER{|5Z}E!pD$<UJA{-VbyZraPO$+YA)qKCgixr+hms(K5xShc8~e=p&e>gK ze6pXyW235ls8pdyw)(`sw6A^YKhOuHmd;)Xp@3BN_I7q>c6N4l=eM)*=%|a}`>pgc zxS2)hcg?*2jR12Ks`v#6MP)=WH9}m*X={{?v^C3S+FE6c8I8=q{)lFoGsH)wJVMQ~ z%dAFjkRPJ>66lT&#;7&Kc~l-}<NPYkQ2PanOq$hgd4f801lmb9Mct<czsDx`5k}P5 zN^2pP{NO(Hc_c`u&LZNIjZM;wX|NkGL3V=>Xe{fD6Iu6RxWh!;WW^Q_p83^osoM-o z?sh#81b+6s#j27~vKw>qkX1w=8F|p6J~SX%4MY{Ji}R$?mZa%-NyI>zJ(t`koIMLz zr{w+tCxzCM=}#=AoV9gtdHw$SW)*9#x1a8tP?w>KJP?X|=p}6K(A+`=b1litKfZjq znOO6WFE(GHS6E@e^@NnXo)3e{g=(EupNb0|IEP{sN5!rW`)YNSb0hSd73wceYU2iw zJmL^(^pOYJi_`-PIz0p{<F%tzwBTetZfjPXcxTc=RbbzJ4)k<Qtz7}T5;ao59N<1! zZWz?0mJafPiGd%OL)6F8YGfeb5b7Ro*sy!|(5P9U2SsLS?jX}QXr7L~Ks}@33`T}% z;11Dkw2%8H9ZThA%2_>2d`rQ9nUi@szK`W-V~kE57_gRg8$HSQGcrdfdzqK$&^Q^} zyCcUN6N5=QHN<_pXY*g_G@Yp#{cJib*U$8_gDHAS&!e-4<`84F=clsMx}WyubWcwA zZ2H;0@#8ei$qZ)bnZc=Ec8KYj(@2j$go|WB*@xY0KQAHia|h(QJkr1hnXZNTwS;J3 z6TcDz-gH<WEv?>9F-@uf<{A?Pta6lzDi2yRh@-?U7AwABMb37&le4#=OKO1xaPATr z6Sx$+#IN~5BxJ!*4rhDqfHSJBItyFM1XX2er6gA@Mm}7VI10OoT`JvOS-M@Y)L7MT z$u?&mIMix`vIOv}Oo~2bRy*`xu+nhTINgFOW`NWn+)wO2?qP29@K44b+~E^sy)u+h zx~TB(i6{k|a&Lrzkkz=^TKrs_(spN1GS`HSlx~D^)eprYbdHriazZ0Gbz<(=2}e62 z(NpsSN8mh1^dWn9?%iME8sQ%}!}UH0)YOYPlw3)Z$N$Ya*I^+`C#S?9L`{EpZ~N|p zTi(|8%7(Y~V0CqA^HFm4+&Qv#9P{O)aHn_(qMDmr`|o9UK9PcUuDgj@Bs;J1jzUSi zNEQ~>V#%&AERfY$NS)n?sK@P)5*_-ZMA{{vG*vor?=2eBocI=0#HTdSnDoPUxlv(X zVYe`=9G}-(J{L@lE%92r$s)O-S+4Rh&4bZYrrcCX^Fq0zU1{;S%7joRZ%4|}NcRBR zD!;M0{{71Gwzs*yzO8H_Dd1COgoJAW^IBmb!ysZpWgzWGQrTS=hH*!kqAOB$#<?<L zp|UJ`7VtQNtH40bZ?#wiw<Cr|WjY+517$VhASyV@PF>^5N}aPg6YWr{(W8@~Ui$DT z2QICrb1|O=$f&$%H=921rlTs8`t$+5l%-LvK_;#O$>Ry^V9S`oHa4*R*PJ~DoQ0<h z2hZZXX<@@Ku=TfN&0rTw-k8N-ys`e|*YtXh=V@!6SD02=&!chG^Y~d9;ooRNG${a4 zR?C-lm8AjKw_auaiby43KyL(QozuFfG+EFS>_GTZcKLZNDQb!9wILeSIE*>}T1$^T x&=$dfP6Y1}{ffZr1P^sPWQ+Ro5odr{$jLacV+Vg}SjL<=gB{cP%Ei{3e*o$%PP703 literal 0 HcmV?d00001 diff --git a/lib/checks/check-links.py b/lib/checks/check-links.py new file mode 100644 index 0000000..293fa24 --- /dev/null +++ b/lib/checks/check-links.py @@ -0,0 +1,211 @@ +""" +Validates URLs and metadata for added/modified services in a PR. +Reads the diff JSON produced by check-yaml-diff.py. +All checks are warnings only -- this script never fails (exit 0). +""" + +import argparse +import json +import os +import sys + +import requests +import yaml + +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") + +# Exit codes +EXIT_PASS = 0 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + +TIMEOUT = 10 +USER_AGENT = "awesome-privacy-ci/1.0" +DESC_MIN_LEN = 50 +DESC_MAX_LEN = 250 + + +def check_url(url, label): + """Check if a URL is reachable. Returns (ok, message).""" + try: + resp = requests.head( + url, + timeout=TIMEOUT, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + if resp.status_code >= 400: + # Retry with GET -- some servers reject HEAD + resp = requests.get( + url, + timeout=TIMEOUT, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + stream=True, + ) + resp.close() + if resp.status_code >= 400: + return False, f"{label}: HTTP {resp.status_code} for {url}" + return True, None + except requests.RequestException as e: + return False, f"{label}: Connection error for {url} ({type(e).__name__})" + + +def check_service(service_data, service_name, category, section): + """Run all checks on a single service. Returns list of warning strings.""" + warnings = [] + name_prefix = f"{category} > {section} > {service_name}" + + # Check url + url = service_data.get("url") + if url: + ok, msg = check_url(url, "url") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + else: + warnings.append(f"{name_prefix}: missing required field 'url'") + + # Check icon + icon = service_data.get("icon") + if icon: + ok, msg = check_url(icon, "icon") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + else: + warnings.append(f"{name_prefix}: missing 'icon' field (recommended by contributing guide)") + + # Check iosApp + ios_app = service_data.get("iosApp") + if ios_app: + ok, msg = check_url(ios_app, "iosApp") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + + # Check github + github = service_data.get("github") + if github: + github_url = f"https://github.com/{github}" + ok, msg = check_url(github_url, "github") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + + # Check description length + desc = service_data.get("description", "") + desc_stripped = desc.strip() + desc_len = len(desc_stripped) + if desc_len < DESC_MIN_LEN: + warnings.append(f"{name_prefix}: description too short ({desc_len} chars, minimum {DESC_MIN_LEN})") + elif desc_len > DESC_MAX_LEN: + warnings.append(f"{name_prefix}: description too long ({desc_len} chars, maximum {DESC_MAX_LEN})") + + return warnings + + +def find_service_in_head(category, section, service_name): + """Look up a service in the head YAML by category/section/service name.""" + try: + with open(DATA_PATH, "r") as f: + data = yaml.safe_load(f) + for cat in data.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + for svc in sec.get("services", []): + if svc.get("name") == service_name: + return svc + except Exception: + pass + return None + + +def write_step_summary(all_warnings, services_checked): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## Link Validation\n"] + + if not services_checked: + lines.append("No services to check.\n") + elif not all_warnings: + lines.append(f"All checks passed for {services_checked} service(s).\n") + else: + lines.append(f"Checked {services_checked} service(s), found {len(all_warnings)} warning(s):\n") + lines.append("| Warning |") + lines.append("|---------|") + for w in all_warnings: + escaped = w.replace("|", "\\|") + lines.append(f"| {escaped} |") + lines.append("") + lines.append("> **Note:** Link warnings are informational only and do not fail the check. " + "URLs may be temporarily down or block automated requests.\n") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Validate links for added/modified services") + parser.add_argument("--diff-json", required=True, help="Path to the diff JSON file") + args = parser.parse_args() + + # Load diff + try: + with open(args.diff_json, "r") as f: + diff = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(red(f"Failed to load diff JSON: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + all_warnings = [] + services_checked = 0 + + # Check added services + for svc in diff.get("services", {}).get("added", []): + services_checked += 1 + warnings = check_service( + svc.get("fields", {}), + svc["service"], + svc["category"], + svc["section"], + ) + all_warnings.extend(warnings) + + # Check modified services -- only if they have URL-related field changes + for svc in diff.get("services", {}).get("modified", []): + url_fields = {"url", "icon", "iosApp", "github", "description"} + changed = set(svc.get("changed_fields", [])) + if changed & url_fields: + services_checked += 1 + head_svc = find_service_in_head(svc["category"], svc["section"], svc["service"]) + if head_svc: + warnings = check_service( + head_svc, + svc["service"], + svc["category"], + svc["section"], + ) + all_warnings.extend(warnings) + + # Print results + if all_warnings: + print(yellow(f"Link validation: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + else: + print(green(f"Link validation passed. {services_checked} service(s) checked.")) + + write_step_summary(all_warnings, services_checked) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-readme-edits.py b/lib/checks/check-readme-edits.py new file mode 100644 index 0000000..aa733b5 --- /dev/null +++ b/lib/checks/check-readme-edits.py @@ -0,0 +1,123 @@ +""" +Fails if the PR directly edits the auto-generated section of the README. +The generated section is between <!-- awesome-privacy-start --> and <!-- awesome-privacy-end -->. +""" + +import argparse +import os +import re +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +README_PATH = ".github/README.md" +README_ABS = os.path.join(PROJECT_ROOT, README_PATH) + +# Exit codes +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) + + +def get_changed_files(base_ref): + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + return result.stdout.strip().splitlines() + + +def get_marker_lines(): + """Find the line numbers of the start/end markers in the README.""" + try: + with open(README_ABS, "r") as f: + lines = f.readlines() + except FileNotFoundError: + return None, None + + start_line = None + end_line = None + for i, line in enumerate(lines, start=1): + if "<!-- awesome-privacy-start -->" in line: + start_line = i + if "<!-- awesome-privacy-end -->" in line: + end_line = i + + return start_line, end_line + + +def get_changed_line_numbers(base_ref): + """Parse git diff hunk headers to find which lines were changed in the README.""" + result = subprocess.run( + ["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_lines = [] + for line in result.stdout.splitlines(): + # Match hunk headers like @@ -10,5 +12,7 @@ + match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line) + if match: + start = int(match.group(1)) + count = int(match.group(2)) if match.group(2) else 1 + for n in range(start, start + count): + changed_lines.append(n) + + return changed_lines + + +def write_step_summary(): + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = [ + "## Direct README Edit Detected\n", + "This PR directly modifies the auto-generated section of `.github/README.md` " + "(between `<!-- awesome-privacy-start -->` and `<!-- awesome-privacy-end -->`).\n", + "**Please edit `awesome-privacy.yml` instead.** The README is regenerated automatically from that file.\n", + ] + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Check for direct README edits to generated section") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + # Skip if README wasn't changed + changed_files = get_changed_files(args.base_ref) + if README_PATH not in changed_files: + print(green("README not modified, skipping.")) + sys.exit(EXIT_PASS) + + # Find marker lines + start_line, end_line = get_marker_lines() + if start_line is None or end_line is None: + print("Could not find generated-section markers in README, skipping check.") + sys.exit(EXIT_PASS) + + # Check if any changed lines fall within the generated section + changed_lines = get_changed_line_numbers(args.base_ref) + for line_num in changed_lines: + if start_line <= line_num <= end_line: + print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr) + print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr) + write_step_summary() + sys.exit(EXIT_FAIL) + + print(green("README changes are outside the generated section, OK.")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-template.py b/lib/checks/check-template.py new file mode 100644 index 0000000..b53671d --- /dev/null +++ b/lib/checks/check-template.py @@ -0,0 +1,206 @@ +""" +Checks PR body against the pull request template. +Reads the PR body from the PR_BODY environment variable (avoids shell injection). +Exits with code 1 for severe violations (empty body, missing required sections). +""" + +import os +import re +import sys + +# Exit codes +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_RUNTIME_ERROR = 2 + +# Warnings that should cause a hard failure +CRITICAL_WARNINGS = { + "PR body is empty or not provided", + "Type section is missing", + "Type section is empty", + "Changes section is missing", + "Changes section is empty", + "Checklist section is missing", + "Checklist section does not contain any checkbox items", +} + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + +# Valid PR types from the template +VALID_TYPES = {"Addition", "Amendment", "Removal", "Spelling or Grammar", "Website Update", "Misc"} + +# Raw template text that indicates an unfilled section +RAW_TYPE_LINE = "Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc" + + +def strip_html_comments(text): + """Remove <!-- ... --> comments from text.""" + return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip() + + +def extract_section(body, header): + """Extract content between a ### header and the next --- or ### header.""" + pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" + match = re.search(pattern, body, re.DOTALL) + if match: + return match.group(1) + return None + + +def check_type_section(content): + """Check the Type section. Returns list of warning strings.""" + warnings = [] + + if content is None: + warnings.append("Type section is missing") + return warnings + + cleaned = strip_html_comments(content).strip() + + if not cleaned: + warnings.append("Type section is empty") + return warnings + + # Check for raw template text (unchanged) + if RAW_TYPE_LINE in cleaned: + warnings.append("Type section appears unchanged from the template -- please select one type") + return warnings + + # Check how many valid types are present + found_types = [t for t in VALID_TYPES if t in cleaned] + + if len(found_types) == 0: + warnings.append(f"Type section does not contain a recognized type. Expected one of: {', '.join(sorted(VALID_TYPES))}") + elif len(found_types) > 1: + warnings.append(f"Type section contains multiple types ({', '.join(found_types)}) -- please select only one") + + return warnings + + +def check_text_section(content, section_name): + """Check a text section (Changes, Supporting Material, Affiliation). Returns list of warnings.""" + warnings = [] + + if content is None: + warnings.append(f"{section_name} section is missing") + return warnings + + cleaned = strip_html_comments(content).strip() + + if not cleaned: + warnings.append(f"{section_name} section is empty") + + return warnings + + +def check_checklist(content): + """Check the Checklist section. Returns list of warnings.""" + warnings = [] + + if content is None: + warnings.append("Checklist section is missing") + return warnings + + checked = re.findall(r"- \[x\]", content, re.IGNORECASE) + unchecked = re.findall(r"- \[ \]", content) + + total = len(checked) + len(unchecked) + + if total == 0: + warnings.append("Checklist section does not contain any checkbox items") + return warnings + + if unchecked: + warnings.append(f"Checklist has {len(unchecked)} unchecked item(s) out of {total}") + + return warnings + + +def has_critical_warnings(warnings): + """Return True if any warning is a critical (hard-fail) violation.""" + return any(w in CRITICAL_WARNINGS for w in warnings) + + +def write_step_summary(all_warnings): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## PR Template Check\n"] + + if not all_warnings: + lines.append("All template checks passed.\n") + else: + critical = has_critical_warnings(all_warnings) + lines.append(f"Found {len(all_warnings)} warning(s):\n") + for w in all_warnings: + lines.append(f"- {w}") + lines.append("") + if critical: + lines.append("> **Error:** One or more required sections are missing or empty. " + "Please fill out the PR template before this check can pass.\n") + else: + lines.append("> **Note:** Template warnings are informational and do not fail the check. " + "Reviewers will verify compliance.\n") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + pr_body = os.environ.get("PR_BODY") + + if pr_body is None or pr_body.strip() == "": + all_warnings = [ + "PR body is empty or not provided", + "Type section is missing", + "Changes section is missing", + "Supporting Material section is missing", + "Affiliation section is missing", + "Checklist section is missing", + ] + print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + write_step_summary(all_warnings) + sys.exit(EXIT_FAIL) + + all_warnings = [] + + # Check each section + type_content = extract_section(pr_body, "Type") + all_warnings.extend(check_type_section(type_content)) + + changes_content = extract_section(pr_body, "Changes") + all_warnings.extend(check_text_section(changes_content, "Changes")) + + supporting_content = extract_section(pr_body, "Supporting Material") + all_warnings.extend(check_text_section(supporting_content, "Supporting Material")) + + affiliation_content = extract_section(pr_body, "Affiliation") + all_warnings.extend(check_text_section(affiliation_content, "Affiliation")) + + checklist_content = extract_section(pr_body, "Checklist") + all_warnings.extend(check_checklist(checklist_content)) + + # Print results + if all_warnings: + print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + else: + print(green("PR template check passed.")) + + write_step_summary(all_warnings) + if has_critical_warnings(all_warnings): + sys.exit(EXIT_FAIL) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py new file mode 100644 index 0000000..2931e68 --- /dev/null +++ b/lib/checks/check-yaml-diff.py @@ -0,0 +1,342 @@ +""" +Analyzes the diff between base and head versions of awesome-privacy.yml. +Enforces the single-entry rule: only one service addition/amendment/removal per PR. +Outputs a JSON diff to /tmp/pr-diff.json and writes a step summary. +""" + +import argparse +import json +import os +import subprocess +import sys + +import yaml + +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_OUTPUT_PATH = "/tmp/pr-diff.json" + +# Exit codes +EXIT_PASS = 0 +EXIT_RULE_VIOLATION = 1 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) +dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s) + + +def load_base_yaml(base_ref): + """Load the YAML from the base ref using git show.""" + try: + result = subprocess.run( + ["git", "show", f"{base_ref}:awesome-privacy.yml"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + return yaml.safe_load(result.stdout) + except subprocess.CalledProcessError: + # File doesn't exist in base (completely new file) + print(yellow("Warning: awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr) + return {"categories": []} + except yaml.YAMLError as e: + print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def load_head_yaml(): + """Load the YAML from the current working tree.""" + try: + with open(DATA_PATH, "r") as f: + return yaml.safe_load(f) + except FileNotFoundError: + print(red(f"File not found: {DATA_PATH}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + except yaml.YAMLError as e: + print(red(f"Failed to parse head YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def build_service_index(data): + """Build a dict keyed by (category, section, service_name) -> service dict.""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + for sec in cat.get("sections", []): + sec_name = sec.get("name", "") + for svc in sec.get("services", []): + svc_name = svc.get("name", "") + key = (cat_name, sec_name, svc_name) + index[key] = svc + return index + + +def build_section_index(data): + """Build a dict keyed by (category, section) -> section metadata (excluding services).""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + for sec in cat.get("sections", []): + sec_name = sec.get("name", "") + key = (cat_name, sec_name) + meta = {k: v for k, v in sec.items() if k != "services"} + index[key] = meta + return index + + +def build_category_index(data): + """Build a dict keyed by category_name -> category metadata (excluding sections).""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + meta = {k: v for k, v in cat.items() if k != "sections"} + index[cat_name] = meta + return index + + +def diff_services(base_data, head_data): + """Find added, removed, and modified services.""" + base_idx = build_service_index(base_data) + head_idx = build_service_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + added = [] + for key in sorted(head_keys - base_keys): + added.append({ + "category": key[0], + "section": key[1], + "service": key[2], + "fields": head_idx[key], + }) + + removed = [] + for key in sorted(base_keys - head_keys): + removed.append({ + "category": key[0], + "section": key[1], + "service": key[2], + }) + + modified = [] + for key in sorted(base_keys & head_keys): + base_svc = base_idx[key] + head_svc = head_idx[key] + if base_svc != head_svc: + changed_fields = [] + all_fields = set(base_svc.keys()) | set(head_svc.keys()) + for field in sorted(all_fields): + old_val = base_svc.get(field) + new_val = head_svc.get(field) + if old_val != new_val: + changed_fields.append(field) + modified.append({ + "category": key[0], + "section": key[1], + "service": key[2], + "changed_fields": changed_fields, + }) + + return added, removed, modified + + +def diff_sections(base_data, head_data): + """Find section-level metadata changes (intro, wordOfWarning, etc.).""" + base_idx = build_section_index(base_data) + head_idx = build_section_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + changes = [] + + # New sections + for key in sorted(head_keys - base_keys): + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "added_section", + }) + + # Removed sections + for key in sorted(base_keys - head_keys): + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "removed_section", + }) + + # Modified section metadata + for key in sorted(base_keys & head_keys): + base_meta = base_idx[key] + head_meta = head_idx[key] + if base_meta != head_meta: + changed_fields = [] + all_fields = set(base_meta.keys()) | set(head_meta.keys()) + for field in sorted(all_fields): + if base_meta.get(field) != head_meta.get(field): + changed_fields.append(field) + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "modified_section_metadata", + "changed_fields": changed_fields, + }) + + return changes + + +def diff_categories(base_data, head_data): + """Find structural category changes.""" + base_idx = build_category_index(base_data) + head_idx = build_category_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + changes = [] + + for name in sorted(head_keys - base_keys): + changes.append({"category": name, "change_type": "added_category"}) + + for name in sorted(base_keys - head_keys): + changes.append({"category": name, "change_type": "removed_category"}) + + return changes + + +def write_github_output(name, value): + """Write a value to $GITHUB_OUTPUT.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def write_step_summary(diff_result): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## YAML Diff Analysis\n"] + + added = diff_result["services"]["added"] + removed = diff_result["services"]["removed"] + modified = diff_result["services"]["modified"] + section_changes = diff_result["sections"] + category_changes = diff_result["categories"] + + if not added and not removed and not modified and not section_changes and not category_changes: + lines.append("No changes detected in `awesome-privacy.yml`.\n") + else: + lines.append("| Type | Category | Section | Service | Details |") + lines.append("|------|----------|---------|---------|---------|") + + for svc in added: + lines.append(f"| Added | {svc['category']} | {svc['section']} | {svc['service']} | New service |") + + for svc in removed: + lines.append(f"| Removed | {svc['category']} | {svc['section']} | {svc['service']} | Service removed |") + + for svc in modified: + fields = ", ".join(svc["changed_fields"]) + lines.append(f"| Modified | {svc['category']} | {svc['section']} | {svc['service']} | Changed: {fields} |") + + for change in section_changes: + detail = change["change_type"].replace("_", " ").title() + fields = ", ".join(change.get("changed_fields", [])) + if fields: + detail += f" ({fields})" + lines.append(f"| Section | {change['category']} | {change['section']} | - | {detail} |") + + for change in category_changes: + detail = change["change_type"].replace("_", " ").title() + lines.append(f"| Category | {change['category']} | - | - | {detail} |") + + lines.append("") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Analyze YAML diff for PR checks") + parser.add_argument("--base-ref", required=True, help="Base git ref (SHA or branch) to diff against") + args = parser.parse_args() + + # Load both versions + base_data = load_base_yaml(args.base_ref) + head_data = load_head_yaml() + + # Compute diffs + added, removed, modified = diff_services(base_data, head_data) + section_changes = diff_sections(base_data, head_data) + category_changes = diff_categories(base_data, head_data) + + # Build result + diff_result = { + "services": { + "added": added, + "removed": removed, + "modified": modified, + }, + "sections": section_changes, + "categories": category_changes, + } + + # Write diff JSON + with open(DIFF_OUTPUT_PATH, "w") as f: + json.dump(diff_result, f, indent=2) + print(f"Diff written to {DIFF_OUTPUT_PATH}") + + # Determine if there are service-level changes + has_service_changes = bool(added or removed or modified) + write_github_output("has_service_changes", str(has_service_changes).lower()) + + # Write step summary + write_step_summary(diff_result) + + # Enforce single-entry rule + service_change_count = len(added) + len(removed) + len(modified) + + if service_change_count > 1: + print(red("Single-entry rule violation: PRs must contain only one service change."), file=sys.stderr) + print(red(f"Found {service_change_count} service-level changes:"), file=sys.stderr) + for svc in added: + print(f" + Added: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) + for svc in removed: + print(f" - Removed: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) + for svc in modified: + fields = ", ".join(svc["changed_fields"]) + print(f" ~ Modified: {svc['category']} > {svc['section']} > {svc['service']} ({fields})", file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + # If no service changes, check section-level changes + if service_change_count == 0 and len(section_changes) > 1: + print(red("Single-entry rule violation: PRs must contain only one section-level change."), file=sys.stderr) + print(red(f"Found {len(section_changes)} section-level changes:"), file=sys.stderr) + for change in section_changes: + detail = change["change_type"].replace("_", " ") + fields = change.get("changed_fields", []) + extra = f" ({', '.join(fields)})" if fields else "" + print(f" ~ {change['category']} > {change['section']}: {detail}{extra}", file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + # Summary + total = service_change_count + len(section_changes) + len(category_changes) + if total == 0: + print(green("No changes detected in awesome-privacy.yml")) + else: + print(green(f"Single-entry rule passed. {service_change_count} service change(s), " + f"{len(section_changes)} section change(s), {len(category_changes)} category change(s).")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py new file mode 100644 index 0000000..9b87e78 --- /dev/null +++ b/lib/checks/detect-changes.py @@ -0,0 +1,50 @@ +""" +Detects which files changed between the PR base and HEAD. +Sets GitHub Actions outputs: yaml_changed, non_yaml_changed. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + + +def write_github_output(name, value): + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def main(): + parser = argparse.ArgumentParser(description="Detect changed files in a PR") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + + print("Changed files:") + for f in changed_files: + print(f" {f}") + + yaml_changed = YAML_FILE in changed_files + non_yaml_changed = any(f != YAML_FILE for f in changed_files) + + write_github_output("yaml_changed", str(yaml_changed).lower()) + write_github_output("non_yaml_changed", str(non_yaml_changed).lower()) + + print(f"yaml_changed={yaml_changed}, non_yaml_changed={non_yaml_changed}") + + +if __name__ == "__main__": + main() diff --git a/lib/checks/warn-non-yaml.py b/lib/checks/warn-non-yaml.py new file mode 100644 index 0000000..e244dbc --- /dev/null +++ b/lib/checks/warn-non-yaml.py @@ -0,0 +1,58 @@ +""" +Warns when a PR modifies files other than awesome-privacy.yml. +This is expected for Website Update or Misc PRs, but may need extra review. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + + +def main(): + parser = argparse.ArgumentParser(description="Warn about non-YAML file changes") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + non_yaml = [f for f in changed_files if f != YAML_FILE] + + if not non_yaml: + return + + print(yellow("This PR modifies files other than awesome-privacy.yml:"), file=sys.stderr) + for f in non_yaml: + print(f" {f}", file=sys.stderr) + + # Write step summary + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_file: + lines = [ + "## Non-YAML Changes Warning\n", + "This PR modifies files other than `awesome-privacy.yml`:\n", + ] + for f in non_yaml: + lines.append(f"- `{f}`") + lines.append("") + lines.append("> **Note:** Most PRs should only modify `awesome-privacy.yml`. " + "Non-YAML changes may require additional review.\n") + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/lib/requirements.txt b/lib/requirements.txt index 3e298b1..f59b908 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,2 +1,3 @@ PyYAML==6.0.1 jsonschema==4.23.0 +requests==2.32.3 diff --git a/lib/schema.json b/lib/schema.json index d77f6a3..2ca04d2 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -7,38 +7,64 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, + "name": { "type": "string", "minLength": 1, "maxLength": 50 }, "sections": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, "services": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" }, - "github": { "type": ["string", "null"] }, - "icon": { "type": ["string", "null"] }, - "followWith": { "type": ["string", "null"] }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 10, "maxLength": 1500 }, + "url": { + "type": "string", + "anyOf": [ + { "pattern": "^https?://" }, + { "maxLength": 0 } + ] + }, + "github": { + "type": ["string", "null"], + "pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$" + }, + "icon": { + "type": ["string", "null"], + "pattern": "^https?://.+" + }, + "followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 }, "securityAudited": { "type": ["boolean", "null"] }, "openSource": { "type": ["boolean", "null"] }, "acceptsCrypto": { "type": ["boolean", "null"] }, - "tosdrId": { "type": ["number", "null"] }, - "iosApp": { "type": ["string", "null"] }, - "androidApp": { "type": ["string", "null"] }, - "discordInvite": { "type": ["string", "null"] }, - "subreddit": { "type": ["string", "null"] } + "tosdrId": { "type": ["integer", "null"], "minimum": 1 }, + "iosApp": { + "type": ["string", "null"], + "pattern": "^https://apps\\.apple\\.com/" + }, + "androidApp": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$" + }, + "discordInvite": { + "type": ["string", "null"], + "pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$" + }, + "subreddit": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z0-9_]+$", + "minLength": 1, + "maxLength": 50 + } }, "required": ["name", "description", "url"], "additionalProperties": false } }, - "intro": { "type": ["string", "null"] }, + "intro": { "type": ["string", "null"], "minLength": 1 }, "notableMentions": { "oneOf": [ { @@ -46,25 +72,26 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" } + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "pattern": "^https?://" } }, "required": ["name", "url"], "additionalProperties": false } }, - { "type": "string" }, + { "type": "string", "minLength": 1 }, { "type": "null" } ] }, - "furtherInfo": { "type": ["string", "null"] }, - "wordOfWarning": { "type": ["string", "null"] }, + "furtherInfo": { "type": ["string", "null"], "minLength": 1 }, + "wordOfWarning": { "type": ["string", "null"], "minLength": 1 }, "alternativeTo": { "oneOf": [ { "type": "array", - "items": { "type": "string" } + "items": { "type": "string", "minLength": 1, "maxLength": 100 }, + "minItems": 1 }, { "type": "null" } ] From 66d9970144f2c043e32e5ab31b2d6cf4c06e0df3 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Sun, 22 Feb 2026 22:54:42 +0000 Subject: [PATCH 16/56] Updates PR Check --- .github/workflows/pr-check.yml | 156 ++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 41 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 109df8e..94095b5 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,65 +10,131 @@ permissions: pull-requests: read jobs: - check-pr: - name: Check PR + detect-changes: + name: Detect changes runs-on: ubuntu-latest - + outputs: + yaml_changed: ${{ steps.changes.outputs.yaml_changed }} + non_yaml_changed: ${{ steps.changes.outputs.non_yaml_changed }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Fetch base ref - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - + - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - - name: Detect changed files id: changes run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} - - - name: Check for direct README edits - id: readme - run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} - - - name: Schema validation - id: schema - if: steps.changes.outputs.yaml_changed == 'true' - run: make validate - - - name: YAML diff analysis - id: diff - if: steps.changes.outputs.yaml_changed == 'true' - run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} - - - name: Link validation - if: steps.changes.outputs.yaml_changed == 'true' && steps.diff.outputs.has_service_changes == 'true' - run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json - - - name: PR template check - id: template - env: - PR_BODY: ${{ github.event.pull_request.body }} - run: python lib/checks/check-template.py - - name: Non-YAML changes warning if: steps.changes.outputs.non_yaml_changed == 'true' run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} - - name: Save PR metadata + readme: + name: README edits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -q -r lib/requirements.txt + - name: Check for direct README edits + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + + schema: + name: Schema validation + needs: detect-changes + if: needs.detect-changes.outputs.yaml_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -q -r lib/requirements.txt + - name: Validate schema + run: make validate + + diff: + name: Single-entry rule + needs: detect-changes + if: needs.detect-changes.outputs.yaml_changed == 'true' + runs-on: ubuntu-latest + outputs: + has_service_changes: ${{ steps.diff.outputs.has_service_changes }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -q -r lib/requirements.txt + - name: YAML diff analysis + id: diff + run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Upload diff data if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-diff + path: /tmp/pr-diff.json + if-no-files-found: ignore + + links: + name: Link validation + needs: diff + if: needs.diff.result == 'success' && needs.diff.outputs.has_service_changes == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -q -r lib/requirements.txt + - name: Download diff data + uses: actions/download-artifact@v4 + with: + name: pr-diff + path: /tmp + - name: Validate links + run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + + template: + name: PR template + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -q -r lib/requirements.txt + - name: PR template check + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: python lib/checks/check-template.py + + comment: + name: Summary + if: always() + needs: [readme, schema, diff, links, template] + runs-on: ubuntu-latest + steps: + - name: Save PR metadata run: | mkdir -p /tmp/pr-meta echo "${{ github.event.pull_request.number }}" > /tmp/pr-meta/number.txt echo "${{ github.run_id }}" > /tmp/pr-meta/run-id.txt - name: Build failure comment - if: failure() + if: contains(needs.*.result, 'failure') run: | cat > /tmp/pr-meta/comment.md << 'HEADER' <!-- pr-check-bot --> @@ -76,7 +142,7 @@ jobs: > ## PR check failed HEADER - if [ "${{ steps.readme.outcome }}" = "failure" ]; then + if [ "${{ needs.readme.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### Direct README edits @@ -85,7 +151,7 @@ jobs: EOF fi - if [ "${{ steps.schema.outcome }}" = "failure" ]; then + if [ "${{ needs.schema.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### Schema validation failed @@ -94,7 +160,7 @@ jobs: EOF fi - if [ "${{ steps.diff.outcome }}" = "failure" ]; then + if [ "${{ needs.diff.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### Single-entry rule @@ -103,7 +169,16 @@ jobs: EOF fi - if [ "${{ steps.template.outcome }}" = "failure" ]; then + if [ "${{ needs.links.result }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Link validation failed + One or more URLs in your entry could not be verified. + Please ensure all links are correct and accessible. + EOF + fi + + if [ "${{ needs.template.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### PR template incomplete @@ -112,7 +187,6 @@ jobs: EOF fi - # Footer with link to logs printf '\n---\n*See the [workflow logs](%s/%s/actions/runs/%s) for full details.*\n' \ "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" \ >> /tmp/pr-meta/comment.md From 96e4e1d7b1b83942172405719bdc50b3380decbb Mon Sep 17 00:00:00 2001 From: 7 <spicezke@gmail.com> Date: Mon, 23 Feb 2026 01:20:59 +0300 Subject: [PATCH 17/56] Adds FFmpeg to File Converters Adds FFmpeg to File Converters --- awesome-privacy.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..293424a 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -4996,7 +4996,17 @@ categories: services: [] - name: File Converters alternativeTo: ['format factory', 'handbrake', 'freemake video converter', 'any video converter', 'online-convert.com'] - services: [] + services: + - name: FFmpeg + url: https://ffmpeg.org/ + icon: https://ffmpeg.org/favicon.ico + openSource: true + github: FFmpeg/FFmpeg + description: | + A complete, cross-platform solution to record, convert, and stream audio and + video. It's the industry standard multimedia framework, handling a vast range + of formats. As a command-line tool, it guarantees that all processing is done + locally on your machine. - name: Creativity sections: From 2476a57bac22dd31a6ba7bd9f27ec0e4e38d86d8 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 15:16:57 +0000 Subject: [PATCH 18/56] Updates check workflow, to review pull requests --- .github/workflows/pr-check.yml | 190 +++++++++++---------------- .github/workflows/pr-comment.yml | 11 +- lib/checks/check-additions.py | 173 ++++++++++++++++++++++++ lib/checks/check-pr-meta.py | 122 +++++++++++++++++ lib/checks/check-project.py | 217 +++++++++++++++++++++++++++++++ lib/checks/format-comment.py | 106 +++++++++++++++ 6 files changed, 700 insertions(+), 119 deletions(-) create mode 100644 lib/checks/check-additions.py create mode 100644 lib/checks/check-pr-meta.py create mode 100644 lib/checks/check-project.py create mode 100644 lib/checks/format-comment.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 94095b5..4e56533 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -18,13 +18,10 @@ jobs: non_yaml_changed: ${{ steps.changes.outputs.non_yaml_changed }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - name: Detect changed files id: changes run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} @@ -32,54 +29,64 @@ jobs: if: steps.changes.outputs.non_yaml_changed == 'true' run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} - readme: - name: README edits + pr-meta: + name: PR metadata runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - fetch-depth: 0 + python-version: "3.12" + - name: Check PR metadata + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + run: python lib/checks/check-pr-meta.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-meta + path: /tmp/findings-meta.json + if-no-files-found: ignore + + file-checks: + name: File checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - name: Check for direct README edits run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} - schema: - name: Schema validation + data-validation: + name: Data validation needs: detect-changes if: needs.detect-changes.outputs.yaml_changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - - name: Validate schema - run: make validate - - diff: - name: Single-entry rule - needs: detect-changes - if: needs.detect-changes.outputs.yaml_changed == 'true' - runs-on: ubuntu-latest - outputs: - has_service_changes: ${{ steps.diff.outputs.has_service_changes }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install -q -r lib/requirements.txt - - name: YAML diff analysis + - name: Schema validation + id: schema + continue-on-error: true + run: make validate + - name: YAML diff id: diff + continue-on-error: true run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Check additions + env: + SCHEMA_OUTCOME: ${{ steps.schema.outcome }} + run: python lib/checks/check-additions.py - name: Upload diff data if: always() uses: actions/upload-artifact@v4 @@ -87,11 +94,21 @@ jobs: name: pr-diff path: /tmp/pr-diff.json if-no-files-found: ignore + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-data + path: /tmp/findings-data.json + if-no-files-found: ignore + - name: Fail if critical checks failed + if: steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure' + run: exit 1 - links: - name: Link validation - needs: diff - if: needs.diff.result == 'success' && needs.diff.outputs.has_service_changes == 'true' + project-checks: + name: Project checks + needs: data-validation + if: "!cancelled() && needs.data-validation.result != 'skipped'" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -104,93 +121,44 @@ jobs: with: name: pr-diff path: /tmp - - name: Validate links - run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + continue-on-error: true + - name: Check project health + env: + PR_USER: ${{ github.event.pull_request.user.login }} + GITHUB_TOKEN: ${{ github.token }} + run: python lib/checks/check-project.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-project + path: /tmp/findings-project.json + if-no-files-found: ignore - template: - name: PR template + summary: + name: Summary + if: always() + needs: [detect-changes, pr-meta, file-checks, data-validation, project-checks] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - - name: PR template check + - name: Download all findings + uses: actions/download-artifact@v4 + with: + pattern: findings-* + path: /tmp/artifacts + merge-multiple: true + continue-on-error: true + - name: Format comment env: - PR_BODY: ${{ github.event.pull_request.body }} - run: python lib/checks/check-template.py - - comment: - name: Summary - if: always() - needs: [readme, schema, diff, links, template] - runs-on: ubuntu-latest - steps: - - name: Save PR metadata - run: | - mkdir -p /tmp/pr-meta - echo "${{ github.event.pull_request.number }}" > /tmp/pr-meta/number.txt - echo "${{ github.run_id }}" > /tmp/pr-meta/run-id.txt - - - name: Build failure comment - if: contains(needs.*.result, 'failure') - run: | - cat > /tmp/pr-meta/comment.md << 'HEADER' - <!-- pr-check-bot --> - > [!CAUTION] - > ## PR check failed - HEADER - - if [ "${{ needs.readme.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### Direct README edits - The auto-generated section of the README must not be edited directly. - Please make your changes in `awesome-privacy.yml` instead — the README is regenerated from it. - EOF - fi - - if [ "${{ needs.schema.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### Schema validation failed - `awesome-privacy.yml` does not conform to the expected schema. - Please check your YAML against the structure defined in `lib/schema.json`. - EOF - fi - - if [ "${{ needs.diff.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### Single-entry rule - Each PR should add, modify, or remove only **one** service at a time. - If you need to change multiple services, please split them into separate PRs. - EOF - fi - - if [ "${{ needs.links.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### Link validation failed - One or more URLs in your entry could not be verified. - Please ensure all links are correct and accessible. - EOF - fi - - if [ "${{ needs.template.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### PR template incomplete - Your PR description is missing required sections. - Please fill out the **Type**, **Changes**, and **Checklist** sections of the PR template. - EOF - fi - - printf '\n---\n*See the [workflow logs](%s/%s/actions/runs/%s) for full details.*\n' \ - "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" \ - >> /tmp/pr-meta/comment.md - + PR_USER: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_ID: ${{ github.run_id }} + README_FAILED: ${{ needs.file-checks.result == 'failure' && 'true' || 'false' }} + run: python lib/checks/format-comment.py - name: Upload PR metadata if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 4034dba..455112c 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -33,7 +33,6 @@ jobs: script: | const fs = require('fs'); const marker = '<!-- pr-check-bot -->'; - const conclusion = context.payload.workflow_run.conclusion; // Determine the PR number let prNumber; @@ -73,12 +72,8 @@ jobs: }); const existing = comments.find(c => c.body.includes(marker)); - if (conclusion === 'failure') { - const commentFile = 'pr-meta/comment.md'; - if (!fs.existsSync(commentFile)) { - console.log('No comment.md found — skipping comment.'); - return; - } + const commentFile = 'pr-meta/comment.md'; + if (fs.existsSync(commentFile)) { const body = fs.readFileSync(commentFile, 'utf8').trim(); if (existing) { await github.rest.issues.updateComment({ @@ -96,7 +91,7 @@ jobs: }); } } else if (existing) { - // Checks passed — remove stale failure comment + // No findings — remove stale comment await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py new file mode 100644 index 0000000..474c651 --- /dev/null +++ b/lib/checks/check-additions.py @@ -0,0 +1,173 @@ +"""Validates data quality for added/modified services using the diff JSON.""" + +import json +import os +import sys + +import yaml + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_PATH = "/tmp/pr-diff.json" +FINDINGS_PATH = "/tmp/findings-data.json" + +REQUIRED_FIELDS = ("name", "description", "url", "icon") + +CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" + +SCHEMA_MSG = ( + "Some of the schema checks have failed. Please check that your addition" + " contains all the required fields, with acceptable values, nothing" + " additional and that it is following valid YAML syntax" +) +MULTIPLE_MSG = "Please make just one addition per pull request" +MISSING_TPL = ( + "Did you include all required fields? Looks like {fields} is missing or" + f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)" + " for available fields." +) +POSITION_MSG = ( + "New entries must be added to the end of the section, unless otherwise requested" +) +OPENSOURCE_MSG = ( + "You indicated this app/service is not open source. This will likely make" + " it ineligible for listing on Awesome Privacy in accordance with our" + f" [Requirements]({CONTRIBUTING}#requirements)." + " Please ensure that this is justified in your PR body." +) + + +def load_json(path): + """Load JSON from a file, returning None on any error.""" + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def load_yaml_data(path): + """Load YAML from a file, returning None on any error.""" + try: + with open(path) as f: + return yaml.safe_load(f) + except Exception: + return None + + +def find_section_services(head, category, section): + """Return the services list for a category/section pair, or None.""" + for cat in head.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + return sec.get("services", []) + return None + + +def find_service_fields(head, category, section, service_name): + """Look up a service's fields in the head YAML.""" + services = find_section_services(head, category, section) + if services: + for svc in services: + if svc.get("name") == service_name: + return svc + return None + + +def check_required_fields(diff, head): + """Return a finding if any added/modified service is missing required fields.""" + missing = set() + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + for f in REQUIRED_FIELDS: + if not fields.get(f): + missing.add(f) + for svc in diff.get("services", {}).get("modified", []): + if not head: + continue + fields = find_service_fields( + head, svc["category"], svc["section"], svc["service"] + ) + if fields: + for f in REQUIRED_FIELDS: + if not fields.get(f): + missing.add(f) + if missing: + names = ", ".join(f"`{f}`" for f in sorted(missing)) + return MISSING_TPL.format(fields=names) + return None + + +def check_position(diff, head): + """Return a finding if a newly added service is not at the end of its section.""" + if not head: + return None + for svc in diff.get("services", {}).get("added", []): + services = find_section_services(head, svc["category"], svc["section"]) + if services and services[-1].get("name") != svc["service"]: + return POSITION_MSG + return None + + +def check_open_source(diff): + """Return a finding if an added service has openSource missing or not true.""" + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + if fields.get("openSource") is not True: + return OPENSOURCE_MSG + return None + + +def check_single_entry(diff): + """Return a finding if the diff contains multiple service or section changes.""" + services = diff.get("services", {}) + svc_count = ( + len(services.get("added", [])) + + len(services.get("removed", [])) + + len(services.get("modified", [])) + ) + if svc_count > 1: + return MULTIPLE_MSG + if svc_count == 0: + sec_count = len(diff.get("sections", [])) + if sec_count > 1: + return MULTIPLE_MSG + return None + + +def main(): + findings = [] + try: + if os.environ.get("SCHEMA_OUTCOME") == "failure": + findings.append(SCHEMA_MSG) + + diff = load_json(DIFF_PATH) + head = load_yaml_data(DATA_PATH) + + if diff: + finding = check_single_entry(diff) + if finding: + findings.append(finding) + + finding = check_required_fields(diff, head) + if finding: + findings.append(finding) + + finding = check_position(diff, head) + if finding: + findings.append(finding) + + finding = check_open_source(diff) + if finding: + findings.append(finding) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-pr-meta.py b/lib/checks/check-pr-meta.py new file mode 100644 index 0000000..f63f561 --- /dev/null +++ b/lib/checks/check-pr-meta.py @@ -0,0 +1,122 @@ +"""Checks PR metadata: title format, draft status, template completeness, and checkboxes.""" + +import json +import os +import re +import sys + +FINDINGS_PATH = "/tmp/findings-meta.json" + +BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"} + +TITLE_MSG = ( + "The pull request title does not follow the format defined in our guidelines." + " Please rename it to `[Add/Remove/Update] [software name] in [software section]`" +) +DRAFT_MSG = ( + "Please avoid opening WIP pull requests." + " Your PR should be 100% ready and complete before submitting" +) +TEMPLATE_MSG = ( + "Please fill in pull request template in full." + " You can find a copy of this" + " [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)" +) +CHECKBOX_MSG = ( + "Ensure you have completed the checklist (put a tick the checkboxes with `[x]`)," + " to confirm that you've read the contributing guidelines, checked your submission," + " indicated your affiliation and agree to follow our CoC" +) + + +def extract_section(body, header): + """Extract content between a ### header and the next delimiter.""" + pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" + match = re.search(pattern, body, re.DOTALL) + return match.group(1) if match else None + + +def strip_html_comments(text): + """Remove HTML comments from text.""" + return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip() + + +def check_title(title): + """Return a finding if the PR title matches a known-bad pattern.""" + if title and title.strip().lower() in BAD_TITLES: + return TITLE_MSG + return None + + +def check_draft(draft_str): + """Return a finding if the PR is in draft state.""" + if str(draft_str).lower() == "true": + return DRAFT_MSG + return None + + +def check_template(body): + """Return a finding if required template sections are missing or empty.""" + for header in ("Type", "Changes", "Checklist"): + content = extract_section(body, header) + if content is None or not strip_html_comments(content): + return TEMPLATE_MSG + return None + + +def check_checkboxes(body): + """Return a finding if any checklist checkboxes are unchecked.""" + section = extract_section(body, "Checklist") + if section is None: + return None + checked = re.findall(r"- \[x\]", section, re.IGNORECASE) + unchecked = re.findall(r"- \[ \]", section) + if not checked and not unchecked: + return None + if unchecked: + return CHECKBOX_MSG + return None + + +def write_findings(findings): + """Write the findings list to the output JSON file.""" + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + + +def main(): + findings = [] + critical = False + try: + title = os.environ.get("PR_TITLE", "") + body = os.environ.get("PR_BODY", "") + draft = os.environ.get("PR_DRAFT", "false") + + finding = check_title(title) + if finding: + findings.append(finding) + + finding = check_draft(draft) + if finding: + findings.append(finding) + + if not body or not body.strip(): + findings.append(TEMPLATE_MSG) + critical = True + else: + finding = check_template(body) + if finding: + findings.append(finding) + critical = True + finding = check_checkboxes(body) + if finding: + findings.append(finding) + except Exception: + pass + + write_findings(findings) + sys.exit(1 if critical else 0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py new file mode 100644 index 0000000..ffa78db --- /dev/null +++ b/lib/checks/check-project.py @@ -0,0 +1,217 @@ +"""Checks project health: URL reachability, GitHub repo stars, activity, and author match.""" + +import json +import os +import sys +from datetime import datetime, timezone + +import requests +import yaml + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_PATH = "/tmp/pr-diff.json" +FINDINGS_PATH = "/tmp/findings-project.json" + +TIMEOUT = 10 +USER_AGENT = "awesome-privacy-ci/1.0" +MIN_STARS = 100 +INACTIVE_DAYS = 90 + +LINK_MSG = ( + "Our automated checks were unable to verify the link(s) you included" + " were reachable, so please double check this yourself" +) +AUTHOR_MSG = ( + "Looks like you are the author of this package. Please ensure that you" + " have clearly disclosed this in your PR body for transparency" +) +STARS_MSG = ( + "It looks like your submission is adding a quite small project." + " In some circumstances we may ask you to resubmit this once the project" + " is more mature and has a proven track record of good practices and maintenance." +) +ACTIVITY_MSG = ( + "Please confirm that the project you are adding is actively maintained," + " as it looks to not have had any recent updates in the past 3 months." +) + + +def load_diff(path): + """Load the diff JSON, returning None on any error.""" + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def check_url(url): + """Return True if the URL is reachable, True on any error (no false positives).""" + try: + resp = requests.head( + url, timeout=TIMEOUT, allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + if resp.status_code >= 400: + resp = requests.get( + url, timeout=TIMEOUT, allow_redirects=True, + headers={"User-Agent": USER_AGENT}, stream=True, + ) + resp.close() + return resp.status_code < 400 + except Exception: + return True + + +def parse_github_field(value): + """Parse a github field into (owner, repo), or (None, None) on failure.""" + if not value: + return None, None + if value.startswith("https://github.com/"): + parts = value.removeprefix("https://github.com/").strip("/").split("/") + if len(parts) >= 2: + return parts[0], parts[1] + return None, None + if "/" in value: + parts = value.split("/") + if len(parts) == 2: + return parts[0], parts[1] + return None, None + + +def fetch_repo(owner, repo, token): + """Fetch GitHub repo metadata, returning None on any error.""" + try: + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT} + if token: + headers["Authorization"] = f"token {token}" + resp = requests.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, timeout=TIMEOUT, + ) + if resp.status_code == 200: + return resp.json() + except Exception: + pass + return None + + +def load_yaml_data(): + """Load the head YAML, returning None on any error.""" + try: + with open(DATA_PATH) as f: + return yaml.safe_load(f) + except Exception: + return None + + +def find_service_in_head(head, category, section, service_name): + """Look up a service in the head YAML by path.""" + if not head: + return None + for cat in head.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + for svc in sec.get("services", []): + if svc.get("name") == service_name: + return svc + return None + + +def get_services(diff, key): + """Safely extract a service list from the diff.""" + return diff.get("services", {}).get(key, []) + + +def check_links(diff, head): + """Return LINK_MSG if any service URL is unreachable.""" + for svc in get_services(diff, "added"): + url = svc.get("fields", {}).get("url") + if url and not check_url(url): + return LINK_MSG + for svc in get_services(diff, "modified"): + if "url" not in svc.get("changed_fields", []): + continue + head_svc = find_service_in_head( + head, svc["category"], svc["section"], svc["service"] + ) + if head_svc: + url = head_svc.get("url") + if url and not check_url(url): + return LINK_MSG + return None + + +def check_repo_signals(diff, pr_user, token): + """Check GitHub repo author match, stars, and activity for added services.""" + findings = [] + if not token: + return findings + cache = {} + for svc in get_services(diff, "added"): + gh = svc.get("fields", {}).get("github") + owner, repo = parse_github_field(gh) + if not owner: + continue + cache_key = f"{owner}/{repo}" + if cache_key not in cache: + cache[cache_key] = fetch_repo(owner, repo, token) + data = cache[cache_key] + if not data: + continue + + repo_owner = data.get("owner", {}) + if ( + pr_user + and repo_owner.get("type") == "User" + and repo_owner.get("login", "").lower() == pr_user.lower() + and AUTHOR_MSG not in findings + ): + findings.append(AUTHOR_MSG) + + stars = data.get("stargazers_count", 0) + if stars < MIN_STARS and STARS_MSG not in findings: + findings.append(STARS_MSG) + + pushed = data.get("pushed_at") + if pushed and ACTIVITY_MSG not in findings: + try: + pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + if (now - pushed_dt).days > INACTIVE_DAYS: + findings.append(ACTIVITY_MSG) + except Exception: + pass + + return findings + + +def main(): + findings = [] + try: + diff = load_diff(DIFF_PATH) + if not diff: + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + head = load_yaml_data() + finding = check_links(diff, head) + if finding: + findings.append(finding) + + pr_user = os.environ.get("PR_USER", "") + token = os.environ.get("GITHUB_TOKEN", "") + findings.extend(check_repo_signals(diff, pr_user, token)) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py new file mode 100644 index 0000000..e81497a --- /dev/null +++ b/lib/checks/format-comment.py @@ -0,0 +1,106 @@ +"""Aggregates findings from all check jobs into a formatted PR comment.""" + +import json +import os +import sys + +ARTIFACTS_DIR = "/tmp/artifacts" +OUTPUT_DIR = "/tmp/pr-meta" + +README_MSG = ( + "Do not edit the README directly. This file is auto-generated from the" + " content in `awesome-privacy.yml`, and so your changes will be overridden!" + " Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines." +) + +CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" + +COMMENT_TEMPLATE = """<!-- pr-check-bot --> +Hello @{user} + +Thank you for contributing to Awesome Privacy! We will review your PR shortly. In the meantime, please ensure that your submission is inline with our guidelines in our [Contributing Requirements]({contributing}). + +Looks like there could be some issues in your PR. Please double check that: + +{findings} + +> [!NOTE] +> I am a bot, and sometimes make mistakes in my suggestions. But a human will review your submission shortly!""" + + +def load_findings(filename): + """Load a findings JSON array from the artifacts directory, or empty list on error.""" + try: + with open(os.path.join(ARTIFACTS_DIR, filename)) as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def collect_findings(readme_failed): + """Gather all findings in display order: meta, readme, data, project.""" + all_findings = [] + all_findings.extend(load_findings("findings-meta.json")) + if readme_failed: + all_findings.append(README_MSG) + all_findings.extend(load_findings("findings-data.json")) + all_findings.extend(load_findings("findings-project.json")) + return all_findings + + +def format_comment(findings, user): + """Build the markdown comment from findings.""" + bullet_list = "\n".join(f"- {f}" for f in findings) + return COMMENT_TEMPLATE.format( + user=user, contributing=CONTRIBUTING, findings=bullet_list, + ) + + +def write_step_summary(findings): + """Write a summary to GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + lines = ["## PR Check Summary\n"] + if findings: + lines.append(f"⚠️ Found {len(findings)} issue(s):\n") + for f in findings: + lines.append(f"- {f}") + else: + lines.append("✅ All checks passed.\n") + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + try: + user = os.environ.get("PR_USER", "contributor") + pr_number = os.environ.get("PR_NUMBER", "") + run_id = os.environ.get("RUN_ID", "") + readme_failed = os.environ.get("README_FAILED", "false") == "true" + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + if pr_number: + with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f: + f.write(pr_number) + if run_id: + with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f: + f.write(run_id) + + findings = collect_findings(readme_failed) + write_step_summary(findings) + + if findings: + comment = format_comment(findings, user) + with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f: + f.write(comment) + except Exception: + pass + + sys.exit(0) + + +if __name__ == "__main__": + main() From 512299c71db3edc0f25c6e75b8ce9c390fd5cb75 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 20:28:43 +0000 Subject: [PATCH 19/56] Updates PR check step concurency --- .github/workflows/pr-check.yml | 79 ++++---- lib/checks/check-links.py | 211 -------------------- lib/checks/check-pr-meta.py | 19 +- lib/checks/check-template.py | 206 ------------------- lib/checks/check-yaml-diff.py | 348 ++++++++++----------------------- lib/checks/detect-changes.py | 8 +- lib/checks/format-comment.py | 17 +- lib/checks/warn-non-yaml.py | 58 ------ 8 files changed, 163 insertions(+), 783 deletions(-) delete mode 100644 lib/checks/check-links.py delete mode 100644 lib/checks/check-template.py delete mode 100644 lib/checks/warn-non-yaml.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4e56533..d30babe 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -4,86 +4,76 @@ on: pull_request: branches: [main] types: [opened, edited, synchronize, reopened] + paths: + - 'awesome-privacy.yml' + - '.github/README.md' permissions: contents: read pull-requests: read jobs: - detect-changes: - name: Detect changes + pr-compliance: + name: PR Compliance runs-on: ubuntu-latest - outputs: - yaml_changed: ${{ steps.changes.outputs.yaml_changed }} - non_yaml_changed: ${{ steps.changes.outputs.non_yaml_changed }} steps: - uses: actions/checkout@v4 - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Detect changed files - id: changes - run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} - - name: Non-YAML changes warning - if: steps.changes.outputs.non_yaml_changed == 'true' - run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} - - pr-meta: - name: PR metadata - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" + - name: Check README edits + id: readme + continue-on-error: true + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} - name: Check PR metadata + id: meta env: PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} PR_DRAFT: ${{ github.event.pull_request.draft }} + README_FAILED: ${{ steps.readme.outcome == 'failure' && 'true' || 'false' }} run: python lib/checks/check-pr-meta.py - name: Upload findings if: always() uses: actions/upload-artifact@v4 with: - name: findings-meta - path: /tmp/findings-meta.json + name: findings-compliance + path: /tmp/findings-compliance.json if-no-files-found: ignore - - file-checks: - name: File checks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Check for direct README edits - run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Fail if critical + if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure' + run: exit 1 data-validation: - name: Data validation - needs: detect-changes - if: needs.detect-changes.outputs.yaml_changed == 'true' + name: Data Validation runs-on: ubuntu-latest + outputs: + yaml_changed: ${{ steps.changes.outputs.yaml_changed }} steps: - uses: actions/checkout@v4 - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -q -r lib/requirements.txt + - name: Detect changes + id: changes + run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Install dependencies + if: steps.changes.outputs.yaml_changed == 'true' + run: pip install -q -r lib/requirements.txt - name: Schema validation + if: steps.changes.outputs.yaml_changed == 'true' id: schema continue-on-error: true run: make validate - name: YAML diff + if: steps.changes.outputs.yaml_changed == 'true' id: diff continue-on-error: true run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} - name: Check additions + if: steps.changes.outputs.yaml_changed == 'true' env: SCHEMA_OUTCOME: ${{ steps.schema.outcome }} run: python lib/checks/check-additions.py @@ -101,14 +91,14 @@ jobs: name: findings-data path: /tmp/findings-data.json if-no-files-found: ignore - - name: Fail if critical checks failed - if: steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure' + - name: Fail if critical + if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure') run: exit 1 - project-checks: - name: Project checks + submission-eligibility: + name: Submission Eligibility needs: data-validation - if: "!cancelled() && needs.data-validation.result != 'skipped'" + if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -138,7 +128,7 @@ jobs: summary: name: Summary if: always() - needs: [detect-changes, pr-meta, file-checks, data-validation, project-checks] + needs: [pr-compliance, data-validation, submission-eligibility] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -157,7 +147,6 @@ jobs: PR_USER: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} RUN_ID: ${{ github.run_id }} - README_FAILED: ${{ needs.file-checks.result == 'failure' && 'true' || 'false' }} run: python lib/checks/format-comment.py - name: Upload PR metadata if: always() diff --git a/lib/checks/check-links.py b/lib/checks/check-links.py deleted file mode 100644 index 293fa24..0000000 --- a/lib/checks/check-links.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Validates URLs and metadata for added/modified services in a PR. -Reads the diff JSON produced by check-yaml-diff.py. -All checks are warnings only -- this script never fails (exit 0). -""" - -import argparse -import json -import os -import sys - -import requests -import yaml - -# Paths (relative to project root) -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") - -# Exit codes -EXIT_PASS = 0 -EXIT_RUNTIME_ERROR = 2 - -# ANSI color helpers -_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") -red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) -green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) -yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) - -TIMEOUT = 10 -USER_AGENT = "awesome-privacy-ci/1.0" -DESC_MIN_LEN = 50 -DESC_MAX_LEN = 250 - - -def check_url(url, label): - """Check if a URL is reachable. Returns (ok, message).""" - try: - resp = requests.head( - url, - timeout=TIMEOUT, - allow_redirects=True, - headers={"User-Agent": USER_AGENT}, - ) - if resp.status_code >= 400: - # Retry with GET -- some servers reject HEAD - resp = requests.get( - url, - timeout=TIMEOUT, - allow_redirects=True, - headers={"User-Agent": USER_AGENT}, - stream=True, - ) - resp.close() - if resp.status_code >= 400: - return False, f"{label}: HTTP {resp.status_code} for {url}" - return True, None - except requests.RequestException as e: - return False, f"{label}: Connection error for {url} ({type(e).__name__})" - - -def check_service(service_data, service_name, category, section): - """Run all checks on a single service. Returns list of warning strings.""" - warnings = [] - name_prefix = f"{category} > {section} > {service_name}" - - # Check url - url = service_data.get("url") - if url: - ok, msg = check_url(url, "url") - if not ok: - warnings.append(f"{name_prefix}: {msg}") - else: - warnings.append(f"{name_prefix}: missing required field 'url'") - - # Check icon - icon = service_data.get("icon") - if icon: - ok, msg = check_url(icon, "icon") - if not ok: - warnings.append(f"{name_prefix}: {msg}") - else: - warnings.append(f"{name_prefix}: missing 'icon' field (recommended by contributing guide)") - - # Check iosApp - ios_app = service_data.get("iosApp") - if ios_app: - ok, msg = check_url(ios_app, "iosApp") - if not ok: - warnings.append(f"{name_prefix}: {msg}") - - # Check github - github = service_data.get("github") - if github: - github_url = f"https://github.com/{github}" - ok, msg = check_url(github_url, "github") - if not ok: - warnings.append(f"{name_prefix}: {msg}") - - # Check description length - desc = service_data.get("description", "") - desc_stripped = desc.strip() - desc_len = len(desc_stripped) - if desc_len < DESC_MIN_LEN: - warnings.append(f"{name_prefix}: description too short ({desc_len} chars, minimum {DESC_MIN_LEN})") - elif desc_len > DESC_MAX_LEN: - warnings.append(f"{name_prefix}: description too long ({desc_len} chars, maximum {DESC_MAX_LEN})") - - return warnings - - -def find_service_in_head(category, section, service_name): - """Look up a service in the head YAML by category/section/service name.""" - try: - with open(DATA_PATH, "r") as f: - data = yaml.safe_load(f) - for cat in data.get("categories", []): - if cat.get("name") == category: - for sec in cat.get("sections", []): - if sec.get("name") == section: - for svc in sec.get("services", []): - if svc.get("name") == service_name: - return svc - except Exception: - pass - return None - - -def write_step_summary(all_warnings, services_checked): - """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_file: - return - - lines = ["## Link Validation\n"] - - if not services_checked: - lines.append("No services to check.\n") - elif not all_warnings: - lines.append(f"All checks passed for {services_checked} service(s).\n") - else: - lines.append(f"Checked {services_checked} service(s), found {len(all_warnings)} warning(s):\n") - lines.append("| Warning |") - lines.append("|---------|") - for w in all_warnings: - escaped = w.replace("|", "\\|") - lines.append(f"| {escaped} |") - lines.append("") - lines.append("> **Note:** Link warnings are informational only and do not fail the check. " - "URLs may be temporarily down or block automated requests.\n") - - with open(summary_file, "a") as f: - f.write("\n".join(lines) + "\n") - - -def main(): - parser = argparse.ArgumentParser(description="Validate links for added/modified services") - parser.add_argument("--diff-json", required=True, help="Path to the diff JSON file") - args = parser.parse_args() - - # Load diff - try: - with open(args.diff_json, "r") as f: - diff = json.load(f) - except (FileNotFoundError, json.JSONDecodeError) as e: - print(red(f"Failed to load diff JSON: {e}"), file=sys.stderr) - sys.exit(EXIT_RUNTIME_ERROR) - - all_warnings = [] - services_checked = 0 - - # Check added services - for svc in diff.get("services", {}).get("added", []): - services_checked += 1 - warnings = check_service( - svc.get("fields", {}), - svc["service"], - svc["category"], - svc["section"], - ) - all_warnings.extend(warnings) - - # Check modified services -- only if they have URL-related field changes - for svc in diff.get("services", {}).get("modified", []): - url_fields = {"url", "icon", "iosApp", "github", "description"} - changed = set(svc.get("changed_fields", [])) - if changed & url_fields: - services_checked += 1 - head_svc = find_service_in_head(svc["category"], svc["section"], svc["service"]) - if head_svc: - warnings = check_service( - head_svc, - svc["service"], - svc["category"], - svc["section"], - ) - all_warnings.extend(warnings) - - # Print results - if all_warnings: - print(yellow(f"Link validation: {len(all_warnings)} warning(s)"), file=sys.stderr) - for w in all_warnings: - print(f" {yellow('WARNING')} {w}", file=sys.stderr) - else: - print(green(f"Link validation passed. {services_checked} service(s) checked.")) - - write_step_summary(all_warnings, services_checked) - sys.exit(EXIT_PASS) - - -if __name__ == "__main__": - main() diff --git a/lib/checks/check-pr-meta.py b/lib/checks/check-pr-meta.py index f63f561..aa1379e 100644 --- a/lib/checks/check-pr-meta.py +++ b/lib/checks/check-pr-meta.py @@ -5,7 +5,7 @@ import os import re import sys -FINDINGS_PATH = "/tmp/findings-meta.json" +FINDINGS_PATH = "/tmp/findings-compliance.json" BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"} @@ -27,6 +27,11 @@ CHECKBOX_MSG = ( " to confirm that you've read the contributing guidelines, checked your submission," " indicated your affiliation and agree to follow our CoC" ) +README_MSG = ( + "Do not edit the README directly. This file is auto-generated from the" + " content in `awesome-privacy.yml`, and so your changes will be overridden!" + " Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines." +) def extract_section(body, header): @@ -78,6 +83,13 @@ def check_checkboxes(body): return None +def check_readme(readme_failed): + """Return a finding if the README check reported a failure.""" + if readme_failed == "true": + return README_MSG + return None + + def write_findings(findings): """Write the findings list to the output JSON file.""" with open(FINDINGS_PATH, "w") as f: @@ -91,6 +103,7 @@ def main(): title = os.environ.get("PR_TITLE", "") body = os.environ.get("PR_BODY", "") draft = os.environ.get("PR_DRAFT", "false") + readme_failed = os.environ.get("README_FAILED", "false") finding = check_title(title) if finding: @@ -111,6 +124,10 @@ def main(): finding = check_checkboxes(body) if finding: findings.append(finding) + + finding = check_readme(readme_failed) + if finding: + findings.append(finding) except Exception: pass diff --git a/lib/checks/check-template.py b/lib/checks/check-template.py deleted file mode 100644 index b53671d..0000000 --- a/lib/checks/check-template.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -Checks PR body against the pull request template. -Reads the PR body from the PR_BODY environment variable (avoids shell injection). -Exits with code 1 for severe violations (empty body, missing required sections). -""" - -import os -import re -import sys - -# Exit codes -EXIT_PASS = 0 -EXIT_FAIL = 1 -EXIT_RUNTIME_ERROR = 2 - -# Warnings that should cause a hard failure -CRITICAL_WARNINGS = { - "PR body is empty or not provided", - "Type section is missing", - "Type section is empty", - "Changes section is missing", - "Changes section is empty", - "Checklist section is missing", - "Checklist section does not contain any checkbox items", -} - -# ANSI color helpers -_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") -red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) -green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) -yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) - -# Valid PR types from the template -VALID_TYPES = {"Addition", "Amendment", "Removal", "Spelling or Grammar", "Website Update", "Misc"} - -# Raw template text that indicates an unfilled section -RAW_TYPE_LINE = "Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc" - - -def strip_html_comments(text): - """Remove <!-- ... --> comments from text.""" - return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip() - - -def extract_section(body, header): - """Extract content between a ### header and the next --- or ### header.""" - pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" - match = re.search(pattern, body, re.DOTALL) - if match: - return match.group(1) - return None - - -def check_type_section(content): - """Check the Type section. Returns list of warning strings.""" - warnings = [] - - if content is None: - warnings.append("Type section is missing") - return warnings - - cleaned = strip_html_comments(content).strip() - - if not cleaned: - warnings.append("Type section is empty") - return warnings - - # Check for raw template text (unchanged) - if RAW_TYPE_LINE in cleaned: - warnings.append("Type section appears unchanged from the template -- please select one type") - return warnings - - # Check how many valid types are present - found_types = [t for t in VALID_TYPES if t in cleaned] - - if len(found_types) == 0: - warnings.append(f"Type section does not contain a recognized type. Expected one of: {', '.join(sorted(VALID_TYPES))}") - elif len(found_types) > 1: - warnings.append(f"Type section contains multiple types ({', '.join(found_types)}) -- please select only one") - - return warnings - - -def check_text_section(content, section_name): - """Check a text section (Changes, Supporting Material, Affiliation). Returns list of warnings.""" - warnings = [] - - if content is None: - warnings.append(f"{section_name} section is missing") - return warnings - - cleaned = strip_html_comments(content).strip() - - if not cleaned: - warnings.append(f"{section_name} section is empty") - - return warnings - - -def check_checklist(content): - """Check the Checklist section. Returns list of warnings.""" - warnings = [] - - if content is None: - warnings.append("Checklist section is missing") - return warnings - - checked = re.findall(r"- \[x\]", content, re.IGNORECASE) - unchecked = re.findall(r"- \[ \]", content) - - total = len(checked) + len(unchecked) - - if total == 0: - warnings.append("Checklist section does not contain any checkbox items") - return warnings - - if unchecked: - warnings.append(f"Checklist has {len(unchecked)} unchecked item(s) out of {total}") - - return warnings - - -def has_critical_warnings(warnings): - """Return True if any warning is a critical (hard-fail) violation.""" - return any(w in CRITICAL_WARNINGS for w in warnings) - - -def write_step_summary(all_warnings): - """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_file: - return - - lines = ["## PR Template Check\n"] - - if not all_warnings: - lines.append("All template checks passed.\n") - else: - critical = has_critical_warnings(all_warnings) - lines.append(f"Found {len(all_warnings)} warning(s):\n") - for w in all_warnings: - lines.append(f"- {w}") - lines.append("") - if critical: - lines.append("> **Error:** One or more required sections are missing or empty. " - "Please fill out the PR template before this check can pass.\n") - else: - lines.append("> **Note:** Template warnings are informational and do not fail the check. " - "Reviewers will verify compliance.\n") - - with open(summary_file, "a") as f: - f.write("\n".join(lines) + "\n") - - -def main(): - pr_body = os.environ.get("PR_BODY") - - if pr_body is None or pr_body.strip() == "": - all_warnings = [ - "PR body is empty or not provided", - "Type section is missing", - "Changes section is missing", - "Supporting Material section is missing", - "Affiliation section is missing", - "Checklist section is missing", - ] - print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) - for w in all_warnings: - print(f" {yellow('WARNING')} {w}", file=sys.stderr) - write_step_summary(all_warnings) - sys.exit(EXIT_FAIL) - - all_warnings = [] - - # Check each section - type_content = extract_section(pr_body, "Type") - all_warnings.extend(check_type_section(type_content)) - - changes_content = extract_section(pr_body, "Changes") - all_warnings.extend(check_text_section(changes_content, "Changes")) - - supporting_content = extract_section(pr_body, "Supporting Material") - all_warnings.extend(check_text_section(supporting_content, "Supporting Material")) - - affiliation_content = extract_section(pr_body, "Affiliation") - all_warnings.extend(check_text_section(affiliation_content, "Affiliation")) - - checklist_content = extract_section(pr_body, "Checklist") - all_warnings.extend(check_checklist(checklist_content)) - - # Print results - if all_warnings: - print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) - for w in all_warnings: - print(f" {yellow('WARNING')} {w}", file=sys.stderr) - else: - print(green("PR template check passed.")) - - write_step_summary(all_warnings) - if has_critical_warnings(all_warnings): - sys.exit(EXIT_FAIL) - sys.exit(EXIT_PASS) - - -if __name__ == "__main__": - main() diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 2931e68..44a5285 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -1,7 +1,5 @@ -""" -Analyzes the diff between base and head versions of awesome-privacy.yml. -Enforces the single-entry rule: only one service addition/amendment/removal per PR. -Outputs a JSON diff to /tmp/pr-diff.json and writes a step summary. +"""Analyzes the diff between base and head versions of awesome-privacy.yml. +Enforces the single-entry rule and outputs a JSON diff to /tmp/pr-diff.json. """ import argparse @@ -12,22 +10,18 @@ import sys import yaml -# Paths (relative to project root) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") DIFF_OUTPUT_PATH = "/tmp/pr-diff.json" -# Exit codes EXIT_PASS = 0 EXIT_RULE_VIOLATION = 1 EXIT_RUNTIME_ERROR = 2 -# ANSI color helpers _use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) -dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s) def load_base_yaml(base_ref): @@ -35,13 +29,11 @@ def load_base_yaml(base_ref): try: result = subprocess.run( ["git", "show", f"{base_ref}:awesome-privacy.yml"], - capture_output=True, text=True, check=True, - cwd=PROJECT_ROOT, + capture_output=True, text=True, check=True, cwd=PROJECT_ROOT, ) return yaml.safe_load(result.stdout) except subprocess.CalledProcessError: - # File doesn't exist in base (completely new file) - print(yellow("Warning: awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr) + print(yellow("awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr) return {"categories": []} except yaml.YAMLError as e: print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr) @@ -51,165 +43,45 @@ def load_base_yaml(base_ref): def load_head_yaml(): """Load the YAML from the current working tree.""" try: - with open(DATA_PATH, "r") as f: + with open(DATA_PATH) as f: return yaml.safe_load(f) - except FileNotFoundError: - print(red(f"File not found: {DATA_PATH}"), file=sys.stderr) - sys.exit(EXIT_RUNTIME_ERROR) - except yaml.YAMLError as e: - print(red(f"Failed to parse head YAML: {e}"), file=sys.stderr) + except (FileNotFoundError, yaml.YAMLError) as e: + print(red(f"Failed to load head YAML: {e}"), file=sys.stderr) sys.exit(EXIT_RUNTIME_ERROR) -def build_service_index(data): - """Build a dict keyed by (category, section, service_name) -> service dict.""" +def build_index(data, depth): + """Build a keyed index at the given depth (3=services, 2=sections, 1=categories).""" index = {} for cat in data.get("categories", []): - cat_name = cat.get("name", "") + cn = cat.get("name", "") + if depth == 1: + index[cn] = {k: v for k, v in cat.items() if k != "sections"} + continue for sec in cat.get("sections", []): - sec_name = sec.get("name", "") + sn = sec.get("name", "") + if depth == 2: + index[(cn, sn)] = {k: v for k, v in sec.items() if k != "services"} + continue for svc in sec.get("services", []): - svc_name = svc.get("name", "") - key = (cat_name, sec_name, svc_name) - index[key] = svc + index[(cn, sn, svc.get("name", ""))] = svc return index -def build_section_index(data): - """Build a dict keyed by (category, section) -> section metadata (excluding services).""" - index = {} - for cat in data.get("categories", []): - cat_name = cat.get("name", "") - for sec in cat.get("sections", []): - sec_name = sec.get("name", "") - key = (cat_name, sec_name) - meta = {k: v for k, v in sec.items() if k != "services"} - index[key] = meta - return index - - -def build_category_index(data): - """Build a dict keyed by category_name -> category metadata (excluding sections).""" - index = {} - for cat in data.get("categories", []): - cat_name = cat.get("name", "") - meta = {k: v for k, v in cat.items() if k != "sections"} - index[cat_name] = meta - return index - - -def diff_services(base_data, head_data): - """Find added, removed, and modified services.""" - base_idx = build_service_index(base_data) - head_idx = build_service_index(head_data) - - base_keys = set(base_idx.keys()) - head_keys = set(head_idx.keys()) - - added = [] - for key in sorted(head_keys - base_keys): - added.append({ - "category": key[0], - "section": key[1], - "service": key[2], - "fields": head_idx[key], - }) - - removed = [] - for key in sorted(base_keys - head_keys): - removed.append({ - "category": key[0], - "section": key[1], - "service": key[2], - }) - +def diff_index(base_idx, head_idx): + """Return (added_keys, removed_keys, modified_keys_with_changed_fields).""" + base_keys, head_keys = set(base_idx), set(head_idx) + added = sorted(head_keys - base_keys) + removed = sorted(base_keys - head_keys) modified = [] for key in sorted(base_keys & head_keys): - base_svc = base_idx[key] - head_svc = head_idx[key] - if base_svc != head_svc: - changed_fields = [] - all_fields = set(base_svc.keys()) | set(head_svc.keys()) - for field in sorted(all_fields): - old_val = base_svc.get(field) - new_val = head_svc.get(field) - if old_val != new_val: - changed_fields.append(field) - modified.append({ - "category": key[0], - "section": key[1], - "service": key[2], - "changed_fields": changed_fields, - }) - + if base_idx[key] != head_idx[key]: + all_fields = set(base_idx[key]) | set(head_idx[key]) + changed = sorted(f for f in all_fields if base_idx[key].get(f) != head_idx[key].get(f)) + modified.append((key, changed)) return added, removed, modified -def diff_sections(base_data, head_data): - """Find section-level metadata changes (intro, wordOfWarning, etc.).""" - base_idx = build_section_index(base_data) - head_idx = build_section_index(head_data) - - base_keys = set(base_idx.keys()) - head_keys = set(head_idx.keys()) - - changes = [] - - # New sections - for key in sorted(head_keys - base_keys): - changes.append({ - "category": key[0], - "section": key[1], - "change_type": "added_section", - }) - - # Removed sections - for key in sorted(base_keys - head_keys): - changes.append({ - "category": key[0], - "section": key[1], - "change_type": "removed_section", - }) - - # Modified section metadata - for key in sorted(base_keys & head_keys): - base_meta = base_idx[key] - head_meta = head_idx[key] - if base_meta != head_meta: - changed_fields = [] - all_fields = set(base_meta.keys()) | set(head_meta.keys()) - for field in sorted(all_fields): - if base_meta.get(field) != head_meta.get(field): - changed_fields.append(field) - changes.append({ - "category": key[0], - "section": key[1], - "change_type": "modified_section_metadata", - "changed_fields": changed_fields, - }) - - return changes - - -def diff_categories(base_data, head_data): - """Find structural category changes.""" - base_idx = build_category_index(base_data) - head_idx = build_category_index(head_data) - - base_keys = set(base_idx.keys()) - head_keys = set(head_idx.keys()) - - changes = [] - - for name in sorted(head_keys - base_keys): - changes.append({"category": name, "change_type": "added_category"}) - - for name in sorted(base_keys - head_keys): - changes.append({"category": name, "change_type": "removed_category"}) - - return changes - - def write_github_output(name, value): """Write a value to $GITHUB_OUTPUT.""" output_file = os.environ.get("GITHUB_OUTPUT") @@ -218,123 +90,113 @@ def write_github_output(name, value): f.write(f"{name}={value}\n") +def fmt_path(key): + """Format a tuple key as a readable path.""" + return " → ".join(key) if isinstance(key, tuple) else key + + def write_step_summary(diff_result): - """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + """Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY.""" summary_file = os.environ.get("GITHUB_STEP_SUMMARY") if not summary_file: return lines = ["## YAML Diff Analysis\n"] + bullets = [] - added = diff_result["services"]["added"] - removed = diff_result["services"]["removed"] - modified = diff_result["services"]["modified"] - section_changes = diff_result["sections"] - category_changes = diff_result["categories"] + for svc in diff_result["services"]["added"]: + bullets.append(f"- Added **{svc['service']}** in {svc['category']} → {svc['section']}") + for svc in diff_result["services"]["removed"]: + bullets.append(f"- Removed **{svc['service']}** from {svc['category']} → {svc['section']}") + for svc in diff_result["services"]["modified"]: + fields = ", ".join(f"`{f}`" for f in svc["changed_fields"]) + bullets.append(f"- Modified {fields} in {svc['category']} → {svc['section']} → {svc['service']}") + for change in diff_result["sections"]: + ct = change["change_type"] + path = f"{change['category']} → {change['section']}" + if ct == "added_section": + bullets.append(f"- Added section **{change['section']}** in {change['category']}") + elif ct == "removed_section": + bullets.append(f"- Removed section **{change['section']}** from {change['category']}") + else: + fields = ", ".join(f"`{f}`" for f in change.get("changed_fields", [])) + bullets.append(f"- Modified section metadata ({fields}) in {path}") + for change in diff_result["categories"]: + if change["change_type"] == "added_category": + bullets.append(f"- Added category **{change['category']}**") + else: + bullets.append(f"- Removed category **{change['category']}**") - if not added and not removed and not modified and not section_changes and not category_changes: - lines.append("No changes detected in `awesome-privacy.yml`.\n") + if bullets: + lines.extend(bullets) else: - lines.append("| Type | Category | Section | Service | Details |") - lines.append("|------|----------|---------|---------|---------|") - - for svc in added: - lines.append(f"| Added | {svc['category']} | {svc['section']} | {svc['service']} | New service |") - - for svc in removed: - lines.append(f"| Removed | {svc['category']} | {svc['section']} | {svc['service']} | Service removed |") - - for svc in modified: - fields = ", ".join(svc["changed_fields"]) - lines.append(f"| Modified | {svc['category']} | {svc['section']} | {svc['service']} | Changed: {fields} |") - - for change in section_changes: - detail = change["change_type"].replace("_", " ").title() - fields = ", ".join(change.get("changed_fields", [])) - if fields: - detail += f" ({fields})" - lines.append(f"| Section | {change['category']} | {change['section']} | - | {detail} |") - - for change in category_changes: - detail = change["change_type"].replace("_", " ").title() - lines.append(f"| Category | {change['category']} | - | - | {detail} |") - - lines.append("") + lines.append("No changes detected in `awesome-privacy.yml`.") with open(summary_file, "a") as f: f.write("\n".join(lines) + "\n") def main(): - parser = argparse.ArgumentParser(description="Analyze YAML diff for PR checks") - parser.add_argument("--base-ref", required=True, help="Base git ref (SHA or branch) to diff against") + parser = argparse.ArgumentParser() + parser.add_argument("--base-ref", required=True) args = parser.parse_args() - # Load both versions - base_data = load_base_yaml(args.base_ref) - head_data = load_head_yaml() + base = load_base_yaml(args.base_ref) + head = load_head_yaml() - # Compute diffs - added, removed, modified = diff_services(base_data, head_data) - section_changes = diff_sections(base_data, head_data) - category_changes = diff_categories(base_data, head_data) + svc_added, svc_removed, svc_modified = diff_index( + build_index(base, 3), build_index(head, 3), + ) + sec_added, sec_removed, sec_modified = diff_index( + build_index(base, 2), build_index(head, 2), + ) + cat_added, cat_removed, _ = diff_index( + build_index(base, 1), build_index(head, 1), + ) + + added = [{"category": k[0], "section": k[1], "service": k[2], + "fields": build_index(head, 3)[k]} for k in svc_added] + removed = [{"category": k[0], "section": k[1], "service": k[2]} for k in svc_removed] + modified = [{"category": k[0], "section": k[1], "service": k[2], + "changed_fields": cf} for k, cf in svc_modified] + + sections = [] + for k in sec_added: + sections.append({"category": k[0], "section": k[1], "change_type": "added_section"}) + for k in sec_removed: + sections.append({"category": k[0], "section": k[1], "change_type": "removed_section"}) + for k, cf in sec_modified: + sections.append({"category": k[0], "section": k[1], + "change_type": "modified_section_metadata", "changed_fields": cf}) + + categories = [] + for k in cat_added: + categories.append({"category": k, "change_type": "added_category"}) + for k in cat_removed: + categories.append({"category": k, "change_type": "removed_category"}) - # Build result diff_result = { - "services": { - "added": added, - "removed": removed, - "modified": modified, - }, - "sections": section_changes, - "categories": category_changes, + "services": {"added": added, "removed": removed, "modified": modified}, + "sections": sections, + "categories": categories, } - # Write diff JSON with open(DIFF_OUTPUT_PATH, "w") as f: json.dump(diff_result, f, indent=2) - print(f"Diff written to {DIFF_OUTPUT_PATH}") - # Determine if there are service-level changes - has_service_changes = bool(added or removed or modified) - write_github_output("has_service_changes", str(has_service_changes).lower()) - - # Write step summary + write_github_output("has_service_changes", str(bool(added or removed or modified)).lower()) write_step_summary(diff_result) - # Enforce single-entry rule - service_change_count = len(added) + len(removed) + len(modified) - - if service_change_count > 1: - print(red("Single-entry rule violation: PRs must contain only one service change."), file=sys.stderr) - print(red(f"Found {service_change_count} service-level changes:"), file=sys.stderr) - for svc in added: - print(f" + Added: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) - for svc in removed: - print(f" - Removed: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) - for svc in modified: - fields = ", ".join(svc["changed_fields"]) - print(f" ~ Modified: {svc['category']} > {svc['section']} > {svc['service']} ({fields})", file=sys.stderr) + svc_count = len(added) + len(removed) + len(modified) + if svc_count > 1: + print(red(f"Single-entry rule violation: {svc_count} service changes found."), file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + if svc_count == 0 and len(sections) > 1: + print(red(f"Single-entry rule violation: {len(sections)} section changes found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) - # If no service changes, check section-level changes - if service_change_count == 0 and len(section_changes) > 1: - print(red("Single-entry rule violation: PRs must contain only one section-level change."), file=sys.stderr) - print(red(f"Found {len(section_changes)} section-level changes:"), file=sys.stderr) - for change in section_changes: - detail = change["change_type"].replace("_", " ") - fields = change.get("changed_fields", []) - extra = f" ({', '.join(fields)})" if fields else "" - print(f" ~ {change['category']} > {change['section']}: {detail}{extra}", file=sys.stderr) - sys.exit(EXIT_RULE_VIOLATION) - - # Summary - total = service_change_count + len(section_changes) + len(category_changes) - if total == 0: - print(green("No changes detected in awesome-privacy.yml")) - else: - print(green(f"Single-entry rule passed. {service_change_count} service change(s), " - f"{len(section_changes)} section change(s), {len(category_changes)} category change(s).")) + print(green(f"Single-entry rule passed. {svc_count} service, " + f"{len(sections)} section, {len(categories)} category change(s).")) sys.exit(EXIT_PASS) diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py index 9b87e78..9fa591d 100644 --- a/lib/checks/detect-changes.py +++ b/lib/checks/detect-changes.py @@ -1,6 +1,6 @@ """ Detects which files changed between the PR base and HEAD. -Sets GitHub Actions outputs: yaml_changed, non_yaml_changed. +Sets GitHub Actions output: yaml_changed. """ import argparse @@ -38,12 +38,8 @@ def main(): print(f" {f}") yaml_changed = YAML_FILE in changed_files - non_yaml_changed = any(f != YAML_FILE for f in changed_files) - write_github_output("yaml_changed", str(yaml_changed).lower()) - write_github_output("non_yaml_changed", str(non_yaml_changed).lower()) - - print(f"yaml_changed={yaml_changed}, non_yaml_changed={non_yaml_changed}") + print(f"yaml_changed={yaml_changed}") if __name__ == "__main__": diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py index e81497a..d2789b1 100644 --- a/lib/checks/format-comment.py +++ b/lib/checks/format-comment.py @@ -7,12 +7,6 @@ import sys ARTIFACTS_DIR = "/tmp/artifacts" OUTPUT_DIR = "/tmp/pr-meta" -README_MSG = ( - "Do not edit the README directly. This file is auto-generated from the" - " content in `awesome-privacy.yml`, and so your changes will be overridden!" - " Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines." -) - CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" COMMENT_TEMPLATE = """<!-- pr-check-bot --> @@ -38,12 +32,10 @@ def load_findings(filename): return [] -def collect_findings(readme_failed): - """Gather all findings in display order: meta, readme, data, project.""" +def collect_findings(): + """Gather all findings in display order: compliance, data, project.""" all_findings = [] - all_findings.extend(load_findings("findings-meta.json")) - if readme_failed: - all_findings.append(README_MSG) + all_findings.extend(load_findings("findings-compliance.json")) all_findings.extend(load_findings("findings-data.json")) all_findings.extend(load_findings("findings-project.json")) return all_findings @@ -78,7 +70,6 @@ def main(): user = os.environ.get("PR_USER", "contributor") pr_number = os.environ.get("PR_NUMBER", "") run_id = os.environ.get("RUN_ID", "") - readme_failed = os.environ.get("README_FAILED", "false") == "true" os.makedirs(OUTPUT_DIR, exist_ok=True) @@ -89,7 +80,7 @@ def main(): with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f: f.write(run_id) - findings = collect_findings(readme_failed) + findings = collect_findings() write_step_summary(findings) if findings: diff --git a/lib/checks/warn-non-yaml.py b/lib/checks/warn-non-yaml.py deleted file mode 100644 index e244dbc..0000000 --- a/lib/checks/warn-non-yaml.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Warns when a PR modifies files other than awesome-privacy.yml. -This is expected for Website Update or Misc PRs, but may need extra review. -""" - -import argparse -import os -import subprocess -import sys - -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -YAML_FILE = "awesome-privacy.yml" - -# ANSI color helpers -_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") -yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) - - -def main(): - parser = argparse.ArgumentParser(description="Warn about non-YAML file changes") - parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") - args = parser.parse_args() - - result = subprocess.run( - ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], - capture_output=True, text=True, check=True, - cwd=PROJECT_ROOT, - ) - - changed_files = [f for f in result.stdout.strip().splitlines() if f] - non_yaml = [f for f in changed_files if f != YAML_FILE] - - if not non_yaml: - return - - print(yellow("This PR modifies files other than awesome-privacy.yml:"), file=sys.stderr) - for f in non_yaml: - print(f" {f}", file=sys.stderr) - - # Write step summary - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if summary_file: - lines = [ - "## Non-YAML Changes Warning\n", - "This PR modifies files other than `awesome-privacy.yml`:\n", - ] - for f in non_yaml: - lines.append(f"- `{f}`") - lines.append("") - lines.append("> **Note:** Most PRs should only modify `awesome-privacy.yml`. " - "Non-YAML changes may require additional review.\n") - with open(summary_file, "a") as f: - f.write("\n".join(lines) + "\n") - - -if __name__ == "__main__": - main() From acd153fd2231122d1ba22dd39cc30cd770f85578 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 21:32:26 +0000 Subject: [PATCH 20/56] Simplifies comment logic --- .github/workflows/pr-comment.yml | 58 ++++++++---------- .../__pycache__/check-links.cpython-310.pyc | Bin 5842 -> 0 bytes .../check-readme-edits.cpython-310.pyc | Bin 4080 -> 0 bytes .../check-template.cpython-310.pyc | Bin 5359 -> 0 bytes .../check-yaml-diff.cpython-310.pyc | Bin 9772 -> 0 bytes .../detect-changes.cpython-310.pyc | Bin 1836 -> 0 bytes .../__pycache__/warn-non-yaml.cpython-310.pyc | Bin 2220 -> 0 bytes lib/checks/check-additions.py | 15 ++--- 8 files changed, 32 insertions(+), 41 deletions(-) delete mode 100644 lib/checks/__pycache__/check-links.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/check-readme-edits.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/check-template.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/detect-changes.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 455112c..44e6c33 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -26,7 +26,7 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Post or update comment + - name: Post comment uses: actions/github-script@v7 with: github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} @@ -34,12 +34,20 @@ jobs: const fs = require('fs'); const marker = '<!-- pr-check-bot -->'; + // Check if there are findings to post + const commentFile = 'pr-meta/comment.md'; + if (!fs.existsSync(commentFile)) { + console.log('No findings to post — skipping.'); + return; + } + // Determine the PR number let prNumber; const numberFile = 'pr-meta/number.txt'; if (fs.existsSync(numberFile)) { prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim()); - } else { + } + if (!prNumber) { // workflow_run.pull_requests is empty for fork PRs, so // fall back to searching by head SHA if needed const prs = context.payload.workflow_run.pull_requests; @@ -53,48 +61,34 @@ jobs: state: 'open', sort: 'updated', direction: 'desc', - per_page: 10, + per_page: 100, }); const match = prList.find(pr => pr.head.sha === headSha); if (!match) { - console.log(`No open PR found for SHA ${headSha} — skipping comment.`); + console.log(`No open PR found for SHA ${headSha} — skipping.`); return; } prNumber = match.number; } } - // Find existing bot comment + // Skip if we already commented on this PR const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + per_page: 100, }); - const existing = comments.find(c => c.body.includes(marker)); - - const commentFile = 'pr-meta/comment.md'; - if (fs.existsSync(commentFile)) { - const body = fs.readFileSync(commentFile, 'utf8').trim(); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - } - } else if (existing) { - // No findings — remove stale comment - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - }); + if (comments.some(c => c.body.includes(marker))) { + console.log('Bot comment already exists — skipping.'); + return; } + + // Post the comment + const body = fs.readFileSync(commentFile, 'utf8').trim(); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); diff --git a/lib/checks/__pycache__/check-links.cpython-310.pyc b/lib/checks/__pycache__/check-links.cpython-310.pyc deleted file mode 100644 index 4fc6b746504ea50ff0d556d17c5e0d41c9b48b6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5842 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 diff --git a/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc b/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc deleted file mode 100644 index 6eb4ca82fce5980dd4238dec331d8b3f40153493..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4080 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 diff --git a/lib/checks/__pycache__/check-template.cpython-310.pyc b/lib/checks/__pycache__/check-template.cpython-310.pyc deleted file mode 100644 index d49b2561d824ecb8c9fd9a6115aa3b9f6c3ec9e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5359 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 diff --git a/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc deleted file mode 100644 index 51c91bfd2bd882b16391f3f81379554355e72e06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9772 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 diff --git a/lib/checks/__pycache__/detect-changes.cpython-310.pyc b/lib/checks/__pycache__/detect-changes.cpython-310.pyc deleted file mode 100644 index 89c86010b6f042edb3abb57013bcbbd2161f57d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1836 zcmZux-ER{|5Z~SV@cA==q!6{Jx*}A`YHiwwwh{_a1ED}glZb>?SVE`s?Z&=vzH@fh zF}BW9EAxt~KJ@`bvLE?}`mnEk>c4<g(b;tf5%g9wyE{8OH#_s2$*R>7f^X1#8UN`Z z^taxe|BA5r9!PLt6txh=lq9%?%}iRv%vQ@Xv)!^8Ido?32fV<%IbMubky@=1wW;$Q zwaV0`1(++WO1-Cqf5$3Qgb^ishU&%fv(grmERw<>cH^k)AH)e0e$)-q4x@gX$suE@ zFT2d&edxDC!Td0#{=GY!Tg}oQgKKwVd2i76HzOHmsqnLb><^^4;g7;z5}exk(oeHA zI4^0I{(%>x?eR)DWFqUaMxV#WVKi!vdPzj|+39D)>;c^d5-ng79pV`TW{xK$C;SU( z<%E(c`F(X_<<`v3(Mv?F90Rvc>^a8h7YMBlV`{<E(H{C5iEBfU>+x8J17w=&&bF%F zy}y6&(XC+T(f;nEeTBo<_@=_8x}!)YRDq?(G0#$Eb(mCk)@P}5hCG(6ZYw(tdrUdU zVKQK<ya+^~<Eik$E5w(;_t#gFxP3k9vgoO}PEFt&ix`P!f23B7sh|_f?w}nkEG+AH zSAbQpjVpK!Uwvc0;TJ#<0iAsXSZOk0`U@61Od2&W@C4=PkWfowg1Rpt2sxTrb2P!y zK6Ge-dbF71Ll**8Iw2h!^kB#WEx&LkgjQ(vIm*eQH!IH3tTabkXo@G6EYsQ)f|y%d z=t*&6!+t5ZU!oJDrAOb&N^Tvai6g6YnO*=3pf|;E)0H)PQ7+}q6wj9DXi6{w$qGo^ zbCNP$%^mRQqa6G?v9(Q;qD$N3D+|P)fph`cIQ2uoXdSCEWg_BnUjuSnYBV%z8=M{N zkFVU)0EY1Tu=Qo;)A-=P4?AI;3Rx%0<Lqb<b4HcjWl0~h97C41(zx7c==3(SG#QQE zW>Z%H<h&MzeL3LlG+D})?3q-K$*{7bA&u}^iV)Jcwt)74#44~3q~eJ?!8zt6$A6F` zC`<zs!(I_ZnqTo6D83VN4G2|QPhugXtk>VTs*P#Ot7yE|gQqmtlPn4ou>p&>O5@t? z(~>sPx-llc|G%yYZEX$MZ`!qjc1@zE*3VETN5o_RZ$pfWk2imM5d3ie!5#hyD8KJs zalOM*_N>o0>e`;JVe;!GG`{?ve{Z!^<$QBc%kQ^~arON9&GE{I9pz^Ig7QM%>4#h} zRomp9L64<!S8uthgp>wwYGkTpl=NPxk{Go6Jd2nR%Ho4mc^WSP07O-GAMX5k=k|W^ zaA#*<xk6GXN#zI$;DUSoB$i2>GNBx3Ua91t!ZVc>rXy3H$^u2@B-xPhdO^A7pIq7I zuP$d|kVsWM?U1HVh()S>FvCC7Ot*{$GfB5~^e%%;=$u#ZGWM`dE@BrG?Ebsvdc@N* zkX!^>Bm5gsQ(h40dqGgIsX`FYED8dy4RBYp&$W&vMnc*BP<B;;#@sZrLf95Y0tPf_ z;FeCNd8K*anR%@ar|-NqE90`a-plA9VH>&(46bbCIUYFf;cLVup5+z1k4xBo^B<H) B>01B* diff --git a/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc deleted file mode 100644 index 53295211e3ced8b7d90b3ec0a65ca9afa2498c2f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2220 zcmb7F-ER{|5Z}E!pD$<UJA{-VbyZraPO$+YA)qKCgixr+hms(K5xShc8~e=p&e>gK ze6pXyW235ls8pdyw)(`sw6A^YKhOuHmd;)Xp@3BN_I7q>c6N4l=eM)*=%|a}`>pgc zxS2)hcg?*2jR12Ks`v#6MP)=WH9}m*X={{?v^C3S+FE6c8I8=q{)lFoGsH)wJVMQ~ z%dAFjkRPJ>66lT&#;7&Kc~l-}<NPYkQ2PanOq$hgd4f801lmb9Mct<czsDx`5k}P5 zN^2pP{NO(Hc_c`u&LZNIjZM;wX|NkGL3V=>Xe{fD6Iu6RxWh!;WW^Q_p83^osoM-o z?sh#81b+6s#j27~vKw>qkX1w=8F|p6J~SX%4MY{Ji}R$?mZa%-NyI>zJ(t`koIMLz zr{w+tCxzCM=}#=AoV9gtdHw$SW)*9#x1a8tP?w>KJP?X|=p}6K(A+`=b1litKfZjq znOO6WFE(GHS6E@e^@NnXo)3e{g=(EupNb0|IEP{sN5!rW`)YNSb0hSd73wceYU2iw zJmL^(^pOYJi_`-PIz0p{<F%tzwBTetZfjPXcxTc=RbbzJ4)k<Qtz7}T5;ao59N<1! zZWz?0mJafPiGd%OL)6F8YGfeb5b7Ro*sy!|(5P9U2SsLS?jX}QXr7L~Ks}@33`T}% z;11Dkw2%8H9ZThA%2_>2d`rQ9nUi@szK`W-V~kE57_gRg8$HSQGcrdfdzqK$&^Q^} zyCcUN6N5=QHN<_pXY*g_G@Yp#{cJib*U$8_gDHAS&!e-4<`84F=clsMx}WyubWcwA zZ2H;0@#8ei$qZ)bnZc=Ec8KYj(@2j$go|WB*@xY0KQAHia|h(QJkr1hnXZNTwS;J3 z6TcDz-gH<WEv?>9F-@uf<{A?Pta6lzDi2yRh@-?U7AwABMb37&le4#=OKO1xaPATr z6Sx$+#IN~5BxJ!*4rhDqfHSJBItyFM1XX2er6gA@Mm}7VI10OoT`JvOS-M@Y)L7MT z$u?&mIMix`vIOv}Oo~2bRy*`xu+nhTINgFOW`NWn+)wO2?qP29@K44b+~E^sy)u+h zx~TB(i6{k|a&Lrzkkz=^TKrs_(spN1GS`HSlx~D^)eprYbdHriazZ0Gbz<(=2}e62 z(NpsSN8mh1^dWn9?%iME8sQ%}!}UH0)YOYPlw3)Z$N$Ya*I^+`C#S?9L`{EpZ~N|p zTi(|8%7(Y~V0CqA^HFm4+&Qv#9P{O)aHn_(qMDmr`|o9UK9PcUuDgj@Bs;J1jzUSi zNEQ~>V#%&AERfY$NS)n?sK@P)5*_-ZMA{{vG*vor?=2eBocI=0#HTdSnDoPUxlv(X zVYe`=9G}-(J{L@lE%92r$s)O-S+4Rh&4bZYrrcCX^Fq0zU1{;S%7joRZ%4|}NcRBR zD!;M0{{71Gwzs*yzO8H_Dd1COgoJAW^IBmb!ysZpWgzWGQrTS=hH*!kqAOB$#<?<L zp|UJ`7VtQNtH40bZ?#wiw<Cr|WjY+517$VhASyV@PF>^5N}aPg6YWr{(W8@~Ui$DT z2QICrb1|O=$f&$%H=921rlTs8`t$+5l%-LvK_;#O$>Ry^V9S`oHa4*R*PJ~DoQ0<h z2hZZXX<@@Ku=TfN&0rTw-k8N-ys`e|*YtXh=V@!6SD02=&!chG^Y~d9;ooRNG${a4 zR?C-lm8AjKw_auaiby43KyL(QozuFfG+EFS>_GTZcKLZNDQb!9wILeSIE*>}T1$^T x&=$dfP6Y1}{ffZr1P^sPWQ+Ro5odr{$jLacV+Vg}SjL<=gB{cP%Ei{3e*o$%PP703 diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index 474c651..b637d89 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -86,12 +86,13 @@ def check_required_fields(diff, head): for svc in diff.get("services", {}).get("modified", []): if not head: continue + changed = svc.get("changed_fields", []) fields = find_service_fields( head, svc["category"], svc["section"], svc["service"] ) if fields: for f in REQUIRED_FIELDS: - if not fields.get(f): + if f in changed and not fields.get(f): missing.add(f) if missing: names = ", ".join(f"`{f}`" for f in sorted(missing)) @@ -120,16 +121,12 @@ def check_open_source(diff): def check_single_entry(diff): - """Return a finding if the diff contains multiple service or section changes.""" + """Return a finding if the diff adds multiple new services or sections.""" services = diff.get("services", {}) - svc_count = ( - len(services.get("added", [])) - + len(services.get("removed", [])) - + len(services.get("modified", [])) - ) - if svc_count > 1: + added_count = len(services.get("added", [])) + if added_count > 1: return MULTIPLE_MSG - if svc_count == 0: + if added_count == 0: sec_count = len(diff.get("sections", [])) if sec_count > 1: return MULTIPLE_MSG From 3b6a18fdecb57e75009a7adcbc0c83c5c8d1f214 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 21:53:33 +0000 Subject: [PATCH 21/56] Updates contributing and PR checks --- .github/CONTRIBUTING.md | 13 ++-- lib/checks/check-additions.py | 108 +++++++++++++++++++++++++++++++++- lib/checks/check-project.py | 22 +++++-- lib/checks/check-yaml-diff.py | 14 +++-- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index de71b2b..b9e07ef 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,7 +19,7 @@ You can add, edit or remove entries by opening a pull request. All data is stored in [`awesome-privacy.yml`](https://github.com/Lissy93/awesome-privacy/blob/main/awesome-privacy.yml). -If you're adding, editing or removing a listing - **this is the only file you need to edit**. +If you're adding, editing or removing a listing - **this is the only file you need to edit**. Don't edit the README directly, as this is auto-generated from the YAML file. ### Process @@ -74,12 +74,11 @@ Usually these entries go within the "Notable Mentions" section instead._ Your pull request must follow these requirements. Failure to do so, might result in it being closed. -- Do not edit the README directly when adding / editing a listing +- Do not edit the README directly when adding / editing a listing (it's auto-generated!) - Ensure your PR is not a duplicate, search for existing / previous submissions first -- You must respond to any comments or requests for changes in a timely manner, 48-hours maximum +- You must respond to any comments or requests for changes in a timely manner, 14 days maximum - Write short but descriptive git commit messages, under 50 characters. This must be in the format of `Adds [software-name] to [section-name]`. Your PR will be rejected if you name it `Updates README.md` - Only include a single addition / amendment / removal, per pull request -- If your pull request contains multiple commits, you must squash them first - You must complete each of the sections in the pull request template. Do not delete it! - Where applicable, include links to supporting material for your addition: git repo, docs, recent security audits, etc. This will make researching it much easier for reviewers - While adding new software to the list, don't make your entry read like an advert. Be objective, and include drawbacks as well as strengths @@ -90,8 +89,8 @@ Your pull request must follow these requirements. Failure to do so, might result - You must adhere to the Contributor Covenant Code of Conduct - Don't open a Draft / WIP pull request while you work on the guidelines. A pull request should be 100% ready and should adhere to all the above guidelines when you open it - Your changes must be correctly spelled, and with good grammar -- Your changes must be correctly formatted, in valid markdown -- The addition title must be a link the project, and in bold +- Your changes must be correctly formatted, in valid yaml and markdown +- The addition title must be a link the project - The addition description must be no less than 50, and no more than 250 characters, keep it clear and to the point --- @@ -103,8 +102,6 @@ This file may look a bit daunting to start with, but don't worry - it's pretty s ### Top-Level Structure - - ```mermaid --- title: Class Diagram diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index b637d89..af51e03 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -35,6 +35,22 @@ OPENSOURCE_MSG = ( f" [Requirements]({CONTRIBUTING}#requirements)." " Please ensure that this is justified in your PR body." ) +DUPLICATE_NAME_MSG = ( + "A service named `{name}` already exists (in {location})." + " If this is a different service, please clarify in your PR description" +) +DUPLICATE_URL_MSG = ( + "The URL `{url}` is already associated with `{existing}`." + " Please check this isn't a duplicate submission" +) +DESC_LENGTH_MSG = ( + "Description length ({length} chars) is outside the recommended 50\u2013250" + f" character range. Please see our [Contributing Guidelines]({CONTRIBUTING}#description)" +) +OPENSOURCE_GITHUB_MSG = ( + "You marked this service as open source but didn't include a `github` field." + " Please add the repository link" +) def load_json(path): @@ -81,7 +97,7 @@ def check_required_fields(diff, head): for svc in diff.get("services", {}).get("added", []): fields = svc.get("fields", {}) for f in REQUIRED_FIELDS: - if not fields.get(f): + if fields.get(f) is None: missing.add(f) for svc in diff.get("services", {}).get("modified", []): if not head: @@ -92,7 +108,7 @@ def check_required_fields(diff, head): ) if fields: for f in REQUIRED_FIELDS: - if f in changed and not fields.get(f): + if f in changed and fields.get(f) is None: missing.add(f) if missing: names = ", ".join(f"`{f}`" for f in sorted(missing)) @@ -133,6 +149,75 @@ def check_single_entry(diff): return None +def build_name_index(head): + """Build {lowercase_name: "category > section"} from all services.""" + index = {} + if not head: + return index + for cat in head.get("categories", []): + cn = cat.get("name", "") + for sec in cat.get("sections", []): + sn = sec.get("name", "") + for svc in sec.get("services", []): + name = svc.get("name", "").lower().strip() + if name: + index[name] = f"{cn} > {sn}" + return index + + +def build_url_index(head): + """Build {url: service_name} from all services, skipping empty URLs.""" + index = {} + if not head: + return index + for cat in head.get("categories", []): + for sec in cat.get("sections", []): + for svc in sec.get("services", []): + url = svc.get("url", "") + if url: + index[url] = svc.get("name", "") + return index + + +def check_duplicate_name(diff, name_index): + """Return a finding if an added service name already exists in the YAML.""" + for svc in diff.get("services", {}).get("added", []): + name = svc.get("fields", {}).get("name", "").lower().strip() + if name and name in name_index: + return DUPLICATE_NAME_MSG.format( + name=svc["fields"]["name"], location=name_index[name], + ) + return None + + +def check_duplicate_url(diff, url_index): + """Return a finding if an added service URL already exists in the YAML.""" + for svc in diff.get("services", {}).get("added", []): + url = svc.get("fields", {}).get("url", "") + if url and url in url_index: + return DUPLICATE_URL_MSG.format(url=url, existing=url_index[url]) + return None + + +def check_description_length(diff): + """Return a finding if an added service description is outside 50-250 chars.""" + for svc in diff.get("services", {}).get("added", []): + desc = svc.get("fields", {}).get("description", "") + length = len(desc) + if length < 50 or length > 250: + return DESC_LENGTH_MSG.format(length=length) + return None + + +def check_opensource_github(diff): + """Return a finding if an added service is open source but has no github field.""" + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + if fields.get("openSource") is True and not fields.get("github"): + return OPENSOURCE_GITHUB_MSG + return None + + def main(): findings = [] try: @@ -158,6 +243,25 @@ def main(): finding = check_open_source(diff) if finding: findings.append(finding) + + name_index = build_name_index(head) + url_index = build_url_index(head) + + finding = check_duplicate_name(diff, name_index) + if finding: + findings.append(finding) + + finding = check_duplicate_url(diff, url_index) + if finding: + findings.append(finding) + + finding = check_description_length(diff) + if finding: + findings.append(finding) + + finding = check_opensource_github(diff) + if finding: + findings.append(finding) except Exception: pass diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py index ffa78db..abb2d87 100644 --- a/lib/checks/check-project.py +++ b/lib/checks/check-project.py @@ -126,21 +126,31 @@ def get_services(diff, key): def check_links(diff, head): - """Return LINK_MSG if any service URL is unreachable.""" + """Return LINK_MSG if any service URL or icon URL is unreachable.""" for svc in get_services(diff, "added"): - url = svc.get("fields", {}).get("url") + fields = svc.get("fields", {}) + url = fields.get("url") if url and not check_url(url): return LINK_MSG + icon = fields.get("icon") + if icon and not check_url(icon): + return LINK_MSG for svc in get_services(diff, "modified"): - if "url" not in svc.get("changed_fields", []): + changed = svc.get("changed_fields", []) + if "url" not in changed and "icon" not in changed: continue head_svc = find_service_in_head( head, svc["category"], svc["section"], svc["service"] ) if head_svc: - url = head_svc.get("url") - if url and not check_url(url): - return LINK_MSG + if "url" in changed: + url = head_svc.get("url") + if url and not check_url(url): + return LINK_MSG + if "icon" in changed: + icon = head_svc.get("icon") + if icon and not check_url(icon): + return LINK_MSG return None diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 44a5285..05cafe9 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -187,16 +187,18 @@ def main(): write_github_output("has_service_changes", str(bool(added or removed or modified)).lower()) write_step_summary(diff_result) - svc_count = len(added) + len(removed) + len(modified) - if svc_count > 1: - print(red(f"Single-entry rule violation: {svc_count} service changes found."), file=sys.stderr) + added_count = len(added) + if added_count > 1: + print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) - if svc_count == 0 and len(sections) > 1: + if added_count == 0 and len(sections) > 1: print(red(f"Single-entry rule violation: {len(sections)} section changes found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) - print(green(f"Single-entry rule passed. {svc_count} service, " - f"{len(sections)} section, {len(categories)} category change(s).")) + total = len(added) + len(removed) + len(modified) + print(green(f"Single-entry rule passed. {total} service " + f"({added_count} added), {len(sections)} section, " + f"{len(categories)} category change(s).")) sys.exit(EXIT_PASS) From 9a9c452b00efd9d3d8ec52667f638781c72f6720 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 21:58:58 +0000 Subject: [PATCH 22/56] Fix check for multiple changes --- lib/checks/check-additions.py | 5 +++-- lib/checks/check-yaml-diff.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index af51e03..30cad8a 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -143,8 +143,9 @@ def check_single_entry(diff): if added_count > 1: return MULTIPLE_MSG if added_count == 0: - sec_count = len(diff.get("sections", [])) - if sec_count > 1: + added_sections = [s for s in diff.get("sections", []) + if s.get("change_type") == "added_section"] + if len(added_sections) > 1: return MULTIPLE_MSG return None diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 05cafe9..a29e096 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -191,8 +191,9 @@ def main(): if added_count > 1: print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) - if added_count == 0 and len(sections) > 1: - print(red(f"Single-entry rule violation: {len(sections)} section changes found."), file=sys.stderr) + added_sections = [s for s in sections if s["change_type"] == "added_section"] + if added_count == 0 and len(added_sections) > 1: + print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) total = len(added) + len(removed) + len(modified) From da8c5bba756ee61ae74a1fcc395706410dcfd7a4 Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Mon, 23 Feb 2026 22:02:25 +0000 Subject: [PATCH 23/56] Updated Awesome Privacy content (last modified on 23-Feb-2026) --- .github/README.md | 70 +++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/README.md b/.github/README.md index f61fd3b..0caa538 100644 --- a/.github/README.md +++ b/.github/README.md @@ -186,7 +186,7 @@ free plan. > - [Password Safe](https://www.pwsafe.org) - An offline, open source password manager designed by [Bruce Schneier](https://www.schneier.com/academic/passsafe/), with native applications for Windows, Linux, MacOS, Android and iOS, and support for YubiKey. The UI is a little dated, and there is no official browser extension, making is slightly less convenient to use compared with other options > - [PassBolt](https://www.passbolt.com) - A good option for teams. It is free, open source, self-hosted, extensible and OpenPGP based. It is specifically good for development and DevOps usage, with integrations for the terminal, browser and chat, and can be easily extended for custom usage, and deployed quickly with Docker -> - [1Password](https://1password.com) - (proprietary) A fully-featured cross-platform password manager with sync. Free for self-hosted data (or $3/ month hosted). Be aware that 1Password is not fully open source, but they do regularly publish results of their independent [security audits](https://support.1password.com/security-assessments), and they have a solid reputation for transparently disclosing and fixing vulnerabilities +> - [1Password](https://1password.com) - (proprietary) A fully-featured cross-platform password manager with sync. Free for self-hosted data (or $3/ month hosted). Be aware that 1Password is not fully open source, but they do regularly publish results of their independent [security audits](https://support.1password.com/security-assessments), and they have a solid reputation for transparently disclosing and fixing vulnerabilities </details> <details> @@ -278,9 +278,9 @@ and HMAC-Based One-Time Password (HOTP, specified in [RFC 4226](https://tools.ie ˙ </details> -- **[<img src='https://raw.githubusercontent.com/ente-io/ente/main/auth/assets/icons/auth-icon.ico' width='16' height='16' alt='icon' /> Ente Auth](https://ente.io/auth/)** - Ente Auth is a free and open-source app which stores and generates TOTP tokens. -It can be used with an online account to backup and sync your tokens across your -devices (and access them via a web interface) in a secure, end-to-end encrypted +- **[<img src='https://raw.githubusercontent.com/ente-io/ente/main/auth/assets/icons/auth-icon.ico' width='16' height='16' alt='icon' /> Ente Auth](https://ente.io/auth/)** - Ente Auth is a free and open-source app which stores and generates TOTP tokens. +It can be used with an online account to backup and sync your tokens across your +devices (and access them via a web interface) in a secure, end-to-end encrypted fashion. It can also be used offline on a single device with no account necessary. […](https://awesome-privacy.xyz/essentials/2-factor-authentication/ente-auth "View full Ente Auth report") - <details> @@ -346,8 +346,8 @@ It features small explicit keys, no config options, and UNIX-style composability ˙ </details> -- **[<img src='https://avatars.githubusercontent.com/u/171401041' width='16' height='16' alt='icon' /> Picocrypt](None)** - Picocrypt is a very small (hence Pico), very simple, yet very secure encryption tools -that you can use to protect your files. It's designed to be the go-to tool for encryption, +- **[<img src='https://avatars.githubusercontent.com/u/171401041' width='16' height='16' alt='icon' /> Picocrypt]()** - Picocrypt is a very small (hence Pico), very simple, yet very secure encryption tools +that you can use to protect your files. It's designed to be the go-to tool for encryption, with a focus on security, simplicity, and reliability. […](https://awesome-privacy.xyz/essentials/file-encryption/picocrypt "View full Picocrypt report") - <details> @@ -565,10 +565,10 @@ meta data. </details> - **[<img src='https://github.com/simplex-chat/simplex-chat/blob/stable/media-logos/simplex-symbol-light.png' width='16' height='16' alt='icon' /> SimpleX](https://simplex.chat/)** - Simplex is gaining popularity as a secure and private messaging app renowned -for its robust encryption protocol without user IDs or phone numbers and this improves your privacy. +for its robust encryption protocol without user IDs or phone numbers and this improves your privacy. Simplex offers instant messaging, supports media attachments and voice and video calls. -Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's -preferences for convenience, security, and accessibility. +Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's +preferences for convenience, security, and accessibility. Learn more about the [Security Policy](https://simplex.chat/security/). […](https://awesome-privacy.xyz/communication/encrypted-messaging/simplex "View full SimpleX report") - <details> @@ -742,11 +742,11 @@ full-featured free plan and premium subscription plans allowing for custom domains (starting at $3/month). Tuta [does not use OpenPGP](https://tuta.com/blog/posts/differences-email-encryption/) like other encrypted mail providers, instead they use a standardized, hybrid method -consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048 +consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048 or ECC (x25519) and Kyber-1024). This causes compatibility issues when communicating with contacts using PGP. But it does allow them to encrypt much more of the header data (body, attachments, subject lines, and sender names etc) which PGP mail providers cannot do. The recent upgrades -to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks +to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks posed by quantum computers. […](https://awesome-privacy.xyz/communication/encrypted-email/tuta "View full Tuta report") - <details> @@ -1061,7 +1061,7 @@ Native apps for Android, iOS, Windows, GNU/Linux and MacOS. ### Virtual Phone Numbers -- **[<img src='https://i.ibb.co/2t4MBFj/apple-touch-icon.png' width='16' height='16' alt='icon' /> SMSPool](https://www.smspool.net)** - Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers. +- **[<img src='https://i.ibb.co/2t4MBFj/apple-touch-icon.png' width='16' height='16' alt='icon' /> SMSPool](https://www.smspool.net)** - Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers. We support over 50+ countries and support over 300+ services. […](https://awesome-privacy.xyz/communication/virtual-phone-numbers/smspool "View full SMSPool report") - <details> @@ -1474,7 +1474,7 @@ and other features you may expect of a full-featured translation solution in-bro <details> <summary>✳️ <b>Notable Mentions</b></summary> -> - [Extension source viewer](https://addons.mozilla.org/en-US/firefox/addon/crxviewer) - A handy extension for viewing the source code of another browser extension, which is a useful tool for verifying the code does what it says +> - [Extension source viewer](https://addons.mozilla.org/en-US/firefox/addon/crxviewer) - A handy extension for viewing the source code of another browser extension, which is a useful tool for verifying the code does what it says </details> <p align="right"><sup><a href="#top">⬆️ [Back to Top]</a></sub></p> @@ -2830,7 +2830,7 @@ who have legal right to view your data. ### DNS Hosting - **[<img src='https://desec.io/favicon.svg' width='16' height='16' alt='icon' /> deSEC](https://desec.io)** - Free DNS hosting provider designed with security in mind, and running -on purely open source software. deSEC is backed and funded by SSE. +on purely open source software. deSEC is backed and funded by SSE. […](https://awesome-privacy.xyz/networking/dns-hosting/desec "View full deSEC report") - <details> <summary>Stats</summary> @@ -2991,7 +2991,7 @@ month for 10GB, additional plans go up-to 2TB. <summary>✳️ <b>Notable Mentions</b></summary> > If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com) -> is a utility that encrypts your entire notebook, before it is uploaded to the cloud. +> is a utility that encrypts your entire notebook, before it is uploaded to the cloud. > > [Org Mode](https://orgmode.org) is a mode for [GNU Emacs](https://www.gnu.org/software/emacs/) > dedicated to working with the Org markup format. Org can be thought of as @@ -3581,7 +3581,7 @@ for Windows, implemented by Bogdan Hrastnik. > > If you have [GIMP](https://www.gimp.org) installed, then just go to `File --> Export As --> Export --> Advanced Options --> Uncheck the "Save EXIF data" option`. > -> Often you need to perform meta data removal programmatically, as part of a script or automation process. +> Often you need to perform meta data removal programmatically, as part of a script or automation process. > - GoLang: [go-exif](https://github.com/dsoprea/go-exif) by @dsoprea > - JS: [exifr](https://github.com/MikeKovarik/exifr) by @MikeKovarik > - Python: [Piexif](https://github.com/hMatoba/Piexif) by @hMatoba @@ -3684,7 +3684,7 @@ collecting a wealth of information, and logging your every move. A [custom ROM](https://en.wikipedia.org/wiki/List_of_custom_Android_distributions), is an open source, usually Google-free mobile OS that can be flashed to your device. -- **[<img src='https://grapheneos.org/apple-touch-icon.png' width='16' height='16' alt='icon' /> GrapheneOS](https://grapheneos.org/)** - GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay. +- **[<img src='https://grapheneos.org/apple-touch-icon.png' width='16' height='16' alt='icon' /> GrapheneOS](https://grapheneos.org/)** - GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay. GrapheneOS is a young project, and currently only supports Pixel devices, partially due to their strong hardware security. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/grapheneos "View full GrapheneOS report") - <details> @@ -3694,8 +3694,8 @@ GrapheneOS is a young project, and currently only supports Pixel devices, partia ˙ </details> -- **[<img src='https://calyxos.org/assets/images/favicon/apple-touch-icon.png' width='16' height='16' alt='icon' /> CalyxOS](https://calyxos.org)** - CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users. -Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently +- **[<img src='https://calyxos.org/assets/images/favicon/apple-touch-icon.png' width='16' height='16' alt='icon' /> CalyxOS](https://calyxos.org)** - CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users. +Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePlus 9 test builds available. Developed by the Calyx Foundation. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/calyxos "View full CalyxOS report") - <details> @@ -3705,8 +3705,8 @@ only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePl ˙ </details> -- **[<img src='https://divestos.org/images/favicon.png' width='16' height='16' alt='icon' /> DivestOS](https://divestos.org)** - DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of -discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained +- **[<img src='https://divestos.org/images/favicon.png' width='16' height='16' alt='icon' /> DivestOS](https://divestos.org)** - DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of +discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained solely by Tad (SkewedZeppelin) since 2014. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/divestos "View full DivestOS report") - <details> @@ -3716,7 +3716,7 @@ solely by Tad (SkewedZeppelin) since 2014. ˙ </details> -- **[<img src='https://www.lineageos.org/images/logo.png' width='16' height='16' alt='icon' /> LineageOS](https://www.lineageos.org)** - A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained, +- **[<img src='https://www.lineageos.org/images/logo.png' width='16' height='16' alt='icon' /> LineageOS](https://www.lineageos.org)** - A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained, supports a wide range of devices, and comes bundled with Privacy Guard. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/lineageos "View full LineageOS report") - <details> @@ -3903,7 +3903,7 @@ which are not available in a standard Windows 10 instance. It does require some technical knowledge to get started with, but once setup should perform just as any other Windows 10 system. Note that you should only download the LTSC ISO from the Microsoft's -[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise) +[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise) #### Improve the Security and Privacy of your current OS @@ -4106,7 +4106,7 @@ and check reviews/ forums. Create a system restore point, before making any significant changes to your OS (such as disabling core features). -From a security and privacy perspective, Linux may be a better option. +From a security and privacy perspective, Linux may be a better option. </details> @@ -4188,7 +4188,7 @@ decrease your privacy. Never use a free anti-virus, and never trust the companie that offer free solutions, even if you pay for the premium package. This includes (but not limited to) Avast, AVG, McAfee and Kasperky. For AV to be effective, it needs intermate access to all areas of your PC, -so it is important to go with a trusted vendor, and monitor its activity closely. +so it is important to go with a trusted vendor, and monitor its activity closely. </details> @@ -4334,7 +4334,7 @@ you are happy with how it works, from a privacy perspective. - **[<img src='https://avatars.githubusercontent.com/u/7365162?s=200&v=4' width='16' height='16' alt='icon' /> Gladys Assistant](https://gladysassistant.com/)** - An open source privacy-respecting Home Assistant, compatible with a wide range of devices including Raspberry Pi, desktop computers, or NAS systems. -Actively developed, with good french community and various integrations +Actively developed, with good french community and various integrations (Zigbee, Philips, Camera, Tuya, MQTT, Telegram, ...). […](https://awesome-privacy.xyz/smart-home-and-iot/smart-home/gladys-assistant "View full Gladys Assistant report") - <details> @@ -4407,7 +4407,7 @@ and be very weary crypto-related scams are very common. as is and cryptocurrency > Other privacy-focused cryptocurrencies include: > [PIVX](https://pivx.org), -> [Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/). +> [Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/). > </details> <details> @@ -4774,12 +4774,12 @@ without algorithmic timeline manipulations. It operates across independent serve ˙ </details> -- **[<img src='https://icon.horse/icon/github.com' width='16' height='16' alt='icon' /> nostr](https://github.com/nostr-protocol/nostr)** - nostr stands for Notes and other stuff transmitted by relays. -It is an open protocol, not merely a platform. -This distinction enables truly censorship-resistant and global value-for-value publishing on the web. -With the power to replace data-greedy applications like Twitter and Instagram, -nostr offers a promising alternative for users seeking a more private and secure online experience -without algorithmic manipulations. ".... I feel like I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr. +- **[<img src='https://icon.horse/icon/github.com' width='16' height='16' alt='icon' /> nostr](https://github.com/nostr-protocol/nostr)** - nostr stands for Notes and other stuff transmitted by relays. +It is an open protocol, not merely a platform. +This distinction enables truly censorship-resistant and global value-for-value publishing on the web. +With the power to replace data-greedy applications like Twitter and Instagram, +nostr offers a promising alternative for users seeking a more private and secure online experience +without algorithmic manipulations. ".... I feel like I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr. […](https://awesome-privacy.xyz/social/social-networks/nostr "View full nostr report") - <details> <summary>Stats</summary> @@ -5291,9 +5291,9 @@ easily extendable via community plugins, or by writing Python scripts great free alternative to Adobe Audition. Features recording from real and virtual devices, import/export to a wide range of formats, high-quality processing -advanced multi-track editing, noise reduction, pitch correction, +advanced multi-track editing, noise reduction, pitch correction, audio restoration and much more. -It's easily extendable via community plugins, and +It's easily extendable via community plugins, and also supports cusotm macros and many scripting options […](https://awesome-privacy.xyz/creativity/audio-editors-and-recorders/audacity "View full Audacity report") - <details> From 5391cc66b8073835832e1c13df6f14870b66cdb7 Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Mon, 23 Feb 2026 22:03:56 +0000 Subject: [PATCH 24/56] Updated Awesome Privacy content (last modified on 23-Feb-2026) --- .github/README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index 0caa538..afecb18 100644 --- a/.github/README.md +++ b/.github/README.md @@ -107,7 +107,7 @@ corporations, governments, and hackers from logging, storing or selling your per - [E-Book Readers](#e-book-readers) (0) - [Podcast Players](#podcast-players) (0) - [Torrent Downloaders](#torrent-downloaders) (0) - - [File Converters](#file-converters) (0) + - [File Converters](#file-converters) (1) - **Creativity** - [Image Editors](#image-editors) (8) - [Video Editors](#video-editors) (7) @@ -5107,7 +5107,19 @@ It is built upon the [Invidious](https://invidious.io/) API. ### File Converters -<p align="center"><b>⚠️ This section is still a work in progress ⚠️</b><br /><i>Check back soon, or help us complete it by submitting a pull request</i></p> +- **[<img src='https://ffmpeg.org/favicon.ico' width='16' height='16' alt='icon' /> FFmpeg](https://ffmpeg.org/)** - A complete, cross-platform solution to record, convert, and stream audio and +video. It's the industry standard multimedia framework, handling a vast range +of formats. As a command-line tool, it guarantees that all processing is done +locally on your machine. +[…](https://awesome-privacy.xyz/media/file-converters/ffmpeg "View full FFmpeg report") + - <details> + <summary>Stats</summary> + + [![GitHub: FFmpeg/FFmpeg](https://img.shields.io/github/stars/FFmpeg/FFmpeg?style=flat&logo=github&label=FFmpeg&color=%235f53f4&cacheSeconds=3600)](https://github.com/FFmpeg/FFmpeg) [![FFmpeg on Awesome Privacy](https://img.shields.io/badge/View%20Report-FC60A8?style=flat&logo=awesomelists&label=FFmpeg)](https://awesome-privacy.xyz/media/file-converters/ffmpeg) +📦 Open Source ˙ + + </details> + <p align="right"><sup><a href="#top">⬆️ [Back to Top]</a></sub></p> --- From 2b7a5a02f4de39082ec98382bfae593eea479114 Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Mon, 23 Feb 2026 22:26:52 +0000 Subject: [PATCH 25/56] Updated Awesome Privacy content (last modified on 23-Feb-2026) --- .github/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/README.md b/.github/README.md index afecb18..5096200 100644 --- a/.github/README.md +++ b/.github/README.md @@ -408,7 +408,7 @@ Brave also has Tor built-in, when you open up a private tab/ window. ˙ </details> -- **[<img src='https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg' width='16' height='16' alt='icon' /> Firefox](https://www.mozilla.org/firefox)** - Significantly more private, and offers some nifty privacy features than Chrome, +- **[<img src='https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg' width='16' height='16' alt='icon' /> Firefox](https://www.firefox.com/)** - Significantly more private, and offers some nifty privacy features than Chrome, Internet Explorer and Safari. After installing, there are a couple of small tweaks you will need to make, in order to secure Firefox. For a though config, see [@arkenfox's user.js](https://github.com/arkenfox/user.js/). You can also follow From 5e57a1ca0c91ff9161f829515522067f06abb5ea Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Tue, 24 Feb 2026 12:32:50 +0000 Subject: [PATCH 26/56] Updates checks script --- lib/checks/check-additions.py | 30 +++++++++++++++++++++++------- lib/checks/check-readme-edits.py | 18 ------------------ lib/checks/check-yaml-diff.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index 30cad8a..f972f1e 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -150,30 +150,46 @@ def check_single_entry(diff): return None -def build_name_index(head): - """Build {lowercase_name: "category > section"} from all services.""" +def _added_keys(diff): + """Build a set of (category, section, lowercase_name) for added services.""" + keys = set() + for svc in diff.get("services", {}).get("added", []): + name = svc.get("fields", {}).get("name", "").lower().strip() + keys.add((svc.get("category", ""), svc.get("section", ""), name)) + return keys + + +def build_name_index(head, diff): + """Build {lowercase_name: "category > section"} from all services, excluding additions.""" index = {} if not head: return index + exclude = _added_keys(diff) for cat in head.get("categories", []): cn = cat.get("name", "") for sec in cat.get("sections", []): sn = sec.get("name", "") for svc in sec.get("services", []): name = svc.get("name", "").lower().strip() - if name: + if name and (cn, sn, name) not in exclude: index[name] = f"{cn} > {sn}" return index -def build_url_index(head): - """Build {url: service_name} from all services, skipping empty URLs.""" +def build_url_index(head, diff): + """Build {url: service_name} from all services, excluding additions.""" index = {} if not head: return index + exclude = _added_keys(diff) for cat in head.get("categories", []): + cn = cat.get("name", "") for sec in cat.get("sections", []): + sn = sec.get("name", "") for svc in sec.get("services", []): + name = svc.get("name", "").lower().strip() + if (cn, sn, name) in exclude: + continue url = svc.get("url", "") if url: index[url] = svc.get("name", "") @@ -245,8 +261,8 @@ def main(): if finding: findings.append(finding) - name_index = build_name_index(head) - url_index = build_url_index(head) + name_index = build_name_index(head, diff) + url_index = build_url_index(head, diff) finding = check_duplicate_name(diff, name_index) if finding: diff --git a/lib/checks/check-readme-edits.py b/lib/checks/check-readme-edits.py index aa733b5..4aa5598 100644 --- a/lib/checks/check-readme-edits.py +++ b/lib/checks/check-readme-edits.py @@ -72,23 +72,6 @@ def get_changed_line_numbers(base_ref): return changed_lines - -def write_step_summary(): - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_file: - return - - lines = [ - "## Direct README Edit Detected\n", - "This PR directly modifies the auto-generated section of `.github/README.md` " - "(between `<!-- awesome-privacy-start -->` and `<!-- awesome-privacy-end -->`).\n", - "**Please edit `awesome-privacy.yml` instead.** The README is regenerated automatically from that file.\n", - ] - - with open(summary_file, "a") as f: - f.write("\n".join(lines) + "\n") - - def main(): parser = argparse.ArgumentParser(description="Check for direct README edits to generated section") parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") @@ -112,7 +95,6 @@ def main(): if start_line <= line_num <= end_line: print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr) print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr) - write_step_summary() sys.exit(EXIT_FAIL) print(green("README changes are outside the generated section, OK.")) diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index a29e096..5ddc798 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -90,6 +90,23 @@ def write_github_output(name, value): f.write(f"{name}={value}\n") +def find_duplicate_names(data): + """Find duplicate service names within the same section.""" + duplicates = [] + for cat in data.get("categories", []): + cn = cat.get("name", "") + for sec in cat.get("sections", []): + sn = sec.get("name", "") + seen = {} + for svc in sec.get("services", []): + name = svc.get("name", "") + if name in seen: + duplicates.append((cn, sn, name)) + else: + seen[name] = True + return duplicates + + def fmt_path(key): """Format a tuple key as a readable path.""" return " → ".join(key) if isinstance(key, tuple) else key @@ -126,6 +143,11 @@ def write_step_summary(diff_result): bullets.append(f"- Added category **{change['category']}**") else: bullets.append(f"- Removed category **{change['category']}**") + for dup in diff_result.get("duplicates", []): + bullets.append( + f"- ⚠️ Duplicate service name **{dup['service']}** " + f"in {dup['category']} → {dup['section']}" + ) if bullets: lines.extend(bullets) @@ -175,10 +197,15 @@ def main(): for k in cat_removed: categories.append({"category": k, "change_type": "removed_category"}) + duplicates = find_duplicate_names(head) + dup_entries = [{"category": d[0], "section": d[1], "service": d[2]} + for d in duplicates] + diff_result = { "services": {"added": added, "removed": removed, "modified": modified}, "sections": sections, "categories": categories, + "duplicates": dup_entries, } with open(DIFF_OUTPUT_PATH, "w") as f: @@ -196,6 +223,11 @@ def main(): print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) + if duplicates: + names = ", ".join(f"{d[2]} (in {d[0]} → {d[1]})" for d in duplicates) + print(red(f"Duplicate service names found: {names}"), file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + total = len(added) + len(removed) + len(modified) print(green(f"Single-entry rule passed. {total} service " f"({added_count} added), {len(sections)} section, " From 38f013a549842c5395fb4a51f5e639458277adfa Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Tue, 24 Feb 2026 13:23:17 +0000 Subject: [PATCH 27/56] Updated Awesome Privacy content (last modified on 24-Feb-2026) --- .github/README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/README.md b/.github/README.md index 5096200..599cb8d 100644 --- a/.github/README.md +++ b/.github/README.md @@ -22,7 +22,7 @@ corporations, governments, and hackers from logging, storing or selling your per - **Essentials** - [Password Managers](#password-managers) (6) - - [2-Factor Authentication](#2-factor-authentication) (9) + - [2-Factor Authentication](#2-factor-authentication) (10) - [File Encryption](#file-encryption) (4) - [Browsers](#browsers) (5) - [Search Engines](#search-engines) (5) @@ -290,6 +290,19 @@ fashion. It can also be used offline on a single device with no account necessar 📦 Open Source ˙ </details> +- **[<img src='https://avatars.githubusercontent.com/u/15990069' width='16' height='16' alt='icon' /> Bitwarden Authenticator](https://bitwarden.com/products/authenticator/)** - Bitwarden Authenticator is a free and open-source app which stores and generates +time-based codes for multi-factor authentication. It can be used with an online +account to backup and sync your tokens across your devices (and access them via +a web interface) in a secure, end-to-end encrypted fashion. It can also be used +offline on a single device with no account necessary. +[…](https://awesome-privacy.xyz/essentials/2-factor-authentication/bitwarden-authenticator "View full Bitwarden Authenticator report") + - <details> + <summary>Stats</summary> + + [![GitHub: bitwarden/ios](https://img.shields.io/github/stars/bitwarden/ios?style=flat&logo=github&label=ios&color=%235f53f4&cacheSeconds=3600)](https://github.com/bitwarden/ios) [![Privacy Policy](https://shields.tosdr.org/en_5256.svg)](https://tosdr.org/en/service/5256) [![Bitwarden Authenticator on Awesome Privacy](https://img.shields.io/badge/View%20Report-FC60A8?style=flat&logo=awesomelists&label=Bitwarden_Authenticator)](https://awesome-privacy.xyz/essentials/2-factor-authentication/bitwarden-authenticator) +📦 Open Source ˙ + + </details> <details> <summary>✳️ <b>Notable Mentions</b></summary> From df4d0590d80018d2be496e0eb6dedb2386c19e3d Mon Sep 17 00:00:00 2001 From: Bruno Bernardino <me@brunobernardino.com> Date: Tue, 24 Feb 2026 13:29:31 +0000 Subject: [PATCH 28/56] Remove Budget Zen It was archived almost a year ago. References: - https://github.com/BrunoBernardino/budgetzen-web --- awesome-privacy.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 33bb472..bc361e6 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -4723,9 +4723,8 @@ categories: [Smart Wallet](https://apps.apple.com/app/smart-wallet/id1378013954) (iOS), [My-Budget](https://rezach.github.io/my-budget) (Desktop), [MoneyManager EX](https://www.moneymanagerex.org), - [Skrooge](https://skrooge.org), - [kMyMoney](https://kmymoney.org) and - [Budget Zen](https://budgetzen.net) (a simple E2E encrypted budget manager) + [Skrooge](https://skrooge.org), and + [kMyMoney](https://kmymoney.org). - name: Social From 406644701b44a49333b845026ad24fe0b80c2c0c Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Tue, 24 Feb 2026 19:20:05 +0000 Subject: [PATCH 29/56] Updated Awesome Privacy content (last modified on 24-Feb-2026) --- .github/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/README.md b/.github/README.md index 599cb8d..3ade5f1 100644 --- a/.github/README.md +++ b/.github/README.md @@ -4735,9 +4735,8 @@ approach to accounting. > [Smart Wallet](https://apps.apple.com/app/smart-wallet/id1378013954) (iOS), > [My-Budget](https://rezach.github.io/my-budget) (Desktop), > [MoneyManager EX](https://www.moneymanagerex.org), -> [Skrooge](https://skrooge.org), -> [kMyMoney](https://kmymoney.org) and -> [Budget Zen](https://budgetzen.net) (a simple E2E encrypted budget manager) +> [Skrooge](https://skrooge.org), and +> [kMyMoney](https://kmymoney.org). > </details> <p align="right"><sup><a href="#top">⬆️ [Back to Top]</a></sub></p> From 2a717de2f3cabea4be9c3d5d1db36d22dc56a720 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino <me@brunobernardino.com> Date: Tue, 24 Feb 2026 20:36:06 +0000 Subject: [PATCH 30/56] Add bewCloud Full disclosure: I'm the author of [bewCloud](https://bewcloud.com). bewCloud is a modern and simpler alternative to Nextcloud and ownCloud. It's AGPL-3.0 licensed, has reached over 1.1k stars in GitHub, and (apparently, since it's not possible to prove) has hundreds of deployments live at the moment. The website also has no analytics. --- awesome-privacy.yml | 53 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index bc361e6..d4446b6 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -3095,6 +3095,19 @@ categories: The disadvantage is that only the frontend is open source. Pricing is free for starter plan, $3/ month for 10GB, additional plans go up-to 2TB. + - name: bewCloud + url: https://bewcloud.com + icon: https://bewcloud.com/favicon.svg + followWith: Web + github: bewcloud/bewcloud + openSource: true + description: | + Modern and simpler alternative to Nextcloud and ownCloud, built with + TypeScript and Deno, with a Docker image available. + Combines file management, syncing, sharing, markdown notes, RSS feeds, + expenses, calendars, contacts, and photo viewing in one interface. + Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. + notableMentions: | If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com) is a utility that encrypts your entire notebook, before it is uploaded to the cloud. @@ -3157,6 +3170,19 @@ categories: A key benefit the wide range of plug-ins in the NextCloud App Store, maintained by the community. NextCloud was a hard fork off OwnCloud. + - name: bewCloud + url: https://bewcloud.com + icon: https://bewcloud.com/favicon.svg + followWith: Web + github: bewcloud/bewcloud + openSource: true + description: | + Modern and simpler alternative to Nextcloud and ownCloud, built with + TypeScript and Deno, with a Docker image available. + Combines file management, syncing, sharing, markdown notes, RSS feeds, + expenses, calendars, contacts, and photo viewing in one interface. + Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. + notableMentions: | Alternatively, consider a headless utility such as [Duplicacy](https://duplicacy.com) or [Duplicity](http://duplicity.nongnu.org). @@ -4709,6 +4735,18 @@ categories: approach to accounting. url: https://plaintextaccounting.org + - name: bewCloud + url: https://bewcloud.com + icon: https://bewcloud.com/favicon.svg + followWith: Web + github: bewcloud/bewcloud + openSource: true + description: | + Modern and simpler alternative to Nextcloud and ownCloud, built with + TypeScript and Deno, with a Docker image available. + Combines file management, syncing, sharing, markdown notes, RSS feeds, + expenses, calendars, contacts, and photo viewing in one interface. + Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. notableMentions: | Spreadsheets remain a popular choice for managing budgets and financial planning. @@ -4927,6 +4965,19 @@ categories: news feed without manipulated content. Parts of the service are open source. url: https://feedly.com tosdrId: 405 + + - name: bewCloud + url: https://bewcloud.com + icon: https://bewcloud.com/favicon.svg + followWith: Web + github: bewcloud/bewcloud + openSource: true + description: | + Modern and simpler alternative to Nextcloud and ownCloud, built with + TypeScript and Deno, with a Docker image available. + Combines file management, syncing, sharing, markdown notes, RSS feeds, + expenses, calendars, contacts, and photo viewing in one interface. + Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. - name: Proxy Sites intro: | @@ -5254,5 +5305,3 @@ categories: description: An animated sprite editor & pixel art tool for Windows, macOS and Linux. github: https://github.com/aseprite/aseprite openSource: true - - From bd40fc2dc7b3ada8c5443c577e9a32da72e465d9 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino <me@brunobernardino.com> Date: Tue, 24 Feb 2026 20:42:17 +0000 Subject: [PATCH 31/56] I thought it was OK to add the same entry in multiple places, but apparently not, sorry --- awesome-privacy.yml | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index d4446b6..42bfdd1 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -3095,19 +3095,6 @@ categories: The disadvantage is that only the frontend is open source. Pricing is free for starter plan, $3/ month for 10GB, additional plans go up-to 2TB. - - name: bewCloud - url: https://bewcloud.com - icon: https://bewcloud.com/favicon.svg - followWith: Web - github: bewcloud/bewcloud - openSource: true - description: | - Modern and simpler alternative to Nextcloud and ownCloud, built with - TypeScript and Deno, with a Docker image available. - Combines file management, syncing, sharing, markdown notes, RSS feeds, - expenses, calendars, contacts, and photo viewing in one interface. - Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. - notableMentions: | If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com) is a utility that encrypts your entire notebook, before it is uploaded to the cloud. @@ -4735,19 +4722,6 @@ categories: approach to accounting. url: https://plaintextaccounting.org - - name: bewCloud - url: https://bewcloud.com - icon: https://bewcloud.com/favicon.svg - followWith: Web - github: bewcloud/bewcloud - openSource: true - description: | - Modern and simpler alternative to Nextcloud and ownCloud, built with - TypeScript and Deno, with a Docker image available. - Combines file management, syncing, sharing, markdown notes, RSS feeds, - expenses, calendars, contacts, and photo viewing in one interface. - Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. - notableMentions: | Spreadsheets remain a popular choice for managing budgets and financial planning. [Collabora](https://nextcloud.com/collaboraonline) or @@ -4965,19 +4939,6 @@ categories: news feed without manipulated content. Parts of the service are open source. url: https://feedly.com tosdrId: 405 - - - name: bewCloud - url: https://bewcloud.com - icon: https://bewcloud.com/favicon.svg - followWith: Web - github: bewcloud/bewcloud - openSource: true - description: | - Modern and simpler alternative to Nextcloud and ownCloud, built with - TypeScript and Deno, with a Docker image available. - Combines file management, syncing, sharing, markdown notes, RSS feeds, - expenses, calendars, contacts, and photo viewing in one interface. - Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. - name: Proxy Sites intro: | From b9b4caf3cd8096bee836d3bed7e3b410a49ea39f Mon Sep 17 00:00:00 2001 From: Bruno Bernardino <me@brunobernardino.com> Date: Tue, 24 Feb 2026 20:47:15 +0000 Subject: [PATCH 32/56] Tweak description --- awesome-privacy.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 42bfdd1..696e8a9 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -3164,11 +3164,9 @@ categories: github: bewcloud/bewcloud openSource: true description: | - Modern and simpler alternative to Nextcloud and ownCloud, built with - TypeScript and Deno, with a Docker image available. - Combines file management, syncing, sharing, markdown notes, RSS feeds, - expenses, calendars, contacts, and photo viewing in one interface. - Supports MFA, WebDAV, CalDAV, CardDAV, SSO, and more. + Modern and simpler alternative to Nextcloud/ownCloud crafted with TypeScript. + Unifies file management, sync, sharing, notes, RSS, expenses, calendars, + contacts, and photos, with MFA, WebDAV, CalDAV, CardDAV, SSO, and more. notableMentions: | Alternatively, consider a headless utility such as [Duplicacy](https://duplicacy.com) From 965009fc219cdf81eae16472ffe2abbd12e3c90e Mon Sep 17 00:00:00 2001 From: Bruno Bernardino <me@brunobernardino.com> Date: Tue, 24 Feb 2026 20:49:51 +0000 Subject: [PATCH 33/56] Bring back whitespaces --- awesome-privacy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 696e8a9..1f92fb3 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -4719,6 +4719,7 @@ categories: Ledger, hledger, and Beancount among others, providing a flexible and vendor-independent approach to accounting. url: https://plaintextaccounting.org + notableMentions: | Spreadsheets remain a popular choice for managing budgets and financial planning. @@ -5264,3 +5265,4 @@ categories: description: An animated sprite editor & pixel art tool for Windows, macOS and Linux. github: https://github.com/aseprite/aseprite openSource: true + From f64537af771dc0facdb0923eb05ecef9f98df956 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino <me@brunobernardino.com> Date: Tue, 24 Feb 2026 20:50:53 +0000 Subject: [PATCH 34/56] Fix whitespaces --- awesome-privacy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 1f92fb3..b71c8d0 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -4719,7 +4719,7 @@ categories: Ledger, hledger, and Beancount among others, providing a flexible and vendor-independent approach to accounting. url: https://plaintextaccounting.org - + notableMentions: | Spreadsheets remain a popular choice for managing budgets and financial planning. @@ -5266,3 +5266,4 @@ categories: github: https://github.com/aseprite/aseprite openSource: true + From ce3a59e13911a18ec706ec2d80f2274a021a9241 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Thu, 26 Feb 2026 12:24:43 +0000 Subject: [PATCH 35/56] Updates contributing and automated checks to ensure project age --- .github/CONTRIBUTING.md | 13 +++-- .github/workflows/pr-check.yml | 10 +++- lib/.gitignore | 1 + lib/checks/check-additions.py | 4 +- lib/checks/check-project.py | 103 +++++++++++++++++++++++++++++++++ lib/checks/check-yaml-diff.py | 30 +++++++--- lib/checks/format-comment.py | 73 +++++++++++++++-------- 7 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 lib/.gitignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b9e07ef..245ab68 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -62,6 +62,10 @@ For software to be included in this list, it must meet the following requirement - A stable (non-alpha/beta) release is required at a minimum - Must be accessible to the general public, and not just a select group of people - If technical knowledge is required to run it, the software must be well documented +- **Mature** + - Software needs to have a proven track record of commitment to maintenance + - Repositories must not be newly created, and the first stable release older than 4 months + - Projects primarily written with AI or vibe coded are not suitable for listing here _There may be some exceptions, but these would need to be fully justified, reviewed by the community, and the drawbacks / anti-features must be clearly listed along-side the software. @@ -79,19 +83,18 @@ Your pull request must follow these requirements. Failure to do so, might result - You must respond to any comments or requests for changes in a timely manner, 14 days maximum - Write short but descriptive git commit messages, under 50 characters. This must be in the format of `Adds [software-name] to [section-name]`. Your PR will be rejected if you name it `Updates README.md` - Only include a single addition / amendment / removal, per pull request -- You must complete each of the sections in the pull request template. Do not delete it! +- You must complete each of the sections in the [pull request template](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Do not delete it! - Where applicable, include links to supporting material for your addition: git repo, docs, recent security audits, etc. This will make researching it much easier for reviewers - While adding new software to the list, don't make your entry read like an advert. Be objective, and include drawbacks as well as strengths - Your entry should be added at the bottom of the appropriate category, unless otherwise requested -- If there are other pull requests open, please help review them before submitting yours -- A pull request must receive multiple approval reviews before it can be merged - You must be transparent about your affiliation with a product or service that you are adding. It's totally okay to submit your own projects as additions (providing they meet the requirements), but if you don't declare your association with that project then there becomes a clear conflict of interest -- You must adhere to the Contributor Covenant Code of Conduct +- You must adhere to the [Contributor Covenant Code of Conduct](https://github.com/Lissy93/awesome-privacy?tab=coc-ov-file#contributor-covenant-code-of-conduct) - Don't open a Draft / WIP pull request while you work on the guidelines. A pull request should be 100% ready and should adhere to all the above guidelines when you open it - Your changes must be correctly spelled, and with good grammar - Your changes must be correctly formatted, in valid yaml and markdown -- The addition title must be a link the project - The addition description must be no less than 50, and no more than 250 characters, keep it clear and to the point +- If there are other pull requests open, please help review them before submitting yours +- A pull request must receive multiple approval reviews before it can be merged --- diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index d30babe..d4dfac5 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -82,7 +82,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: pr-diff - path: /tmp/pr-diff.json + path: | + /tmp/pr-diff.json + /tmp/pr-diff-summary.md if-no-files-found: ignore - name: Upload findings if: always() @@ -142,6 +144,12 @@ jobs: path: /tmp/artifacts merge-multiple: true continue-on-error: true + - name: Download diff data + uses: actions/download-artifact@v4 + with: + name: pr-diff + path: /tmp/artifacts + continue-on-error: true - name: Format comment env: PR_USER: ${{ github.event.pull_request.user.login }} diff --git a/lib/.gitignore b/lib/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/lib/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index f972f1e..b2cf8b4 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -131,7 +131,7 @@ def check_open_source(diff): """Return a finding if an added service has openSource missing or not true.""" for svc in diff.get("services", {}).get("added", []): fields = svc.get("fields", {}) - if fields.get("openSource") is not True: + if fields.get("openSource") is not True and not fields.get("github"): return OPENSOURCE_MSG return None @@ -221,7 +221,7 @@ def check_description_length(diff): for svc in diff.get("services", {}).get("added", []): desc = svc.get("fields", {}).get("description", "") length = len(desc) - if length < 50 or length > 250: + if length < 50 or length > 280: return DESC_LENGTH_MSG.format(length=length) return None diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py index abb2d87..bf8335f 100644 --- a/lib/checks/check-project.py +++ b/lib/checks/check-project.py @@ -17,6 +17,12 @@ TIMEOUT = 10 USER_AGENT = "awesome-privacy-ci/1.0" MIN_STARS = 100 INACTIVE_DAYS = 90 +MIN_AGE_DAYS = 120 +AI_COMMIT_THRESHOLD = 5 +AI_BOT_AUTHORS = [ + "noreply@anthropic.com", + "devin-ai-integration[bot]", +] LINK_MSG = ( "Our automated checks were unable to verify the link(s) you included" @@ -35,6 +41,29 @@ ACTIVITY_MSG = ( "Please confirm that the project you are adding is actively maintained," " as it looks to not have had any recent updates in the past 3 months." ) +MATURITY_MSG = ( + "This project appears to be quite new (created less than 4 months ago)." + " Repositories should have a proven track record before listing." +) +AI_CODE_MSG = ( + "This project appears to contain AI-generated code." + " Additional care will be needed when reviewing the submission." +) +FORK_MSG = ( + "The GitHub link in this listing is a fork." + " Please confirm it's the correct (and actively maintained) repository" +) +LICENSE_MSG = ( + "There doesn't appear to be a license included in the project's GitHub repo" +) +ARCHIVED_MSG = ( + "The GitHub project linked has been archived." + " Additions must be actively maintained." +) +SECURITY_MSG = ( + "This project has open security vulnerabilities (critical or high severity)" + " flagged by GitHub Dependabot. Please verify these have been addressed" +) def load_diff(path): @@ -154,6 +183,51 @@ def check_links(diff, head): return None +def check_ai_commits(owner, repo, token): + """Return AI_CODE_MSG if recent commits contain significant AI bot activity.""" + try: + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT} + if token: + headers["Authorization"] = f"token {token}" + resp = requests.get( + f"https://api.github.com/repos/{owner}/{repo}/commits", + headers=headers, timeout=TIMEOUT, params={"per_page": 100}, + ) + if resp.status_code != 200: + return None + bot_set = {a.lower() for a in AI_BOT_AUTHORS} + count = 0 + for commit in resp.json(): + author = commit.get("commit", {}).get("author", {}) + email = (author.get("email") or "").lower() + name = (author.get("name") or "").lower() + if email in bot_set or name in bot_set: + count += 1 + if count >= AI_COMMIT_THRESHOLD: + return AI_CODE_MSG + except Exception: + pass + return None + + +def check_security_alerts(owner, repo, token): + """Return SECURITY_MSG if the repo has open critical/high Dependabot alerts.""" + try: + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT} + if token: + headers["Authorization"] = f"token {token}" + resp = requests.get( + f"https://api.github.com/repos/{owner}/{repo}/dependabot/alerts", + headers=headers, timeout=TIMEOUT, + params={"state": "open", "severity": "critical,high", "per_page": 1}, + ) + if resp.status_code == 200 and resp.json(): + return SECURITY_MSG + except Exception: + pass + return None + + def check_repo_signals(diff, pr_user, token): """Check GitHub repo author match, stars, and activity for added services.""" findings = [] @@ -185,6 +259,15 @@ def check_repo_signals(diff, pr_user, token): if stars < MIN_STARS and STARS_MSG not in findings: findings.append(STARS_MSG) + if data.get("fork") and FORK_MSG not in findings: + findings.append(FORK_MSG) + + if not data.get("license") and LICENSE_MSG not in findings: + findings.append(LICENSE_MSG) + + if data.get("archived") and ARCHIVED_MSG not in findings: + findings.append(ARCHIVED_MSG) + pushed = data.get("pushed_at") if pushed and ACTIVITY_MSG not in findings: try: @@ -195,6 +278,26 @@ def check_repo_signals(diff, pr_user, token): except Exception: pass + created = data.get("created_at") + if created and MATURITY_MSG not in findings: + try: + created_dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + if (now - created_dt).days < MIN_AGE_DAYS: + findings.append(MATURITY_MSG) + except Exception: + pass + + if AI_CODE_MSG not in findings: + finding = check_ai_commits(owner, repo, token) + if finding: + findings.append(finding) + + if SECURITY_MSG not in findings: + finding = check_security_alerts(owner, repo, token) + if finding: + findings.append(finding) + return findings diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 5ddc798..0ae7c40 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -13,6 +13,7 @@ import yaml PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") DIFF_OUTPUT_PATH = "/tmp/pr-diff.json" +SUMMARY_OUTPUT_PATH = "/tmp/pr-diff-summary.md" EXIT_PASS = 0 EXIT_RULE_VIOLATION = 1 @@ -112,13 +113,8 @@ def fmt_path(key): return " → ".join(key) if isinstance(key, tuple) else key -def write_step_summary(diff_result): - """Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY.""" - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_file: - return - - lines = ["## YAML Diff Analysis\n"] +def format_diff_bullets(diff_result): + """Build bullet-point lines summarizing all changes. Returns list of strings or empty list.""" bullets = [] for svc in diff_result["services"]["added"]: @@ -149,6 +145,25 @@ def write_step_summary(diff_result): f"in {dup['category']} → {dup['section']}" ) + return bullets + + +def write_diff_summary(diff_result): + """Write the bullet-point summary to a file for downstream consumers.""" + bullets = format_diff_bullets(diff_result) + if bullets: + with open(SUMMARY_OUTPUT_PATH, "w") as f: + f.write("\n".join(bullets) + "\n") + + +def write_step_summary(diff_result): + """Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + bullets = format_diff_bullets(diff_result) + lines = ["## YAML Diff Analysis\n"] if bullets: lines.extend(bullets) else: @@ -213,6 +228,7 @@ def main(): write_github_output("has_service_changes", str(bool(added or removed or modified)).lower()) write_step_summary(diff_result) + write_diff_summary(diff_result) added_count = len(added) if added_count > 1: diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py index d2789b1..ca00a47 100644 --- a/lib/checks/format-comment.py +++ b/lib/checks/format-comment.py @@ -7,19 +7,9 @@ import sys ARTIFACTS_DIR = "/tmp/artifacts" OUTPUT_DIR = "/tmp/pr-meta" -CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" - -COMMENT_TEMPLATE = """<!-- pr-check-bot --> -Hello @{user} - -Thank you for contributing to Awesome Privacy! We will review your PR shortly. In the meantime, please ensure that your submission is inline with our guidelines in our [Contributing Requirements]({contributing}). - -Looks like there could be some issues in your PR. Please double check that: - -{findings} - -> [!NOTE] -> I am a bot, and sometimes make mistakes in my suggestions. But a human will review your submission shortly!""" +REPO_URL = "https://github.com/Lissy93/awesome-privacy" +CONTRIBUTING = f"{REPO_URL}/blob/main/.github/CONTRIBUTING.md" +DIFF_SUMMARY_PATH = os.path.join(ARTIFACTS_DIR, "pr-diff-summary.md") def load_findings(filename): @@ -41,12 +31,49 @@ def collect_findings(): return all_findings -def format_comment(findings, user): - """Build the markdown comment from findings.""" - bullet_list = "\n".join(f"- {f}" for f in findings) - return COMMENT_TEMPLATE.format( - user=user, contributing=CONTRIBUTING, findings=bullet_list, - ) +def load_diff_summary(): + """Load the pre-formatted diff summary, or None if unavailable.""" + try: + with open(DIFF_SUMMARY_PATH) as f: + content = f.read().strip() + return content if content else None + except Exception: + return None + + +def format_comment(findings, user, changes_summary, run_id): + """Build the markdown comment.""" + parts = [ + f"<!-- pr-check-bot -->\nHello @{user}\n", + f"Thank you for contributing to Awesome Privacy! We will review your " + f"submission shortly. In the meantime, please ensure all changes are " + f"correct and inline with our [Contributing Requirements]({CONTRIBUTING}).\n", + ] + + if findings: + bullet_list = "\n".join(f"- {f}" for f in findings) + parts.append( + f"Our automated checks detected some issues:\n\n{bullet_list}\n\n" + f"> [!NOTE]\n" + f"> I am a bot, and sometimes make mistakes in my suggestions. " + f"But a human will review your submission shortly!" + ) + else: + parts.append("> ✅ All our automated checks have passed.") + + if changes_summary: + parts.append( + f"<details><summary>Summary of Changes:</summary>\n\n" + f"{changes_summary}\n</details>" + ) + + if run_id: + parts.append( + f'<sup>For full details, please see workflow run ' + f'<a href="{REPO_URL}/actions/runs/{run_id}">{run_id}</a></sup>' + ) + + return "\n\n".join(parts) + "\n" def write_step_summary(findings): @@ -81,12 +108,12 @@ def main(): f.write(run_id) findings = collect_findings() + changes_summary = load_diff_summary() write_step_summary(findings) - if findings: - comment = format_comment(findings, user) - with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f: - f.write(comment) + comment = format_comment(findings, user, changes_summary, run_id) + with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f: + f.write(comment) except Exception: pass From 4f8ab87ea7cf858740bbc26de4a648541a274469 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Thu, 26 Feb 2026 12:53:58 +0000 Subject: [PATCH 36/56] Lints source files --- web/src/components/Card.astro | 14 +- web/src/components/Hero.astro | 256 ++-- web/src/components/form/Button.astro | 51 +- web/src/components/form/EditableTitle.svelte | 24 +- web/src/components/form/FontAwesome.svelte | 8 +- web/src/components/form/Icon.astro | 131 +- web/src/components/form/Social.astro | 214 +-- web/src/components/form/ThemeSwitcher.svelte | 5 +- web/src/components/scafold/Footer.astro | 34 +- web/src/components/scafold/MainCard.astro | 8 +- web/src/components/scafold/NavBar.astro | 197 +-- .../components/things/AddNewService.svelte | 259 +++- .../things/AndroidDetailedInfo.astro | 304 ++-- web/src/components/things/Comments.svelte | 34 +- web/src/components/things/DataActions.svelte | 102 +- .../components/things/DeleteListing.svelte | 66 +- .../things/DiscordDetailedInfo.astro | 185 +-- .../components/things/GetSharableLink.svelte | 58 +- .../things/RedditDetailedInfo.astro | 326 ++-- web/src/components/things/SaveListing.svelte | 44 +- .../components/things/SavedServices.svelte | 77 +- web/src/components/things/Search.svelte | 249 ++-- web/src/components/things/SectionList.astro | 269 ++-- web/src/components/things/ServiceCard.astro | 146 +- web/src/components/things/ServiceCard.svelte | 11 +- web/src/components/things/ServiceList.astro | 662 +++++---- .../components/things/SmartSuggestions.svelte | 71 +- web/src/components/things/service-card.scss | 16 +- web/src/layouts/Layout.astro | 148 +- web/src/pages/404.astro | 165 +-- web/src/pages/[...listing].astro | 1318 +++++++++-------- web/src/pages/[category].astro | 213 +-- web/src/pages/about.astro | 615 ++++---- web/src/pages/api/awesome-privacy.json.ts | 23 +- web/src/pages/api/index.astro | 62 +- web/src/pages/api/line-numbers.json.ts | 67 +- web/src/pages/browse.astro | 390 ++--- web/src/pages/index.astro | 92 +- .../pages/inventory/[...inventoryId].astro | 150 +- web/src/pages/inventory/index.astro | 86 +- web/src/pages/search/[...searchTerm].astro | 303 ++-- web/src/pages/search/index.astro | 82 +- web/src/pages/sitemap.astro | 442 +++--- web/src/pages/submit.astro | 295 ++-- web/src/site-config.ts | 27 +- web/src/styles/typography.css | 140 +- web/src/styles/values.css | 84 +- web/src/types/Service.ts | 3 - web/src/utils/config.ts | 12 +- web/src/utils/data-src-delete-n-edit.ts | 161 +- web/src/utils/dates-n-stuff.test.ts | 32 + web/src/utils/dates-n-stuff.ts | 13 +- web/src/utils/do-searchy-searchy.test.ts | 105 ++ web/src/utils/do-searchy-searchy.ts | 14 +- web/src/utils/fetch-android-info.ts | 9 +- web/src/utils/fetch-data.test.ts | 37 + web/src/utils/fetch-data.ts | 18 +- web/src/utils/fetch-discord-info.ts | 5 +- web/src/utils/fetch-docker-instructions.ts | 6 +- web/src/utils/fetch-ios-info.ts | 5 +- web/src/utils/fetch-privacy-policy.ts | 5 +- web/src/utils/fetch-reddit-info.ts | 5 +- web/src/utils/fetch-repo-info.ts | 7 +- web/src/utils/fetch-website-info.ts | 9 +- web/src/utils/parse-markdown.test.ts | 43 + web/src/utils/parse-markdown.ts | 11 +- web/src/utils/security-check-mappings.test.ts | 68 + web/src/utils/security-check-mappings.ts | 175 +-- 68 files changed, 5117 insertions(+), 4149 deletions(-) create mode 100644 web/src/utils/dates-n-stuff.test.ts create mode 100644 web/src/utils/do-searchy-searchy.test.ts create mode 100644 web/src/utils/fetch-data.test.ts create mode 100644 web/src/utils/parse-markdown.test.ts create mode 100644 web/src/utils/security-check-mappings.test.ts diff --git a/web/src/components/Card.astro b/web/src/components/Card.astro index 7b1e813..147f7ba 100644 --- a/web/src/components/Card.astro +++ b/web/src/components/Card.astro @@ -21,19 +21,18 @@ const { href, title, body } = Astro.props; </li> <style lang="scss"> .link-card { - color: var(--foreground); - background: var(--background); - border: 2px solid var(--box-outline); - box-shadow: 6px 6px 0 var(--box-outline); - font-family: "Lekton", sans-serif; - font-weight: 700; + color: var(--foreground); + background: var(--background); + border: 2px solid var(--box-outline); + box-shadow: 6px 6px 0 var(--box-outline); + font-family: 'Lekton', sans-serif; + font-weight: 700; transition: all ease-in-out 0.1s; list-style: none; &:hover { box-shadow: 8px 8px 0 var(--box-outline); background: var(--accent); color: var(--background); - } a { box-sizing: border-box; @@ -45,5 +44,4 @@ const { href, title, body } = Astro.props; margin: 0; } } - </style> diff --git a/web/src/components/Hero.astro b/web/src/components/Hero.astro index a0e9b7c..0c32133 100644 --- a/web/src/components/Hero.astro +++ b/web/src/components/Hero.astro @@ -1,61 +1,60 @@ --- -import FontAwesome from "@components/form/FontAwesome.svelte" -import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte" +import FontAwesome from '@components/form/FontAwesome.svelte'; +import ThemeSwitcher from '@components/form/ThemeSwitcher.svelte'; --- <div class="theme-switcher"> -<ThemeSwitcher client:load /> + <ThemeSwitcher client:load /> </div> <div class="hero"> - <h1>Awesome Privacy</h1> - <p class="intro"> - Your guide to finding and comparing privacy-respecting alternatives to popular software and services. - </p> - <div class="github-link-wrap"> - <a href="https://github.com/lissy93/awesome-privacy"> - <FontAwesome iconName="github" /> - View on GitHub - </a> - </div> + <h1>Awesome Privacy</h1> + <p class="intro"> + Your guide to finding and comparing privacy-respecting alternatives to + popular software and services. + </p> + <div class="github-link-wrap"> + <a href="https://github.com/lissy93/awesome-privacy"> + <FontAwesome iconName="github" /> + View on GitHub + </a> + </div> </div> <nav class="top-right"> - <ul> - <li> - <a href="/">Home</a> - </li> - <li> - <a href="/search">Search</a> - </li> - <li> - <a href="/browse">Browse</a> - </li> - <li> - <a href="/about">About</a> - </li> - <li> - <a href="https://github.com/lissy93/awesome-privacy">Source</a> - </li> - <li> - <a href="https://as93.net">More Apps</a> - </li> - </ul> + <ul> + <li> + <a href="/">Home</a> + </li> + <li> + <a href="/search">Search</a> + </li> + <li> + <a href="/browse">Browse</a> + </li> + <li> + <a href="/about">About</a> + </li> + <li> + <a href="https://github.com/lissy93/awesome-privacy">Source</a> + </li> + <li> + <a href="https://as93.net">More Apps</a> + </li> + </ul> </nav> - <style lang="scss"> - .hero { - color: var(--accent-fg); - border-radius: var(--curve-sm); - padding: 2rem 4rem; - display: flex; - flex-direction: column; - gap: 2rem; - @media(max-width: 768px) { - padding: 2rem 1rem; - } + color: var(--accent-fg); + border-radius: var(--curve-sm); + padding: 2rem 4rem; + display: flex; + flex-direction: column; + gap: 2rem; + @media (max-width: 768px) { + padding: 2rem 1rem; + } } svg { position: absolute; @@ -65,98 +64,97 @@ import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte" width: 220px; height: auto; opacity: 0.6; - display: none; + display: none; } h1 { - margin: 0; + margin: 0; font-size: 5rem; font-weight: 700; line-height: 1; text-align: center; - font-family: 'Libre Franklin', sans-serif; - color: var(--accent-3); - -webkit-text-fill-color: var(--accent-3); - -webkit-text-stroke-width: 2px; - -webkit-text-stroke-color: var(--box-outline); - text-shadow: 3px 3px 0 var(--box-outline); - @media(max-width: 768px) { - font-size: 4rem; - } + font-family: 'Libre Franklin', sans-serif; + color: var(--accent-3); + -webkit-text-fill-color: var(--accent-3); + -webkit-text-stroke-width: 2px; + -webkit-text-stroke-color: var(--box-outline); + text-shadow: 3px 3px 0 var(--box-outline); + @media (max-width: 768px) { + font-size: 4rem; + } + } + .intro { + text-align: center; + font-size: 1.6rem; + padding: 0.5rem 1rem; + color: var(--accent-fg); + background: var(--accent-3); + border: 2px solid var(--box-outline); + border-radius: var(--curve-sm); + box-shadow: 6px 6px 0 var(--box-outline); + font-family: 'Lekton', sans-serif; + font-weight: 700; + max-width: 735px; + margin: 0 auto; + } + .github-link-wrap { + font-family: 'Lekton', sans-serif; + max-width: 735px; + text-align: center; + margin: 0 auto; + border: 1px solid var(--box-outline); + box-shadow: 3px 3px 0 var(--box-outline); + background: var(--accent); + border-radius: 18px; + padding: 0.5rem 1rem; + a { + text-decoration: none; + color: var(--accent-fg); + font-size: 1.2rem; + font-family: 'Lekton', sans-serif; + font-weight: bold; + display: flex; + align-items: center; + :global(svg) { + width: 1.5rem; + height: 1.5rem; + color: var(--accent-fg); + margin-right: 0.5rem; + } + } } - .intro { - text-align: center; - font-size: 1.6rem; - padding: 0.5rem 1rem; - color: var(--accent-fg); - background: var(--accent-3); - border: 2px solid var(--box-outline); - border-radius: var(--curve-sm); - box-shadow: 6px 6px 0 var(--box-outline); - font-family: "Lekton", sans-serif; - font-weight: 700; - max-width: 735px; - margin: 0 auto; - } - .github-link-wrap { - font-family: "Lekton", sans-serif; - max-width: 735px; - text-align: center; - margin: 0 auto; - border: 1px solid var(--box-outline); - box-shadow: 3px 3px 0 var(--box-outline); - background: var(--accent); - border-radius: 18px; - padding: 0.5rem 1rem; - a { - text-decoration: none; - color: var(--accent-fg);; - font-size: 1.2rem; - font-family: "Lekton", sans-serif; - font-weight: bold; - display: flex; - align-items: center; - :global(svg) { - width: 1.5rem; - height: 1.5rem; - color: var(--accent-fg); - margin-right: 0.5rem; - } - } - } - .theme-switcher { - position: absolute; - right: 1rem; - top: 1rem; - } + .theme-switcher { + position: absolute; + right: 1rem; + top: 1rem; + } - .top-right { - position: absolute; - top: 0; - right: 1rem; - opacity: 0.8; - display: none; - &:hover { - opacity: 1; - } - ul { - list-style: none; - display: flex; - padding: 0; - gap: 0.5rem; - li { - &:not(:last-child) { - border-right: 1px solid var(--accent); - padding-right: 0.5rem; - } - } - li a { - text-decoration: none; - &:hover { - text-decoration: underline; - } - } - } - } - + .top-right { + position: absolute; + top: 0; + right: 1rem; + opacity: 0.8; + display: none; + &:hover { + opacity: 1; + } + ul { + list-style: none; + display: flex; + padding: 0; + gap: 0.5rem; + li { + &:not(:last-child) { + border-right: 1px solid var(--accent); + padding-right: 0.5rem; + } + } + li a { + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + } + } </style> diff --git a/web/src/components/form/Button.astro b/web/src/components/form/Button.astro index a2d9a26..d449306 100644 --- a/web/src/components/form/Button.astro +++ b/web/src/components/form/Button.astro @@ -1,37 +1,30 @@ --- - -const { - text, - url, - className, - title, -} = Astro.props; - +const { text, url, className, title } = Astro.props; --- <div class={`button ${className || ''}`} title={title}> - <a href={url}>{text}<slot /></a> + <a href={url}>{text}<slot /></a> </div> <style lang="scss"> - .button { - font-family: "Lekton", sans-serif; - text-align: center; - border: 1px solid var(--box-outline); - box-shadow: 3px 3px 0 var(--box-outline); - background: var(--accent); - border-radius: 18px; - padding: 0.5rem 1rem; - transition: all 0.2s ease-in-out; - &:hover { - box-shadow: 4px 4px 0 var(--box-outline); - } - a { - text-decoration: none; - color: var(--accent-fg);; - font-size: 1.2rem; - font-family: "Lekton", sans-serif; - font-weight: bold; - } - } + .button { + font-family: 'Lekton', sans-serif; + text-align: center; + border: 1px solid var(--box-outline); + box-shadow: 3px 3px 0 var(--box-outline); + background: var(--accent); + border-radius: 18px; + padding: 0.5rem 1rem; + transition: all 0.2s ease-in-out; + &:hover { + box-shadow: 4px 4px 0 var(--box-outline); + } + a { + text-decoration: none; + color: var(--accent-fg); + font-size: 1.2rem; + font-family: 'Lekton', sans-serif; + font-weight: bold; + } + } </style> diff --git a/web/src/components/form/EditableTitle.svelte b/web/src/components/form/EditableTitle.svelte index ee9b999..816e9b2 100644 --- a/web/src/components/form/EditableTitle.svelte +++ b/web/src/components/form/EditableTitle.svelte @@ -41,25 +41,27 @@ } </script> -<svelte:window on:click={handleClickOutside}/> +<svelte:window on:click={handleClickOutside} /> <!-- svelte-ignore a11y-no-noninteractive-tabindex --> <div> -<h2 - contenteditable={true} - class:editable={editing} - on:click={() => editing = true} - on:keydown={handleKeydown} - on:blur={() => saveTitle(title)} - tabindex="0" ->{title}</h2> + <h2 + contenteditable={true} + class:editable={editing} + on:click={() => (editing = true)} + on:keydown={handleKeydown} + on:blur={() => saveTitle(title)} + tabindex="0" + > + {title} + </h2> -<small>Click the title, to edit your inventory name</small> + <small>Click the title, to edit your inventory name</small> </div> <style> h2 { - font-family: "Lekton", sans-serif; + font-family: 'Lekton', sans-serif; font-weight: bold; font-size: 3rem; margin: 0; diff --git a/web/src/components/form/FontAwesome.svelte b/web/src/components/form/FontAwesome.svelte index c18ff55..488e544 100644 --- a/web/src/components/form/FontAwesome.svelte +++ b/web/src/components/form/FontAwesome.svelte @@ -4,7 +4,6 @@ import * as brands from '@fortawesome/free-brands-svg-icons'; import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; - export const iconMap: Record<string, IconDefinition> = { // Branding logo: solidIcons.faEyeSlash, @@ -83,13 +82,8 @@ }; export let iconName: string; - </script> {#if iconMap[iconName]} - <FontAwesomeIcon - class="fa-icon" - icon={iconMap[iconName]} /> + <FontAwesomeIcon class="fa-icon" icon={iconMap[iconName]} /> {/if} - - diff --git a/web/src/components/form/Icon.astro b/web/src/components/form/Icon.astro index bbc1623..bfcdc00 100644 --- a/web/src/components/form/Icon.astro +++ b/web/src/components/form/Icon.astro @@ -1,73 +1,84 @@ --- - interface IconProps { - icon: string; - color?: string; - class?: string; - width?: number; - height?: number; + icon: string; + color?: string; + class?: string; + width?: number; + height?: number; } const getSvgPath = (icon: string) => { - switch (icon) { - case 'star': - return { - vb: "0 0 24 24", - path: "M10 15l-5.5 3 1-5.5L0 7.5l5.6-0.5L10 2l2 5 5.5 0.5-4 4 1 5.5z", - }; - case 'mastodon': - return { - vb: "0 0 512 512", - path: "M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z", - }; - case 'twitter': - return { - vb: "0 0 512 512", - path: "M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z", - }; - case 'hub': - return { - vb: "0 0 512 512", - path: "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z", - }; - case 'dev': - return { - vb: "0 0 512 512", - path: "M120.1 208.3c-3.9-2.9-7.8-4.4-11.7-4.4H91v104.5h17.5c3.9 0 7.8-1.5 11.7-4.4 3.9-2.9 5.8-7.3 5.8-13.1v-69.7c0-5.8-2-10.2-5.8-13.1zM404.1 32H43.9C19.7 32 .1 51.6 0 75.8v360.4C.1 460.4 19.7 480 43.9 480h360.2c24.2 0 43.8-19.6 43.9-43.8V75.8c-.1-24.2-19.7-43.8-43.9-43.8zM154.2 291.2c0 18.8-11.6 47.3-48.4 47.3h-46.4V173h47.4c35.4 0 47.4 28.5 47.4 47.3l0 70.9zm100.7-88.7H201.6v38.4h32.6v29.6H201.6v38.4h53.3v29.6h-62.2c-11.2 .3-20.4-8.5-20.7-19.7V193.7c-.3-11.2 8.6-20.4 19.7-20.7h63.2l0 29.5zm103.6 115.3c-13.2 30.8-36.9 24.6-47.4 0l-38.5-144.8h32.6l29.7 113.7 29.6-113.7h32.6l-38.5 144.8z", - }; - case 'linkedin': - return { - vb: "0 0 512 512", - path: "M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z", - }; - case 'essentials': - return { - vb: "0 0 512 512", - path: "M208 176c0-70.7 57.3-128 128-128s128 57.3 128 128s-57.3 128-128 128c-10.4 0-20.5-1.2-30.1-3.6c-8.1-2-16.7 .4-22.6 6.4L254.1 336H200c-13.3 0-24 10.7-24 24v40H136c-13.3 0-24 10.7-24 24v40H48V385.9L205.2 228.7c5.9-5.9 8.3-14.5 6.4-22.6c-2.3-9.6-3.6-19.7-3.6-30.1zM336 0C238.8 0 160 78.8 160 176c0 9.5 .7 18.8 2.2 27.9L7 359c-4.5 4.5-7 10.6-7 17V488c0 13.3 10.7 24 24 24H136c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l27.2-27.2c9.1 1.4 18.4 2.2 27.9 2.2c97.2 0 176-78.8 176-176S433.2 0 336 0zm32 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64z", - }; - case 'communication': - return { - vb: "0 0 640 512", - path: "M48 72c0-13.3 10.7-24 24-24H344c13.3 0 24 10.7 24 24V248c0 13.3-10.7 24-24 24H216c-4.7 0-9.4 1.4-13.3 4L144 315.2V296c0-13.3-10.7-24-24-24H72c-13.3 0-24-10.7-24-24V72zM72 0C32.2 0 0 32.2 0 72V248c0 39.8 32.2 72 72 72H96v40c0 8.9 4.9 17 12.7 21.2s17.3 3.7 24.6-1.2l90-60H344c39.8 0 72-32.2 72-72V72c0-39.8-32.2-72-72-72H72zM256 376c0 39.8 32.2 72 72 72h88.7l90 60c7.4 4.9 16.8 5.4 24.6 1.2S544 496.9 544 488V448h24c39.8 0 72-32.2 72-72V200c0-39.8-32.2-72-72-72H448v48H568c13.3 0 24 10.7 24 24V376c0 13.3-10.7 24-24 24H520c-13.3 0-24 10.7-24 24v19.2L437.3 404c-3.9-2.6-8.6-4-13.3-4H328c-13.3 0-24-10.7-24-24V352H256v24z", - }; - case 'security-tools': - return { - vp: "0 0 512 512", - path: "M232 60.8V447.4c-66.9-37.8-108.8-94.3-134.1-152.6C71 232.9 63.1 169.5 64.1 126L232 60.8zm48 386.5V60.8L448 126c1 43.5-6.9 106.9-33.8 168.8C388.8 353.1 346.9 409.5 280 447.3zM495.5 113l-1.2-20.5L475.1 85 267.6 4.5 256 0 244.4 4.5 36.9 85 17.8 92.5 16.6 113c-2.9 49.9 4.9 126.3 37.3 200.9c32.7 75.2 91 150 189.4 192.6L256 512l12.7-5.5c98.4-42.6 156.7-117.3 189.4-192.6c32.4-74.7 40.2-151 37.3-200.9z" - }; - // Add more icons as needed... - default: - return { vb: "", path: "" }; // Default path or a placeholder icon - } + switch (icon) { + case 'star': + return { + vb: '0 0 24 24', + path: 'M10 15l-5.5 3 1-5.5L0 7.5l5.6-0.5L10 2l2 5 5.5 0.5-4 4 1 5.5z', + }; + case 'mastodon': + return { + vb: '0 0 512 512', + path: 'M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z', + }; + case 'twitter': + return { + vb: '0 0 512 512', + path: 'M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z', + }; + case 'hub': + return { + vb: '0 0 512 512', + path: 'M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z', + }; + case 'dev': + return { + vb: '0 0 512 512', + path: 'M120.1 208.3c-3.9-2.9-7.8-4.4-11.7-4.4H91v104.5h17.5c3.9 0 7.8-1.5 11.7-4.4 3.9-2.9 5.8-7.3 5.8-13.1v-69.7c0-5.8-2-10.2-5.8-13.1zM404.1 32H43.9C19.7 32 .1 51.6 0 75.8v360.4C.1 460.4 19.7 480 43.9 480h360.2c24.2 0 43.8-19.6 43.9-43.8V75.8c-.1-24.2-19.7-43.8-43.9-43.8zM154.2 291.2c0 18.8-11.6 47.3-48.4 47.3h-46.4V173h47.4c35.4 0 47.4 28.5 47.4 47.3l0 70.9zm100.7-88.7H201.6v38.4h32.6v29.6H201.6v38.4h53.3v29.6h-62.2c-11.2 .3-20.4-8.5-20.7-19.7V193.7c-.3-11.2 8.6-20.4 19.7-20.7h63.2l0 29.5zm103.6 115.3c-13.2 30.8-36.9 24.6-47.4 0l-38.5-144.8h32.6l29.7 113.7 29.6-113.7h32.6l-38.5 144.8z', + }; + case 'linkedin': + return { + vb: '0 0 512 512', + path: 'M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z', + }; + case 'essentials': + return { + vb: '0 0 512 512', + path: 'M208 176c0-70.7 57.3-128 128-128s128 57.3 128 128s-57.3 128-128 128c-10.4 0-20.5-1.2-30.1-3.6c-8.1-2-16.7 .4-22.6 6.4L254.1 336H200c-13.3 0-24 10.7-24 24v40H136c-13.3 0-24 10.7-24 24v40H48V385.9L205.2 228.7c5.9-5.9 8.3-14.5 6.4-22.6c-2.3-9.6-3.6-19.7-3.6-30.1zM336 0C238.8 0 160 78.8 160 176c0 9.5 .7 18.8 2.2 27.9L7 359c-4.5 4.5-7 10.6-7 17V488c0 13.3 10.7 24 24 24H136c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l27.2-27.2c9.1 1.4 18.4 2.2 27.9 2.2c97.2 0 176-78.8 176-176S433.2 0 336 0zm32 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64z', + }; + case 'communication': + return { + vb: '0 0 640 512', + path: 'M48 72c0-13.3 10.7-24 24-24H344c13.3 0 24 10.7 24 24V248c0 13.3-10.7 24-24 24H216c-4.7 0-9.4 1.4-13.3 4L144 315.2V296c0-13.3-10.7-24-24-24H72c-13.3 0-24-10.7-24-24V72zM72 0C32.2 0 0 32.2 0 72V248c0 39.8 32.2 72 72 72H96v40c0 8.9 4.9 17 12.7 21.2s17.3 3.7 24.6-1.2l90-60H344c39.8 0 72-32.2 72-72V72c0-39.8-32.2-72-72-72H72zM256 376c0 39.8 32.2 72 72 72h88.7l90 60c7.4 4.9 16.8 5.4 24.6 1.2S544 496.9 544 488V448h24c39.8 0 72-32.2 72-72V200c0-39.8-32.2-72-72-72H448v48H568c13.3 0 24 10.7 24 24V376c0 13.3-10.7 24-24 24H520c-13.3 0-24 10.7-24 24v19.2L437.3 404c-3.9-2.6-8.6-4-13.3-4H328c-13.3 0-24-10.7-24-24V352H256v24z', + }; + case 'security-tools': + return { + vp: '0 0 512 512', + path: 'M232 60.8V447.4c-66.9-37.8-108.8-94.3-134.1-152.6C71 232.9 63.1 169.5 64.1 126L232 60.8zm48 386.5V60.8L448 126c1 43.5-6.9 106.9-33.8 168.8C388.8 353.1 346.9 409.5 280 447.3zM495.5 113l-1.2-20.5L475.1 85 267.6 4.5 256 0 244.4 4.5 36.9 85 17.8 92.5 16.6 113c-2.9 49.9 4.9 126.3 37.3 200.9c32.7 75.2 91 150 189.4 192.6L256 512l12.7-5.5c98.4-42.6 156.7-117.3 189.4-192.6c32.4-74.7 40.2-151 37.3-200.9z', + }; + // Add more icons as needed... + default: + return { vb: '', path: '' }; // Default path or a placeholder icon + } }; - // Props are defined in the component's signature -const { icon, color = 'currentcolor', class: className = '', width = 80, height = 50 } = Astro.props as IconProps; +const { + icon, + color = 'currentcolor', + class: className = '', + width = 80, + height = 50, +} = Astro.props as IconProps; const svgStyle = { fill: color }; const { vb, path } = getSvgPath(icon); --- -<svg class={className} style={svgStyle} xmlns="http://www.w3.org/2000/svg" viewBox={vb} width={width} height={height ?? width}> - <path d={path} /> +<svg + class={className} + style={svgStyle} + xmlns="http://www.w3.org/2000/svg" + viewBox={vb} + width={width} + height={height ?? width} +> + <path d={path}></path> </svg> diff --git a/web/src/components/form/Social.astro b/web/src/components/form/Social.astro index 52d87b4..c40d5f1 100644 --- a/web/src/components/form/Social.astro +++ b/web/src/components/form/Social.astro @@ -1,12 +1,15 @@ --- - import Icon from '@components/form/FontAwesome.svelte'; -import { site, title as defaultTitle, description as defaultDescription } from '@utils/config'; +import { + site, + title as defaultTitle, + description as defaultDescription, +} from '@utils/config'; interface Props { - url?: string; - title?: string; - description?: string; + url?: string; + title?: string; + description?: string; } const url = Astro.props.url || site; @@ -18,109 +21,114 @@ const encodedTitle = encodeURIComponent(title); const encodedDescription = encodeURIComponent(description); const socialMedias = { - mastodon: { - url: `https://mastodon.social/share?text=${encodeURIComponent(`${title} ${description}`)}&url=${encodedUrl}`, - title: 'Mastodon', - icon: 'mastodon', - color: '#6364FF', - }, - twitter: { - url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} ${description}`)}&url=${url}`, - title: 'Twitter', - icon: 'twitter', - color: '#444343' - }, - reddit: { - url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`, - title: 'Reddit', - icon: 'reddit', - color: '#FF4500', - }, - linkedIn: { - url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedDescription}`, - title: 'LinkedIn', - icon: 'linkedin', - color: '#0A66C2' - }, - pinterest: { - url: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`, - title: 'Pinterest', - icon: 'pinterest', - color: '#BD081C', - }, - telegram: { - url: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(`${title} ${description}`)}`, - title: 'Telegram', - icon: 'telegram', - color: '#26A5E4', - }, - whatsapp: { - url: `https://wa.me/?text=${encodedTitle} ${encodedUrl}`, - title: 'WhatsApp', - icon: 'whatsapp', - color: '#25D366', - }, - signal: { - url: `https://signal.me/#p/+${encodedUrl}`, - title: 'Signal', - icon: 'signal', - color: '#3A76F0', - }, - pocket: { - url: `https://getpocket.com/save?url=${encodedUrl}&title=${encodedTitle}`, - title: 'Pocket', - icon: 'pocket', - color: '#EF3F56', - }, + mastodon: { + url: `https://mastodon.social/share?text=${encodeURIComponent(`${title} ${description}`)}&url=${encodedUrl}`, + title: 'Mastodon', + icon: 'mastodon', + color: '#6364FF', + }, + twitter: { + url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} ${description}`)}&url=${url}`, + title: 'Twitter', + icon: 'twitter', + color: '#444343', + }, + reddit: { + url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`, + title: 'Reddit', + icon: 'reddit', + color: '#FF4500', + }, + linkedIn: { + url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedDescription}`, + title: 'LinkedIn', + icon: 'linkedin', + color: '#0A66C2', + }, + pinterest: { + url: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`, + title: 'Pinterest', + icon: 'pinterest', + color: '#BD081C', + }, + telegram: { + url: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(`${title} ${description}`)}`, + title: 'Telegram', + icon: 'telegram', + color: '#26A5E4', + }, + whatsapp: { + url: `https://wa.me/?text=${encodedTitle} ${encodedUrl}`, + title: 'WhatsApp', + icon: 'whatsapp', + color: '#25D366', + }, + signal: { + url: `https://signal.me/#p/+${encodedUrl}`, + title: 'Signal', + icon: 'signal', + color: '#3A76F0', + }, + pocket: { + url: `https://getpocket.com/save?url=${encodedUrl}&title=${encodedTitle}`, + title: 'Pocket', + icon: 'pocket', + color: '#EF3F56', + }, }; - --- <ul class="social-share"> - {Object.entries(socialMedias).map(([platform, shareUrl]) => ( - <li style={`--color: ${shareUrl.color}`}> - <a title={`Share on ${platform}`} href={shareUrl.url} target="_blank" rel="noopener noreferrer"> - <Icon iconName={shareUrl.icon} /> - </a> - </li> - ))} + { + Object.entries(socialMedias).map(([platform, shareUrl]) => ( + <li style={`--color: ${shareUrl.color}`}> + <a + title={`Share on ${platform}`} + href={shareUrl.url} + target="_blank" + rel="noopener noreferrer" + > + <Icon iconName={shareUrl.icon} /> + </a> + </li> + )) + } </ul> <style lang="scss"> -.social-share { - list-style: none; - padding: 0; - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - li { - padding: 0; - margin: 0; - opacity: 0.8; - border: 1px solid var(--box-outline); - box-shadow: 2px 2px 0 var(--box-outline); - border-radius: var(--curve-sm); - transition: all 0.2s ease-in-out; - background: var(--background-form); - a { - display: flex; - color: var(--foreground); - transition: all 0.2s ease-in-out; - padding: 4px; - :global(svg) { - width: 2rem; - height: 2rem; - } - } - &:hover { - box-shadow: 3px 3px 0 var(--box-outline); - border-radius: var(--curve-md); - opacity: 1; - a { - color: var(--color); - } - } - } -} + .social-share { + list-style: none; + padding: 0; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + li { + padding: 0; + margin: 0; + opacity: 0.8; + border: 1px solid var(--box-outline); + box-shadow: 2px 2px 0 var(--box-outline); + border-radius: var(--curve-sm); + transition: all 0.2s ease-in-out; + background: var(--background-form); + a { + display: flex; + color: var(--foreground); + transition: all 0.2s ease-in-out; + padding: 4px; + :global(svg) { + width: 2rem; + height: 2rem; + } + } + &:hover { + box-shadow: 3px 3px 0 var(--box-outline); + border-radius: var(--curve-md); + opacity: 1; + a { + color: var(--color); + } + } + } + } </style> - diff --git a/web/src/components/form/ThemeSwitcher.svelte b/web/src/components/form/ThemeSwitcher.svelte index 3f5542a..3e7f7c8 100644 --- a/web/src/components/form/ThemeSwitcher.svelte +++ b/web/src/components/form/ThemeSwitcher.svelte @@ -28,7 +28,6 @@ } </script> - <!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-no-static-element-interactions --> <div class="theme-switcher" on:click={toggleTheme}> @@ -38,7 +37,6 @@ </div> </div> - <style lang="scss"> .theme-switcher { cursor: pointer; @@ -52,7 +50,7 @@ transition: background-color 0.3s ease; border: 2px solid var(--box-outline); box-shadow: 3px 3px 0 var(--box-outline); - + &:hover { background-color: rgba(255, 255, 255, 0.3); } @@ -96,5 +94,4 @@ display: flex; font-size: 1.5rem; } - </style> diff --git a/web/src/components/scafold/Footer.astro b/web/src/components/scafold/Footer.astro index 7e02fed..ed7a7ba 100644 --- a/web/src/components/scafold/Footer.astro +++ b/web/src/components/scafold/Footer.astro @@ -1,23 +1,21 @@ - <footer> - <a href="/about">Awesome Privacy</a> is licensed - under <a href="https://github.com/Lissy93/awesome-privacy/blob/main/LICENSE">MIT</a> - © <a href="https://aliciasykes.com">Alicia Sykes</a> 2024 | - Source code available on <a href="https://github.com/Lissy93/awesome-privacy">GitHub</a> + <a href="/about">Awesome Privacy</a> is licensed under <a + href="https://github.com/Lissy93/awesome-privacy/blob/main/LICENSE">MIT</a + > + © <a href="https://aliciasykes.com">Alicia Sykes</a> 2024 | Source code available + on <a href="https://github.com/Lissy93/awesome-privacy">GitHub</a> </footer> <style lang="scss"> - footer { - font-family: "Lekton", sans-serif; - font-weight: bold; - text-align: center; - padding: 0.5rem 0; - margin: 0 auto; - a { - font-family: "Lekton", sans-serif; - color: var(--accent); - } - - - } + footer { + font-family: 'Lekton', sans-serif; + font-weight: bold; + text-align: center; + padding: 0.5rem 0; + margin: 0 auto; + a { + font-family: 'Lekton', sans-serif; + color: var(--accent); + } + } </style> diff --git a/web/src/components/scafold/MainCard.astro b/web/src/components/scafold/MainCard.astro index 18e2c8e..ed2e997 100644 --- a/web/src/components/scafold/MainCard.astro +++ b/web/src/components/scafold/MainCard.astro @@ -1,7 +1,5 @@ - - <main> - <slot /> + <slot /> </main> <style lang="scss"> @@ -11,8 +9,8 @@ width: 1200px; max-width: calc(100% - 5rem); border: 2px solid var(--box-outline); - box-shadow: 6px 6px 0 var(--box-outline); + box-shadow: 6px 6px 0 var(--box-outline); background: var(--accent-fg); position: relative; - } + } </style> diff --git a/web/src/components/scafold/NavBar.astro b/web/src/components/scafold/NavBar.astro index 64ed300..fab5392 100644 --- a/web/src/components/scafold/NavBar.astro +++ b/web/src/components/scafold/NavBar.astro @@ -1,106 +1,111 @@ --- -import FontAwesome from "@components/form/FontAwesome.svelte" -import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte" - +import FontAwesome from '@components/form/FontAwesome.svelte'; +import ThemeSwitcher from '@components/form/ThemeSwitcher.svelte'; --- <div class="nav"> - <a href="/" class="homepage"> - <FontAwesome iconName="logo" /> - <h1>Awesome Privacy</h1> - </a> - <nav> - <a href="/browse">Browse</a> - <a href="/search">Search</a> - <a href="/about">About</a> - <a href="https://github.com/lissy93/awesome-privacy">GitHub</a> - <div class="theme-switcher"> - <ThemeSwitcher client:load /> - </div> - </nav> + <a href="/" class="homepage"> + <FontAwesome iconName="logo" /> + <h1>Awesome Privacy</h1> + </a> + <nav> + <a href="/browse">Browse</a> + <a href="/search">Search</a> + <a href="/about">About</a> + <a href="https://github.com/lissy93/awesome-privacy">GitHub</a> + <div class="theme-switcher"> + <ThemeSwitcher client:load /> + </div> + </nav> </div> <style lang="scss"> - .nav { - background: var(--accent-fg); - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - border-bottom: 2px solid var(--accent-3); - - .homepage { - text-decoration: none; - display: flex; - align-items: center; - padding: 0 0.5rem; - h1 { - margin: 0; - font-size: 2.4rem; - padding: 0 1rem; - color: var(--foreground); - font-family: "Lekton", sans-serif; - } - :global(svg) { - width: 2.5rem; - height: 2.5rem; - color: var(--accent-3); - transition: all 0.2s ease-in-out; - } - &:hover { - :global(svg) { - color: var(--accent); - transform: scale(1.05); - } - } - } + .nav { + background: var(--accent-fg); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + border-bottom: 2px solid var(--accent-3); - nav { - display: flex; - align-items: center; - height: 3rem; - a { - padding: 1rem; - font-size: 1.2rem; - font-family: "Lekton", sans-serif; - font-weight: bold; - color: var(--foreground); - transition: background 0.3s, transform 0.3s, box-shadow 0.3s; - transition-timing-function: ease-in-out; - &:hover { - background: var(--accent-3); - color: var(--accent-fg); - border-bottom: 2px solid var(--accent-3); - &:nth-child(4n+1) { - background-color: var(--accent); - } - &:nth-child(4n+2) { - background-color: var(--accent-2); - color: var(--foreground); - } - &:nth-child(4n+3) { - background-color: var(--accent-3); - } - &:nth-child(4n+4) { - background-color: var(--accent-4); - color: var(--foreground); - } - } - } - .theme-switcher { - transform: scale(0.7); - margin: 0.2rem auto; - @media(max-width: 768px) { - display: none; - } - } - } - .homepage, nav { - @media(max-width: 768px) { - margin: 0 auto; - a { border: none; } + .homepage { + text-decoration: none; + display: flex; + align-items: center; + padding: 0 0.5rem; + h1 { + margin: 0; + font-size: 2.4rem; + padding: 0 1rem; + color: var(--foreground); + font-family: 'Lekton', sans-serif; } - } - } + :global(svg) { + width: 2.5rem; + height: 2.5rem; + color: var(--accent-3); + transition: all 0.2s ease-in-out; + } + &:hover { + :global(svg) { + color: var(--accent); + transform: scale(1.05); + } + } + } + + nav { + display: flex; + align-items: center; + height: 3rem; + a { + padding: 1rem; + font-size: 1.2rem; + font-family: 'Lekton', sans-serif; + font-weight: bold; + color: var(--foreground); + transition: + background 0.3s, + transform 0.3s, + box-shadow 0.3s; + transition-timing-function: ease-in-out; + &:hover { + background: var(--accent-3); + color: var(--accent-fg); + border-bottom: 2px solid var(--accent-3); + &:nth-child(4n + 1) { + background-color: var(--accent); + } + &:nth-child(4n + 2) { + background-color: var(--accent-2); + color: var(--foreground); + } + &:nth-child(4n + 3) { + background-color: var(--accent-3); + } + &:nth-child(4n + 4) { + background-color: var(--accent-4); + color: var(--foreground); + } + } + } + .theme-switcher { + transform: scale(0.7); + margin: 0.2rem auto; + @media (max-width: 768px) { + display: none; + } + } + } + .homepage, + nav { + @media (max-width: 768px) { + margin: 0 auto; + a { + border: none; + } + } + } + } </style> diff --git a/web/src/components/things/AddNewService.svelte b/web/src/components/things/AddNewService.svelte index 48c8cc8..a77cdd0 100644 --- a/web/src/components/things/AddNewService.svelte +++ b/web/src/components/things/AddNewService.svelte @@ -23,11 +23,11 @@ let codeBlock: any; let interactiveActivated = false; - $: yamlText, updateHighlighting(); + $: (yamlText, updateHighlighting()); function updateHighlighting() { if (codeBlock) { - codeBlock.textContent = yamlText + codeBlock.textContent = yamlText; codeBlock.dataset.highlighted && delete codeBlock.dataset.highlighted; if (window && (window as any).hljs) { (window as any).hljs.highlightElement(codeBlock); @@ -38,15 +38,17 @@ const filterEmptyValues = (obj: Record<string, any>) => { const filteredObj: Record<string, any> = {}; - Object.keys(obj).forEach(key => { + Object.keys(obj).forEach((key) => { if (obj[key] || ['name', 'url', 'icon', 'description'].includes(key)) { filteredObj[key] = obj[key]; } }); return filteredObj; - } - - $: yamlText = yaml.dump([{ + }; + + $: yamlText = yaml.dump( + [ + { name: $serviceName, url: $serviceUrl, icon: $serviceIcon, @@ -60,9 +62,12 @@ openSource: $serviceOpenSource, securityAudited: $serviceSecurityAudited, acceptsCrypto: $serviceCrypto, - }].map(obj => filterEmptyValues(obj))); + }, + ].map((obj) => filterEmptyValues(obj)), + ); - $: issueUrl = makeAdditionRequest({ + $: issueUrl = makeAdditionRequest( + { listingCategory: $listingCategory, serviceName: $serviceName, serviceUrl: $serviceUrl, @@ -78,7 +83,9 @@ serviceSecurityAudited: $serviceSecurityAudited, serviceCrypto: $serviceCrypto, additionalInfo: $additionalInfo, - }, yamlText); + }, + yamlText, + ); // Form submission handler function handleSubmit() { @@ -87,29 +94,39 @@ </script> <svelte:head> -<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/an-old-hope.min.css"> -<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> -<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"></script> + <link + rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/an-old-hope.min.css" + /> + <script + src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" + ></script> + <script + src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js" + ></script> </svelte:head> <p> - Before completing this form, you must ensure that the service you are adding aligns - with the <a href="/about#creteria">Requirements</a> for Awesome Privacy. + Before completing this form, you must ensure that the service you are adding + aligns with the <a href="/about#creteria">Requirements</a> for Awesome + Privacy. <br /> You'll need a GitHub account in order to submit this form. </p> <form on:submit|preventDefault={handleSubmit}> - <h3>Basics</h3> - <p class="sub-title-description"> - All fields here are required. - </p> + <p class="sub-title-description">All fields here are required.</p> <!-- Category Dropdown --> <div class="form-row"> <label for="listing-category">Category</label> - <select bind:value={$listingCategory} id="listing-category" required autocomplete="off"> + <select + bind:value={$listingCategory} + id="listing-category" + required + autocomplete="off" + > <option value="">--Please choose an option--</option> <option value="Essentials">Essentials</option> <option value="Communication">Communication</option> @@ -126,45 +143,78 @@ <option value="Creativity">Creativity</option> </select> <p> - Choose the top-level category, which should align with - the <a href="/browse">one of these</a>. + Choose the top-level category, which should align with the <a + href="/browse">one of these</a + >. </p> </div> <!-- Listing Name --> <div class="form-row"> <label for="service-name">Listing Name</label> - <input type="text" bind:value={$serviceName} id="service-name" required autocomplete="off"> + <input + type="text" + bind:value={$serviceName} + id="service-name" + required + autocomplete="off" + /> <p>Enter the name of the app, software or service</p> </div> <!-- Listing URL --> <div class="form-row"> <label for="service-url">Listing URL</label> - <input type="url" bind:value={$serviceUrl} id="service-url" required autocomplete="off"> - <p>Enter the fully-qualified domain name of the homepage for this listing</p> + <input + type="url" + bind:value={$serviceUrl} + id="service-url" + required + autocomplete="off" + /> + <p> + Enter the fully-qualified domain name of the homepage for this listing + </p> </div> - + <!-- Listing Icon --> <div class="form-row"> <label for="service-icon">Listing Icon</label> - <input type="url" bind:value={$serviceIcon} id="service-icon" required autocomplete="off"> - <p>Paste a URL to a square logo for the service. Dimensions must be no less than 64x64, and no more than 512x512 pixels</p> + <input + type="url" + bind:value={$serviceIcon} + id="service-icon" + required + autocomplete="off" + /> + <p> + Paste a URL to a square logo for the service. Dimensions must be no less + than 64x64, and no more than 512x512 pixels + </p> </div> <!-- Listing Description --> <div class="form-row"> <label for="service-description">Listing Description</label> - <textarea bind:value={$serviceDescription} id="service-description" required autocomplete="off"></textarea> - <p>Please provide a description for this listing. Keep it factual and objective. Markdown is supported.</p> + <textarea + bind:value={$serviceDescription} + id="service-description" + required + autocomplete="off" + ></textarea> + <p> + Please provide a description for this listing. Keep it factual and + objective. Markdown is supported. + </p> </div> <!-- Section 2 --> <h3>Third-Party Referencing</h3> <p class="sub-title-description"> - In order to create a comprehensive listing, we combine the data inputted above with other sources, - to give additional context and help users make informed decisions. - Metrics from these services are fetched automatically at build-time from our API. + In order to create a comprehensive listing, we combine the data inputted + above with other sources, to give additional context and help users make + informed decisions. Metrics from these services are fetched automatically at + build-time from our API. <br /> All fields are optional, but the more information you provide, the better! </p> @@ -172,7 +222,13 @@ <!-- GitHub Repository --> <div class="form-row"> <label for="service-github">GitHub Repository</label> - <input type="text" bind:value={$serviceGithub} id="service-github" required autocomplete="off"> + <input + type="text" + bind:value={$serviceGithub} + id="service-github" + required + autocomplete="off" + /> <p> Share a link to where the project's source is located.<br /> Use the format [user]/[repo] e.g, lissy93/dashy @@ -182,18 +238,29 @@ <!-- ToS;DR ID --> <div class="form-row"> <label for="service-tosdr-id">ToS;DR ID</label> - <input type="number" bind:value={$serviceTosdrId} id="service-tosdr-id" autocomplete="off"> + <input + type="number" + bind:value={$serviceTosdrId} + id="service-tosdr-id" + autocomplete="off" + /> <p> - Has the Privacy policy been documented by <a href="https://tosdr.org/">tosdr.org</a>? - If so, please include the report reference below (this is a 3 or 4-digit numerical ID). - Skip section if not applicable. + Has the Privacy policy been documented by <a href="https://tosdr.org/" + >tosdr.org</a + >? If so, please include the report reference below (this is a 3 or + 4-digit numerical ID). Skip section if not applicable. </p> </div> <!-- Apple App Store URL --> <div class="form-row"> <label for="service-tosdr-id">iOS App</label> - <input type="url" bind:value={$serviceIosApp} id="service-ios-app" autocomplete="off"> + <input + type="url" + bind:value={$serviceIosApp} + id="service-ios-app" + autocomplete="off" + /> <p> Paste the link to the mobile app on the Apple App Store.<br /> E.g. https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744 @@ -203,7 +270,12 @@ <!-- Google Play App Store URL --> <div class="form-row"> <label for="service-tosdr-id">Android App</label> - <input type="url" bind:value={$serviceAndroidApp} id="service-android-app" autocomplete="off"> + <input + type="url" + bind:value={$serviceAndroidApp} + id="service-android-app" + autocomplete="off" + /> <p> Paste the link to the mobile app on the Google Play Store.<br /> E.g. https://play.google.com/store/apps/details?id=com.x8bit.bitwarden @@ -213,17 +285,28 @@ <!-- Discord Server Invite Code --> <div class="form-row"> <label for="service-tosdr-id">Discord Invite</label> - <input type="text" bind:value={$serviceDiscordInvite} id="service-discord-invite" autocomplete="off"> + <input + type="text" + bind:value={$serviceDiscordInvite} + id="service-discord-invite" + autocomplete="off" + /> <p> Paste the invite code to the Discord server for this service.<br /> - E.g. If the invite URL is https://discord.com/invite/4JMAauFZBq the code is 4JMAauFZBq + E.g. If the invite URL is https://discord.com/invite/4JMAauFZBq the code is + 4JMAauFZBq </p> </div> <!-- Reddit sub name --> <div class="form-row"> <label for="service-tosdr-id">Subreddit</label> - <input type="text" bind:value={$serviceSubreddit} id="service-subreddit" autocomplete="off"> + <input + type="text" + bind:value={$serviceSubreddit} + id="service-subreddit" + autocomplete="off" + /> <p> If the service has a subreddit, please provide the name here.<br /> Don't include `r/` in the name, nor the full URL - just the sub name. @@ -233,70 +316,94 @@ <!-- Section 3 - Checklist and details --> <h3>Privacy Checklist</h3> <p class="sub-title-description"> - Finally, check the boxes that apply to the service you are submitting, - and then provide any additional information to back this up in the text area below. + Finally, check the boxes that apply to the service you are submitting, and + then provide any additional information to back this up in the text area + below. </p> - <!-- Open Source Checkbox --> + <!-- Open Source Checkbox --> <div class="form-row"> <label for="service-open-source">Is Open Source?</label> - <input type="checkbox" bind:checked={$serviceOpenSource} id="service-open-source"> - <p>Is this service fully open source? Aka, can it be compiled from source by the user, or self-hosted?</p> + <input + type="checkbox" + bind:checked={$serviceOpenSource} + id="service-open-source" + /> + <p> + Is this service fully open source? Aka, can it be compiled from source by + the user, or self-hosted? + </p> </div> <!-- Security Audited Checkbox --> <div class="form-row"> <label for="service-security-audited">Security Audited?</label> - <input type="checkbox" bind:checked={$serviceSecurityAudited} id="service-security-audited"> - <p>Has this service been independently security audited by an accredited auditor?</p> + <input + type="checkbox" + bind:checked={$serviceSecurityAudited} + id="service-security-audited" + /> + <p> + Has this service been independently security audited by an accredited + auditor? + </p> </div> <!-- Accepts Crypto Checkbox --> <div class="form-row"> <label for="service-crypto">Accepts Anon Payment?</label> - <input type="checkbox" bind:checked={$serviceCrypto} id="service-crypto"> - <p>If this is a hosted and paid for service, does it accept anonymous payment methods, including crypto (e.g., Monero)?</p> + <input type="checkbox" bind:checked={$serviceCrypto} id="service-crypto" /> + <p> + If this is a hosted and paid for service, does it accept anonymous payment + methods, including crypto (e.g., Monero)? + </p> </div> <div class="final-info"> - <p> - Finally, please provide any supporting material, including: - </p> + <p>Finally, please provide any supporting material, including:</p> <ul> <li> A justification of why this app/service should be included in the list </li> + <li>Links to any published security audit, if they exist</li> <li> - Links to any published security audit, if they exist + Links to the services privacy policy, terms of service and other + relevant documents where applicable </li> <li> - Links to the services privacy policy, terms of service and other relevant - documents where applicable + Your affiliation with the service. For transparency, you must disclose + if you are associated with them or any similar items in any way </li> <li> - Your affiliation with the service. - For transparency, you must disclose if you are associated - with them or any similar items in any way + Links to relevant discussions, past issues/PRs related to this service </li> - <li>Links to relevant discussions, past issues/PRs related to this service</li> </ul> - <textarea bind:value={$additionalInfo} id="additional-info" rows="5"></textarea> + <textarea bind:value={$additionalInfo} id="additional-info" rows="5" + ></textarea> </div> <button type="submit">Submit</button> - <a href={issueUrl} target="_blank" class="open-in-gh">Open in GitHub Issues</a> + <a href={issueUrl} target="_blank" class="open-in-gh">Open in GitHub Issues</a + > </form> <div class="output-yaml"> - <p>Below is the YAML content, which will be appended to the appropriate section - within <a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a> + <p> + Below is the YAML content, which will be appended to the appropriate section + within <a + href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml" + >awesome-privacy.yml</a + > upon approval. </p> {#if !interactiveActivated || !codeBlock} <pre><code class="language-yaml">{@html yamlText}</code></pre> {/if} <pre><code bind:this={codeBlock} class="language-yaml"></code></pre> - <p>Your submission will need to be reviewed by a maintainer and the community before it can be merged.</p> + <p> + Your submission will need to be reviewed by a maintainer and the community + before it can be merged. + </p> </div> <style lang="scss"> @@ -332,7 +439,9 @@ } } - input, textarea, select { + input, + textarea, + select { width: 100%; border: 1px solid var(--accent-3); border-radius: var(--curve-md); @@ -347,15 +456,15 @@ } input { height: fit-content; - &[type="number"]::-webkit-outer-spin-button, - &[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; + &[type='number']::-webkit-outer-spin-button, + &[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; } - &[type="number"] { - -moz-appearance: textfield; + &[type='number'] { + -moz-appearance: textfield; } - &[type="checkbox"] { + &[type='checkbox'] { width: 2rem; height: 2rem; background: var(--background-form); @@ -381,7 +490,7 @@ box-shadow: 3px 3px 0 var(--box-outline); border-radius: var(--curve-lg); font-size: 1.8rem; - font-family: "Lekton", sans-serif; + font-family: 'Lekton', sans-serif; margin: 1rem auto; display: flex; transition: all 0.2s ease-in-out; diff --git a/web/src/components/things/AndroidDetailedInfo.astro b/web/src/components/things/AndroidDetailedInfo.astro index 414de71..e9b2400 100644 --- a/web/src/components/things/AndroidDetailedInfo.astro +++ b/web/src/components/things/AndroidDetailedInfo.astro @@ -1,170 +1,178 @@ --- - import type { AndroidInfo } from '@utils/fetch-android-info'; -import { formatDate, timeAgo } from '@utils/dates-n-stuff'; -import FontAwesome from "@components/form/FontAwesome.svelte" - +import { formatDate, timeAgo } from '@utils/dates-n-stuff'; +import FontAwesome from '@components/form/FontAwesome.svelte'; interface Props { - androidData: AndroidInfo; -}; + androidData: AndroidInfo; +} const { androidData } = Astro.props; function permissionToReadable(permission: string): string { - return (permission - .split('.') - .pop() || '') - .replace(/_/g, ' ') - .toLowerCase() - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') + return (permission.split('.').pop() || '') + .replace(/_/g, ' ') + .toLowerCase() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); } - - --- <div class="android-info-wrapper"> - <div class="left"> - <h4>Update Info</h4> - <ul class="list-table"> - <li> - <span class="lbl">App</span> - <span class="val"> - <!-- <img width="20" src={`https://reports.exodus-privacy.eu.org/en/reports/${androidData.version_code}/icon`} alt="Android Icon" /> --> - <a href={`https://play.google.com/store/apps/details?id=${androidData.handle}`}>{androidData.app_name}</a> - </span> - </li> - <li> - <span class="lbl">Creation Date</span> - <span class="val">{formatDate(androidData.created)}</span> - </li> - <li> - <span class="lbl">Last Updated</span> - <span class="val">{formatDate(androidData.updated)}</span> - </li> - <li> - <span class="lbl">Current Version</span> - <span class="val">{androidData.version_name}</span> - </li> - {androidData.creator && ( - <li> - <span class="lbl">Creator</span> - <span class="val">{androidData.creator}</span> - </li> - )} - {androidData.downloads && ( - <li> - <span class="lbl">Downloads</span> - <span class="val">{androidData.downloads}</span> - </li> - )} - </ul> + <div class="left"> + <h4>Update Info</h4> + <ul class="list-table"> + <li> + <span class="lbl">App</span> + <span class="val"> + <!-- <img width="20" src={`https://reports.exodus-privacy.eu.org/en/reports/${androidData.version_code}/icon`} alt="Android Icon" /> --> + <a + href={`https://play.google.com/store/apps/details?id=${androidData.handle}`} + >{androidData.app_name}</a + > + </span> + </li> + <li> + <span class="lbl">Creation Date</span> + <span class="val">{formatDate(androidData.created)}</span> + </li> + <li> + <span class="lbl">Last Updated</span> + <span class="val">{formatDate(androidData.updated)}</span> + </li> + <li> + <span class="lbl">Current Version</span> + <span class="val">{androidData.version_name}</span> + </li> + { + androidData.creator && ( + <li> + <span class="lbl">Creator</span> + <span class="val">{androidData.creator}</span> + </li> + ) + } + { + androidData.downloads && ( + <li> + <span class="lbl">Downloads</span> + <span class="val">{androidData.downloads}</span> + </li> + ) + } + </ul> - <h4>Trackers</h4> - {(androidData.trackers || []).length === 0 && ( - <p class="all-good"> - <FontAwesome iconName="noTrackers" /> - No trackers found - </p> - )} - <ul class="list"> - {(androidData.trackers || []).map((track) => ( - <li title={track.code_signature}>{track.name}</li> - ))} - </ul> - </div> + <h4>Trackers</h4> + { + (androidData.trackers || []).length === 0 && ( + <p class="all-good"> + <FontAwesome iconName="noTrackers" /> + No trackers found + </p> + ) + } + <ul class="list"> + { + (androidData.trackers || []).map((track) => ( + <li title={track.code_signature}>{track.name}</li> + )) + } + </ul> + </div> - <div class="right"> - <h4>Permissions</h4> - {(androidData.permissions || []).length === 0 && ( - <p class="all-good"> - <FontAwesome iconName="noTrackers" /> - No permissions required - </p> - )} - <ul class="list"> - {(androidData.permissions || []).map((perm) => ( - <li title={perm}>{permissionToReadable(perm)}</li> - ))} - </ul> - </div> + <div class="right"> + <h4>Permissions</h4> + { + (androidData.permissions || []).length === 0 && ( + <p class="all-good"> + <FontAwesome iconName="noTrackers" /> + No permissions required + </p> + ) + } + <ul class="list"> + { + (androidData.permissions || []).map((perm) => ( + <li title={perm}>{permissionToReadable(perm)}</li> + )) + } + </ul> + </div> </div> - <style lang="scss"> -.android-info-wrapper { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 1rem; - .left, .right { - width: calc(50% - 1rem); - @media screen and (max-width: 768px){ - width: 100%; - } - } -} + .android-info-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + .left, + .right { + width: calc(50% - 1rem); + @media screen and (max-width: 768px) { + width: 100%; + } + } + } -h3 { - margin: 0; - font-size: 1.6rem; -} + h3 { + margin: 0; + font-size: 1.6rem; + } -h4 { - margin: 1rem 0 0 0; - font-size: 1.2rem; -} + h4 { + margin: 1rem 0 0 0; + font-size: 1.2rem; + } -ul { - padding-left: 0; - list-style: none; - max-height: 300px; - overflow-y: auto; - overflow-x: hidden; - img { - border-radius: var(--curve-sm); - } - .list-item { - display: flex; - flex-direction: column; - margin-bottom: 0.5rem; - } - &.list-table { - font-size: 0.9rem; - padding-left: 0; - li { - display: flex; - justify-content: space-between; - padding: 0.1rem 0; - .lbl { - font-weight: 400; - } - .val { - img { - margin-right: 0.5rem; - } - } - &:not(:last-child) { - border-bottom: 1px solid #5f53f440; - } - } - } - &.list { - list-style: circle; - padding-left: 1rem; - } -} - -.all-good { - color: var(--success); - display: flex; - align-items: center; - gap: 0.5rem; - :global(svg) { - width: 1.5rem; - } -} + ul { + padding-left: 0; + list-style: none; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; + img { + border-radius: var(--curve-sm); + } + .list-item { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; + } + &.list-table { + font-size: 0.9rem; + padding-left: 0; + li { + display: flex; + justify-content: space-between; + padding: 0.1rem 0; + .lbl { + font-weight: 400; + } + .val { + img { + margin-right: 0.5rem; + } + } + &:not(:last-child) { + border-bottom: 1px solid #5f53f440; + } + } + } + &.list { + list-style: circle; + padding-left: 1rem; + } + } + .all-good { + color: var(--success); + display: flex; + align-items: center; + gap: 0.5rem; + :global(svg) { + width: 1.5rem; + } + } </style> diff --git a/web/src/components/things/Comments.svelte b/web/src/components/things/Comments.svelte index 1a69a52..554e416 100644 --- a/web/src/components/things/Comments.svelte +++ b/web/src/components/things/Comments.svelte @@ -1,19 +1,25 @@ <svelte:head> <script async lang="javascript"> - var remark_config = { - host: 'https://comments.as93.net', site_id: 'awesome-privacy.xyz', - components: ['embed'], show_rss_subsription: true, theme: 'dark', - }; - !(function (e, n) { - for (var o = 0; o < e.length; o++) { - var r = n.createElement('script'), d = n.head || n.body; - 'noModule' in r ? - (r.type = 'module', r.src = remark_config.host + '/web/' + e[o] + '.mjs') - : ( r.async = !0, r.defer = !0, r.src = remark_config.host + '/web/' + e[o] + '.js'), - d.appendChild(r); - } - })(remark_config.components || ['embed'], document); - + var remark_config = { + host: 'https://comments.as93.net', + site_id: 'awesome-privacy.xyz', + components: ['embed'], + show_rss_subsription: true, + theme: 'dark', + }; + !(function (e, n) { + for (var o = 0; o < e.length; o++) { + var r = n.createElement('script'), + d = n.head || n.body; + ('noModule' in r + ? ((r.type = 'module'), + (r.src = remark_config.host + '/web/' + e[o] + '.mjs')) + : ((r.async = !0), + (r.defer = !0), + (r.src = remark_config.host + '/web/' + e[o] + '.js')), + d.appendChild(r)); + } + })(remark_config.components || ['embed'], document); </script> </svelte:head> diff --git a/web/src/components/things/DataActions.svelte b/web/src/components/things/DataActions.svelte index 04ccad4..c519441 100644 --- a/web/src/components/things/DataActions.svelte +++ b/web/src/components/things/DataActions.svelte @@ -1,18 +1,23 @@ <script lang="ts"> - import { onMount } from "svelte"; - import { fetchSrcData, makeRemovalRequest, makeEditRequest } from '@utils/data-src-delete-n-edit'; + import { onMount } from 'svelte'; + import { + fetchSrcData, + makeRemovalRequest, + makeEditRequest, + } from '@utils/data-src-delete-n-edit'; import FontAwesome from '@components/form/FontAwesome.svelte'; export let categoryName: string; export let sectionName: string; export let serviceName: string; - let lineNumbers: { start: number, end: number } | null = null; + let lineNumbers: { start: number; end: number } | null = null; let yamlContent = ''; const getGitHubSrcFile = () => { if (lineNumbers) { - const baseFile = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; + const baseFile = + 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; return `${baseFile}#L${lineNumbers.start}-L${lineNumbers.end}`; } return ''; @@ -21,56 +26,68 @@ const getIframeSrc = () => { const host = 'https://github-embed.as93.net'; const target = encodeURIComponent(getGitHubSrcFile()); - const opts = 'style=felipec&type=code&showBorder=on&showLineNumbers=on&showFileMeta=on&showFullPath=on&showCopy=on'; + const opts = + 'style=felipec&type=code&showBorder=on&showLineNumbers=on&showFileMeta=on&showFullPath=on&showCopy=on'; return `${host}/iframe.html?target=${target}&${opts}`; }; onMount(async () => { const results = await fetchSrcData(categoryName, sectionName, serviceName); - lineNumbers = results.lineNumbers + lineNumbers = results.lineNumbers; yamlContent = results.yamlContent; }); - </script> {#if lineNumbers} + <h4>Edit {serviceName} Data</h4> + <p> + You can view or edit this {serviceName}'s entry in + <a href={getGitHubSrcFile()}> this section </a> + of <code>awesome-privacy.yml</code> in our GitHub repo. + </p> -<h4>Edit {serviceName} Data</h4> -<p> - You can view or edit this {serviceName}'s entry in - <a href={getGitHubSrcFile()}> - this section - </a> - of <code>awesome-privacy.yml</code> in our GitHub repo. -</p> - -<h4>Origin Data</h4> -<iframe - frameborder="0" - scrolling="no" - class="yaml-embed" - allow="clipboard-write" - title="awesome-privacy.yml" - src={getIframeSrc()}></iframe> - -<h4>Modify Data</h4> -<div class="button-wrap"> - <a class="button-link" target="_blank" - href={makeRemovalRequest(categoryName, sectionName, serviceName, yamlContent)}> - <FontAwesome iconName="delete" /> Delete {serviceName} - </a> - <a class="button-link" target="_blank" - href={makeEditRequest(categoryName, sectionName, serviceName, yamlContent)}> - <FontAwesome iconName="edit" /> Submit Edit to {serviceName} - </a> - <a class="button-link" href="/submit"> - <FontAwesome iconName="add" /> Add alternative - </a> -</div> + <h4>Origin Data</h4> + <iframe + frameborder="0" + scrolling="no" + class="yaml-embed" + allow="clipboard-write" + title="awesome-privacy.yml" + src={getIframeSrc()} + ></iframe> + <h4>Modify Data</h4> + <div class="button-wrap"> + <a + class="button-link" + target="_blank" + href={makeRemovalRequest( + categoryName, + sectionName, + serviceName, + yamlContent, + )} + > + <FontAwesome iconName="delete" /> Delete {serviceName} + </a> + <a + class="button-link" + target="_blank" + href={makeEditRequest( + categoryName, + sectionName, + serviceName, + yamlContent, + )} + > + <FontAwesome iconName="edit" /> Submit Edit to {serviceName} + </a> + <a class="button-link" href="/submit"> + <FontAwesome iconName="add" /> Add alternative + </a> + </div> {/if} - <style lang="scss"> h4 { font-size: 1.4rem; @@ -85,7 +102,7 @@ gap: 1rem; justify-content: center; margin: 1rem auto; - @media(max-width: 768px) { + @media (max-width: 768px) { flex-direction: column; } } @@ -101,7 +118,7 @@ min-width: 15rem; display: inline-block; text-align: center; - font-family: "Lekton",sans-serif; + font-family: 'Lekton', sans-serif; font-size: 1.2rem; :global(svg) { width: 1rem; @@ -118,5 +135,4 @@ margin: 1rem auto; box-shadow: 3px 3px 0 var(--accent-3); } - </style> diff --git a/web/src/components/things/DeleteListing.svelte b/web/src/components/things/DeleteListing.svelte index 2e825d0..ac26b73 100644 --- a/web/src/components/things/DeleteListing.svelte +++ b/web/src/components/things/DeleteListing.svelte @@ -1,15 +1,17 @@ - <script lang="ts"> import FontAwesome from '@components/form/FontAwesome.svelte'; - import { fetchSrcData, makeRemovalRequest } from '@utils/data-src-delete-n-edit'; + import { + fetchSrcData, + makeRemovalRequest, + } from '@utils/data-src-delete-n-edit'; import { onMount } from 'svelte'; - export let categoryName: string; export let sectionName: string; export let serviceName: string; - const apYaml = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; + const apYaml = + 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; let yamlContent = ''; let editLink = apYaml; @@ -19,43 +21,51 @@ yamlContent = results.yamlContent; const lineNumbers = results.lineNumbers || null; - const numberRange = lineNumbers ? `#L${lineNumbers.start}-L${lineNumbers.end}` : ''; - const yamlLink = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; + const numberRange = lineNumbers + ? `#L${lineNumbers.start}-L${lineNumbers.end}` + : ''; + const yamlLink = + 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; editLink = `${yamlLink}${numberRange}`; }); - </script> <div class="actions"> - <a title="Edit" target="_blank" - href={editLink}> + <a title="Edit" target="_blank" href={editLink}> <FontAwesome iconName="edit" /> </a> - <a title="Delete" target="_blank" - href={makeRemovalRequest(categoryName, sectionName, serviceName, yamlContent)}> + <a + title="Delete" + target="_blank" + href={makeRemovalRequest( + categoryName, + sectionName, + serviceName, + yamlContent, + )} + > <FontAwesome iconName="delete" /> </a> </div> <style lang="scss"> -.actions { - position: absolute; - right: 3.5rem; - top: 1rem; - width: 2.8rem; - gap: 1rem; - opacity: 0; - display: flex; - transition: all 0.2s ease-in-out; - a { - color: var(--foreground); - width: 1rem; + .actions { + position: absolute; + right: 3.5rem; + top: 1rem; + width: 2.8rem; + gap: 1rem; + opacity: 0; + display: flex; transition: all 0.2s ease-in-out; - &:hover { - color: var(--accent-3); - opacity: 1; + a { + color: var(--foreground); + width: 1rem; + transition: all 0.2s ease-in-out; + &:hover { + color: var(--accent-3); + opacity: 1; + } } } - -} </style> diff --git a/web/src/components/things/DiscordDetailedInfo.astro b/web/src/components/things/DiscordDetailedInfo.astro index cbf47b8..0696a43 100644 --- a/web/src/components/things/DiscordDetailedInfo.astro +++ b/web/src/components/things/DiscordDetailedInfo.astro @@ -1,110 +1,113 @@ --- - import type { DiscordInfo } from '@utils/fetch-discord-info'; -import { formatDate, timeAgo } from '@utils/dates-n-stuff'; -import FontAwesome from "@components/form/FontAwesome.svelte" - +import { formatDate, timeAgo } from '@utils/dates-n-stuff'; +import FontAwesome from '@components/form/FontAwesome.svelte'; interface Props { - discordData: DiscordInfo; -}; + discordData: DiscordInfo; +} const { discordData } = Astro.props; - - --- <div class="discord-info-wrapper"> + <h3>Discord</h3> - <h3>Discord</h3> + <ul class="list-table"> + <li> + <span class="lbl">Server Name</span> + <span class="val" + ><img src={discordData.icon} width="16" />{discordData.name}</span + > + </li> + <li> + <span class="lbl">Member Count</span> + <span class="val" + >{discordData.memberCount} ({discordData.memberOnlineCount} online)</span + > + </li> + <li> + <span class="lbl">Initial Channel</span> + <span class="val">{discordData.channel}</span> + </li> + <li> + <span class="lbl">Inviter</span> + <span class="val">{discordData.inviter || 'Anon'}</span> + </li> + <li> + <span class="lbl">Join Link</span> + <span class="val" + ><a href={`https://discord.com/invite/${discordData.inviteCode}`} + >discord.com/invite/{discordData.inviteCode}</a + ></span + > + </li> + </ul> - <ul class="list-table"> - <li> - <span class="lbl">Server Name</span> - <span class="val"><img src={discordData.icon} width="16" />{discordData.name}</span> - </li> - <li> - <span class="lbl">Member Count</span> - <span class="val">{discordData.memberCount} ({discordData.memberOnlineCount} online)</span> - </li> - <li> - <span class="lbl">Initial Channel</span> - <span class="val">{discordData.channel}</span> - </li> - <li> - <span class="lbl">Inviter</span> - <span class="val">{discordData.inviter || 'Anon'}</span> - </li> - <li> - <span class="lbl">Join Link</span> - <span class="val"><a href={`https://discord.com/invite/${discordData.inviteCode}`}>discord.com/invite/{discordData.inviteCode}</a></span> - </li> - </ul> - - { discordData.banner && (<img class="banner" width="300" src={discordData.banner} />)} - - + { + discordData.banner && ( + <img class="banner" width="300" src={discordData.banner} /> + ) + } </div> - <style lang="scss"> -.discord-info-wrapper { - display: flex; - flex-direction: column; - max-width: 400px; -} + .discord-info-wrapper { + display: flex; + flex-direction: column; + max-width: 400px; + } -h3 { - margin: 0; - font-size: 1.6rem; -} + h3 { + margin: 0; + font-size: 1.6rem; + } -h4 { - margin: 1rem 0 0 0; - font-size: 1.2rem; -} + h4 { + margin: 1rem 0 0 0; + font-size: 1.2rem; + } -ul { - padding-left: 0; - list-style: none; - max-height: 300px; - overflow-y: auto; - overflow-x: hidden; - img { - border-radius: var(--curve-sm); - } - .list-item { - display: flex; - flex-direction: column; - margin-bottom: 0.5rem; - } - &.list-table { - font-size: 0.9rem; - padding-left: 0; - li { - display: flex; - justify-content: space-between; - padding: 0.1rem 0; - .lbl { - font-weight: 400; - } - .val { - img { - margin-right: 0.5rem; - } - } - &:not(:last-child) { - border-bottom: 1px solid #5f53f440; - } - } - } -} - -.banner { - width: 80%; - margin: 1rem auto 0 auto; - display: flex; - border-radius: var(--curve-md); -} + ul { + padding-left: 0; + list-style: none; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; + img { + border-radius: var(--curve-sm); + } + .list-item { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; + } + &.list-table { + font-size: 0.9rem; + padding-left: 0; + li { + display: flex; + justify-content: space-between; + padding: 0.1rem 0; + .lbl { + font-weight: 400; + } + .val { + img { + margin-right: 0.5rem; + } + } + &:not(:last-child) { + border-bottom: 1px solid #5f53f440; + } + } + } + } + .banner { + width: 80%; + margin: 1rem auto 0 auto; + display: flex; + border-radius: var(--curve-md); + } </style> diff --git a/web/src/components/things/GetSharableLink.svelte b/web/src/components/things/GetSharableLink.svelte index 0fcc694..5af0bdf 100644 --- a/web/src/components/things/GetSharableLink.svelte +++ b/web/src/components/things/GetSharableLink.svelte @@ -1,37 +1,39 @@ - <script lang="ts"> + import { slugify } from '@utils/fetch-data'; -import { slugify } from "@utils/fetch-data"; + let linkId = ''; + let done = false; + let error = false; -let linkId = ''; -let done = false; -let error = false; - -const save = async () => { - const savedServices = JSON.parse(localStorage.getItem('savedServices') || '[]'); - const inventoryTitle = localStorage.getItem('userTitle') || 'Anon\'s Inventory'; - const uniqueId = Math.random().toString(36).substring(2); - const saveKey = `${uniqueId}_${slugify(inventoryTitle)}`; - const url = 'https://awesome-privacy-share-api.as93.net'; - const data = { key: saveKey, services: savedServices }; - fetch(url, { + const save = async () => { + const savedServices = JSON.parse( + localStorage.getItem('savedServices') || '[]', + ); + const inventoryTitle = + localStorage.getItem('userTitle') || "Anon's Inventory"; + const uniqueId = Math.random().toString(36).substring(2); + const saveKey = `${uniqueId}_${slugify(inventoryTitle)}`; + const url = 'https://awesome-privacy-share-api.as93.net'; + const data = { key: saveKey, services: savedServices }; + fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), - }) - .then(response => response.json()) - .then(data => { - linkId = data.key; - done = true; - error = false; - navigator.clipboard.writeText(`https://awesome-privacy.xyz/inventory/${linkId}`); - }) - .catch(error => { - error = true; - console.error('Error:', error) - }); -}; - + }) + .then((response) => response.json()) + .then((data) => { + linkId = data.key; + done = true; + error = false; + navigator.clipboard.writeText( + `https://awesome-privacy.xyz/inventory/${linkId}`, + ); + }) + .catch((error) => { + error = true; + console.error('Error:', error); + }); + }; </script> <div class="share-container"> diff --git a/web/src/components/things/RedditDetailedInfo.astro b/web/src/components/things/RedditDetailedInfo.astro index 546e27b..d76b473 100644 --- a/web/src/components/things/RedditDetailedInfo.astro +++ b/web/src/components/things/RedditDetailedInfo.astro @@ -1,172 +1,192 @@ --- - import type { RedditData } from '@utils/fetch-reddit-info'; -import { timestampToDate, timeAgo } from '@utils/dates-n-stuff'; -import FontAwesome from "@components/form/FontAwesome.svelte" - +import { timestampToDate, timeAgo } from '@utils/dates-n-stuff'; +import FontAwesome from '@components/form/FontAwesome.svelte'; interface Props { - redditData: RedditData; -}; + redditData: RedditData; +} const { redditData } = Astro.props; - --- <div class="reddit-info-wrapper"> - <div class="left"> - <h3>Reddit</h3> - <p class="website-title"> - <img src={redditData.info.icon} width="16" /> - {redditData.info.title || redditData.info.name} - </p> - <p class="website-description">{redditData.info.description}</p> - {redditData.info.banner && (<img class="banner" width="300" src={redditData.info.banner} alt="Banner" />)} - <ul class="list-table"> - {redditData.info.dateCreated && ( - <li> - <span class="lbl">Created at</span> - <span class="val">{timestampToDate(redditData.info.dateCreated * 1000)}</span> - </li> - )} - <li> - <span class="lbl">Members</span> - <span class="val">{redditData.info.subscribers}</span> - </li> - <li> - <span class="lbl">Join</span> - <span class="val"><a href={`https://reddit.com/${redditData.info.name}`}>{redditData.info.name}</a></span> - </li> - </ul> - </div> - <div class="right"> - <h4>Posts</h4> - <ul class="posts"> - {redditData.posts.map((post) => ( - <li title={post.body}> - ○ <a href={post.url} target="_blank">{post.title}</a> - <span class="votes">(▲ {post.upVotes} ▼ {post.downVotes})</span> - </li> - ))} - </ul> - </div> + <div class="left"> + <h3>Reddit</h3> + <p class="website-title"> + <img src={redditData.info.icon} width="16" /> + {redditData.info.title || redditData.info.name} + </p> + <p class="website-description">{redditData.info.description}</p> + { + redditData.info.banner && ( + <img + class="banner" + width="300" + src={redditData.info.banner} + alt="Banner" + /> + ) + } + <ul class="list-table"> + { + redditData.info.dateCreated && ( + <li> + <span class="lbl">Created at</span> + <span class="val"> + {timestampToDate(redditData.info.dateCreated * 1000)} + </span> + </li> + ) + } + <li> + <span class="lbl">Members</span> + <span class="val">{redditData.info.subscribers}</span> + </li> + <li> + <span class="lbl">Join</span> + <span class="val" + ><a href={`https://reddit.com/${redditData.info.name}`} + >{redditData.info.name}</a + ></span + > + </li> + </ul> + </div> + <div class="right"> + <h4>Posts</h4> + <ul class="posts"> + { + redditData.posts.map((post) => ( + <li title={post.body}> + ○{' '} + <a href={post.url} target="_blank"> + {post.title} + </a> + <span class="votes"> + (▲ {post.upVotes} ▼ {post.downVotes}) + </span> + </li> + )) + } + </ul> + </div> </div> - <style lang="scss"> -.reddit-info-wrapper { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 1rem; - .left, .right { - width: calc(50% - 1rem); - @media screen and (max-width: 768px){ - width: 100%; - } - } -} + .reddit-info-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + .left, + .right { + width: calc(50% - 1rem); + @media screen and (max-width: 768px) { + width: 100%; + } + } + } -.banner { - margin: 0.5rem auto; - border-radius: var(--curve-md); - width: 100%; -} + .banner { + margin: 0.5rem auto; + border-radius: var(--curve-md); + width: 100%; + } -h3 { - margin: 0 0 1rem 0; - font-size: 1.6rem; -} + h3 { + margin: 0 0 1rem 0; + font-size: 1.6rem; + } -h4 { - margin: 1rem 0 0 0; - font-size: 1.2rem; -} -p { - margin: 0; - display: flex; - align-items: center; - gap: 0.25rem; - :global(svg) { - width: 1rem; - } - img { - border-radius: var(--curve-sm); - } -} + h4 { + margin: 1rem 0 0 0; + font-size: 1.2rem; + } + p { + margin: 0; + display: flex; + align-items: center; + gap: 0.25rem; + :global(svg) { + width: 1rem; + } + img { + border-radius: var(--curve-sm); + } + } -ul { - padding-left: 0; - list-style: none; - max-height: 300px; - overflow-y: auto; - overflow-x: hidden; - img { - border-radius: var(--curve-sm); - } - .list-item { - display: flex; - flex-direction: column; - margin-bottom: 0.5rem; - } - &.list-table { - font-size: 0.9rem; - padding-left: 0; - li { - display: flex; - justify-content: space-between; - padding: 0.1rem 0; - .lbl { - font-weight: 400; - } - &:not(:last-child) { - border-bottom: 1px solid #5f53f440; - } - } - } -} - -.posts { - list-style: circle; - padding-left: 1rem; - font-size: 0.9rem; - li { - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - a { - max-width: 80%; - } - .votes { - font-size: 0.8rem; - opacity: 0.5; - } - } -} - -.website-title, .website-description { - font-size: 0.9rem; - opacity: 0.8; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - border-left: 2px solid var(--accent-3); - padding-left: 0.5rem; -} -.website-title { - font-weight: 500; -} -.website-description { - font-style: italic; - -webkit-line-clamp: 3; -} -.explainer { - font-size: 0.8rem; - opacity: 0.8; - font-style: italic; -} + ul { + padding-left: 0; + list-style: none; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; + img { + border-radius: var(--curve-sm); + } + .list-item { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; + } + &.list-table { + font-size: 0.9rem; + padding-left: 0; + li { + display: flex; + justify-content: space-between; + padding: 0.1rem 0; + .lbl { + font-weight: 400; + } + &:not(:last-child) { + border-bottom: 1px solid #5f53f440; + } + } + } + } + .posts { + list-style: circle; + padding-left: 1rem; + font-size: 0.9rem; + li { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + a { + max-width: 80%; + } + .votes { + font-size: 0.8rem; + opacity: 0.5; + } + } + } + .website-title, + .website-description { + font-size: 0.9rem; + opacity: 0.8; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + border-left: 2px solid var(--accent-3); + padding-left: 0.5rem; + } + .website-title { + font-weight: 500; + } + .website-description { + font-style: italic; + -webkit-line-clamp: 3; + } + .explainer { + font-size: 0.8rem; + opacity: 0.8; + font-style: italic; + } </style> diff --git a/web/src/components/things/SaveListing.svelte b/web/src/components/things/SaveListing.svelte index a725971..2ea7a90 100644 --- a/web/src/components/things/SaveListing.svelte +++ b/web/src/components/things/SaveListing.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { onMount } from 'svelte'; - import FontAwesome from "@components/form/FontAwesome.svelte"; - import { slugify } from "@utils/fetch-data"; + import FontAwesome from '@components/form/FontAwesome.svelte'; + import { slugify } from '@utils/fetch-data'; export let categoryName: string; export let sectionName: string; @@ -34,23 +34,24 @@ </script> <div class="wrapper-or-something"> -<button - class={`save-container ${isSaved ? 'saved' : ''} ${showLabel ? 'label-button' : ''}`} - title={`Save ${serviceName}`} - on:click={toggleSave}> - {#if showLabel } - <span> - {isSaved ? 'Saved' : 'Save'} - </span> - {/if} - <FontAwesome iconName="saveListing"/> -</button> + <button + class={`save-container ${isSaved ? 'saved' : ''} ${showLabel ? 'label-button' : ''}`} + title={`Save ${serviceName}`} + on:click={toggleSave} + > + {#if showLabel} + <span> + {isSaved ? 'Saved' : 'Save'} + </span> + {/if} + <FontAwesome iconName="saveListing" /> + </button> -{#if showLabel && isSaved } -<div class="done-msg"> - You can view all saved items in your <a href="/inventory">Inventory</a> -</div> -{/if} + {#if showLabel && isSaved} + <div class="done-msg"> + You can view all saved items in your <a href="/inventory">Inventory</a> + </div> + {/if} </div> <style lang="scss"> @@ -71,7 +72,7 @@ font-size: 1.2rem; opacity: 0.8; color: var(--foreground); - font-family: "Lekton"; + font-family: 'Lekton'; } :global(svg) { color: var(--foreground); @@ -98,9 +99,8 @@ box-shadow: 3px 3px 0 var(--box-outline); border: 1px solid var(--box-outline); background: var(--background-form); - + &:hover { - box-shadow: 4px 4px 0 var(--box-outline); } } @@ -110,7 +110,7 @@ max-width: 165px; font-size: 0.8rem; opacity: 0.6; - @media(max-width: 768px) { + @media (max-width: 768px) { display: none; } } diff --git a/web/src/components/things/SavedServices.svelte b/web/src/components/things/SavedServices.svelte index 214c498..713f035 100644 --- a/web/src/components/things/SavedServices.svelte +++ b/web/src/components/things/SavedServices.svelte @@ -1,42 +1,48 @@ <script lang="ts"> -import { onMount } from 'svelte'; -import { writable } from 'svelte/store'; + import { onMount } from 'svelte'; + import { writable } from 'svelte/store'; -import type { Category, Service } from '../../types/Service'; -import { slugify } from "@utils/fetch-data"; -import ServiceCard from './ServiceCard.svelte'; + import type { Category, Service } from '../../types/Service'; + import { slugify } from '@utils/fetch-data'; + import ServiceCard from './ServiceCard.svelte'; -export let allData: Category[]; -export let serviceList: string[] | null = null; + export let allData: Category[]; + export let serviceList: string[] | null = null; -interface SavedServices { - category: string; - section: string; - service: Service; -} + interface SavedServices { + category: string; + section: string; + service: Service; + } -const savedServices = writable<SavedServices[]>([]); + const savedServices = writable<SavedServices[]>([]); -onMount(async () => { - const results: SavedServices[] = []; - const saved = serviceList || JSON.parse(localStorage.getItem('savedServices') || '[]'); - saved.forEach((serviceId: string) => { - const parts = serviceId.split('/'); - const categoryName = parts[0]; - const sectionName = parts[1]; - const serviceName = parts[2]; + onMount(async () => { + const results: SavedServices[] = []; + const saved = + serviceList || JSON.parse(localStorage.getItem('savedServices') || '[]'); + saved.forEach((serviceId: string) => { + const parts = serviceId.split('/'); + const categoryName = parts[0]; + const sectionName = parts[1]; + const serviceName = parts[2]; - const category = allData.find((category) => slugify(category.name) === categoryName); - if (!category) return; - const section = category.sections.find((section) => slugify(section.name) === sectionName); - if (!section) return; - const service = section.services.find((service) => slugify(service.name) === serviceName); - if (!service) return; - results.push({ category: category.name, section: section.name, service}); + const category = allData.find( + (category) => slugify(category.name) === categoryName, + ); + if (!category) return; + const section = category.sections.find( + (section) => slugify(section.name) === sectionName, + ); + if (!section) return; + const service = section.services.find( + (service) => slugify(service.name) === serviceName, + ); + if (!service) return; + results.push({ category: category.name, section: section.name, service }); + }); + savedServices.set(results || []); }); - savedServices.set(results || []); -}); - </script> <div> @@ -52,10 +58,13 @@ onMount(async () => { </div> {:else if !serviceList} <div class="nothing-yet"> - <p>Here you'll find a list of all the software and services you've bookmarked.</p> + <p> + Here you'll find a list of all the software and services you've + bookmarked. + </p> <small> - All data is stored on-device, in your browser's local storage, - and not sent anywhere unless you choose to share it + All data is stored on-device, in your browser's local storage, and not + sent anywhere unless you choose to share it </small> <p class="nope">Nothing saved yet!</p> </div> diff --git a/web/src/components/things/Search.svelte b/web/src/components/things/Search.svelte index a90f76e..22f3d21 100644 --- a/web/src/components/things/Search.svelte +++ b/web/src/components/things/Search.svelte @@ -2,7 +2,12 @@ import { onMount } from 'svelte'; import Fuse from 'fuse.js'; import { slugify } from '@utils/fetch-data'; - import type { Category, Section, Service, ShortService } from '../../types/Service'; + import type { + Category, + Section, + Service, + ShortService, + } from '../../types/Service'; import { formatLink } from '@utils/parse-markdown'; import { prepareSearchItems, searchOptions } from '@utils/do-searchy-searchy'; @@ -20,9 +25,9 @@ }); const makeResultLink = (cat?: string, sec?: string, itm?: string) => { - if (!cat) return '/' - if (!sec) return `/${slugify(cat)}` - if (!itm) return `/${slugify(cat)}/${slugify(sec)}` + if (!cat) return '/'; + if (!sec) return `/${slugify(cat)}`; + if (!itm) return `/${slugify(cat)}/${slugify(sec)}`; return `/${slugify(cat)}/${slugify(sec)}/${slugify(itm)}`; }; @@ -40,7 +45,7 @@ const makeTitle = (typ: string, desc: string) => { if (desc && typ === 'Service') { - return `${desc.slice(0, 60)}...` + return `${desc.slice(0, 60)}...`; } return ''; }; @@ -59,7 +64,10 @@ // Watch for changes in the search query and update results $: if (searchQuery) { - results = fuse.search(searchQuery).map(result => result.item).splice(0, 25); + results = fuse + .search(searchQuery) + .map((result) => result.item) + .splice(0, 25); } else { results = []; } @@ -79,35 +87,48 @@ bind:value={searchQuery} on:keydown={handleKeyDown} /> - - + {#if searchQuery.length > 0} <div class="suggestions"> <ul> {#each results as result} - <li class="result-row"> - <a - href={makeResultLink(result.category, result.sectionName, result.name)} - title={makeTitle(result.type, result.description)} + <li class="result-row"> + <a + href={makeResultLink( + result.category, + result.sectionName, + result.name, + )} + title={makeTitle(result.type, result.description)} > - <span class="name"> - {#if result.type === 'Service'} - <img src={makeLogoSrc(result.logo, result.url)} alt={result.name} width="20" height="20" loading="lazy" /> - {/if} - - {makeResultText(result.category, result.sectionName, result.name)} - - {#if result.itemCount} - <i>({result.itemCount})</i> - {/if} - </span> - <span class="path"> - {result.category ? `${result.category}` : ''} - {result.sectionName ? `➔ ${result.sectionName}` : ''} - {result.name ? `➔ ${result.name}` : ''} - </span> - </a> - </li> + <span class="name"> + {#if result.type === 'Service'} + <img + src={makeLogoSrc(result.logo, result.url)} + alt={result.name} + width="20" + height="20" + loading="lazy" + /> + {/if} + + {makeResultText( + result.category, + result.sectionName, + result.name, + )} + + {#if result.itemCount} + <i>({result.itemCount})</i> + {/if} + </span> + <span class="path"> + {result.category ? `${result.category}` : ''} + {result.sectionName ? `➔ ${result.sectionName}` : ''} + {result.name ? `➔ ${result.name}` : ''} + </span> + </a> + </li> {/each} </ul> </div> @@ -115,103 +136,101 @@ </div> <style lang="scss"> - -.search-wrap { - display: flex; - flex-direction: column; - position: relative; - margin: 1rem auto; - max-width: 900px; - margin: 0 auto; - width: 80vw; - label { - margin: 0.5rem 0; + .search-wrap { display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - .enter-hint { - font-size:0.8rem; - opacity: 0.7; + flex-direction: column; + position: relative; + margin: 1rem auto; + max-width: 900px; + margin: 0 auto; + width: 80vw; + label { + margin: 0.5rem 0; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + .enter-hint { + font-size: 0.8rem; + opacity: 0.7; + } } - } - input { - padding: 0.5rem 1rem; - font-size: 1.8rem; - border: 2px solid var(--box-outline); - border-radius: var(--curve-lg); - box-shadow: 3px 3px 0 var(--box-outline); - z-index: 4; - background: var(--accent-fg); - color: var(--foreground); - &:focus { - outline: none; - border-color: var(--accent); - box-shadow: 3px 3px 0 var(--accent); - } - } - - .suggestions { - ul { - position: absolute; - background: var(--background-form); - z-index: 3; - width: 100%; - - list-style: none; - padding: 0; - margin: 0; + input { + padding: 0.5rem 1rem; + font-size: 1.8rem; border: 2px solid var(--box-outline); - border-radius: 0 0 var(--curve-lg) var(--curve-lg); + border-radius: var(--curve-lg); box-shadow: 3px 3px 0 var(--box-outline); - transform: translateY(-0.5rem); - max-height: 500px; - overflow-y: scroll; - background: var(--background-form); - li.result-row { - padding: 0.5rem 1rem; - margin: 0.5rem 0; - a { - color: var(--foreground); - text-decoration: none; - display: flex; - justify-content: space-between; - flex-wrap: wrap; - .name { + z-index: 4; + background: var(--accent-fg); + color: var(--foreground); + &:focus { + outline: none; + border-color: var(--accent); + box-shadow: 3px 3px 0 var(--accent); + } + } + + .suggestions { + ul { + position: absolute; + background: var(--background-form); + z-index: 3; + width: 100%; + + list-style: none; + padding: 0; + margin: 0; + border: 2px solid var(--box-outline); + border-radius: 0 0 var(--curve-lg) var(--curve-lg); + box-shadow: 3px 3px 0 var(--box-outline); + transform: translateY(-0.5rem); + max-height: 500px; + overflow-y: scroll; + background: var(--background-form); + li.result-row { + padding: 0.5rem 1rem; + margin: 0.5rem 0; + a { + color: var(--foreground); + text-decoration: none; display: flex; - align-items: center; - gap: 0.5rem; - i { - color: var(--accent); - font-weight: bold; - font-style: normal; + justify-content: space-between; + flex-wrap: wrap; + .name { + display: flex; + align-items: center; + gap: 0.5rem; + i { + color: var(--accent); + font-weight: bold; + font-style: normal; + } + img { + border-radius: var(--curve-md); + width: 1.25rem; + height: 1.25rem; + font-size: 10px; + color: var(--accent); + overflow: hidden; + background: #f453974d; + padding: 1px; + } } - img { - border-radius: var(--curve-md); - width: 1.25rem; - height: 1.25rem; - font-size: 10px; - color: var(--accent); - overflow: hidden; - background: #f453974d; - padding: 1px; + .path { + font-size: 0.85rem; + opacity: 0.7; } } - .path { - font-size: 0.85rem; - opacity: 0.7; - } - } - &:hover { - background: var(--accent); - .name i { - color: var(--accent-fg); + &:hover { + background: var(--accent); + .name i { + color: var(--accent-fg); + } } } } } } -} - </style> diff --git a/web/src/components/things/SectionList.astro b/web/src/components/things/SectionList.astro index 76e9777..ad6e746 100644 --- a/web/src/components/things/SectionList.astro +++ b/web/src/components/things/SectionList.astro @@ -1,6 +1,4 @@ --- - - import FontAwesome from '@components/form/FontAwesome.svelte'; import { slugify } from '../../utils/fetch-data'; @@ -9,144 +7,161 @@ import type { Section } from '../../types/Service'; interface Props { title: string; sections: Section[]; - bigTitle?: boolean; + bigTitle?: boolean; } const { title, sections, bigTitle } = Astro.props; - --- <div class="wrap"> + { + bigTitle ? ( + <h2> + <FontAwesome iconName={slugify(title)} /> + {title}{' '} + </h2> + ) : ( + <a class="category-title" href={`/${slugify(title)}`}> + <h3>{title}</h3> + </a> + ) + } -{ bigTitle ? - <h2><FontAwesome iconName={slugify(title)} />{title} </h2> : - <a class="category-title" href={`/${slugify(title)}`}><h3>{title}</h3></a> -} + { + !bigTitle && ( + <span class="section-icon"> + <FontAwesome iconName={slugify(title)} /> + </span> + ) + } -{ !bigTitle && <span class="section-icon"><FontAwesome iconName={slugify(title)} /></span> } - -<ul> - {sections.map((section) => ( - <li class="section"> - <a href={`/${slugify(title)}/${slugify(section.name)}`}> - <span>{section.name}</span> - <span class="service-count">({section.services ? section.services.length : 0})</span> - </a> - </li> - ))} -</ul> + <ul> + { + sections.map((section) => ( + <li class="section"> + <a href={`/${slugify(title)}/${slugify(section.name)}`}> + <span>{section.name}</span> + <span class="service-count"> + ({section.services ? section.services.length : 0}) + </span> + </a> + </li> + )) + } + </ul> </div> - <style lang="scss"> + .wrap { + position: relative; + &:hover { + .section-icon :global(svg) { + opacity: 1; + transform: scale(1.2); + } + } + } -.wrap { - position: relative; - &:hover { - .section-icon :global(svg){ - opacity: 1; - transform: scale(1.2); - } - } -} + h2 { + font-family: 'Lekton', sans-serif; + font-size: 2rem; + margin: -2rem 0 2rem -2rem; + box-shadow: 6px 6px 0 var(--box-outline); + border: 2px solid var(--box-outline); + background: var(--accent); + color: var(--accent-fg); + width: fit-content; + padding: 0.25rem 0.5rem; + display: flex; + justify-content: center; + gap: 1rem; + :global(svg) { + width: 2rem; + height: 2rem; + color: var(--accent-fg); + } + } -h2 { - font-family: "Lekton", sans-serif; - font-size: 2rem; - margin: -2rem 0 2rem -2rem; - box-shadow: 6px 6px 0 var(--box-outline); - border: 2px solid var(--box-outline); - background: var(--accent); - color: var(--accent-fg); - width: fit-content; - padding: 0.25rem 0.5rem; - display: flex; - justify-content: center; - gap: 1rem; - :global(svg) { - width: 2rem; - height: 2rem; - color: var(--accent-fg); - } -} + .category-title { + text-decoration: none; + color: var(--foreground); + z-index: 2; + position: relative; + h3 { + font-family: 'Lekton', sans-serif; + font-weight: bold; + margin: 0; + font-size: 1.8rem; + position: relative; + &:after { + background: none repeat scroll 0 0 transparent; + bottom: 0; + content: ''; + display: block; + height: 3px; + left: 50%; + position: absolute; + background: var(--accent); + transition: + width 0.3s ease 0s, + left 0.3s ease 0s; + width: 0; + } + &:hover:after { + width: 80%; + left: 0; + } + } + } -.category-title { - text-decoration: none; - color: var(--foreground); - z-index: 2; - position: relative; - h3 { - font-family: "Lekton", sans-serif; - font-weight: bold; - margin: 0; - font-size: 1.8rem; - position: relative; - &:after { - background: none repeat scroll 0 0 transparent; - bottom: 0; - content: ""; - display: block; - height: 3px; - left: 50%; - position: absolute; - background: var(--accent); - transition: width 0.3s ease 0s, left 0.3s ease 0s; - width: 0; - } - &:hover:after { - width: 80%; - left: 0; - } - } - -} + ul { + list-style: circle; + padding-left: 1rem; + li { + margin: 0.5rem 0; + font-size: 1.25rem; + a { + text-decoration: none; + color: var(--foreground); + position: relative; + &:after { + background: none repeat scroll 0 0 transparent; + bottom: 0; + content: ''; + display: block; + height: 2px; + left: 50%; + position: absolute; + background: var(--accent-3); + transition: + width 0.15s ease 0s, + left 0.15s ease 0s; + width: 0; + } + &:hover:after { + text-decoration: underline; + width: 80%; + left: 0; + } + } + .service-count { + color: var(--accent-3); + } + } + } -ul { - list-style: circle; - padding-left: 1rem; - li { - margin: 0.5rem 0; - font-size: 1.25rem; - a { - text-decoration: none; - color: var(--foreground); - position: relative; - &:after { - background: none repeat scroll 0 0 transparent; - bottom: 0; - content: ""; - display: block; - height: 2px; - left: 50%; - position: absolute; - background: var(--accent-3); - transition: width 0.15s ease 0s, left 0.15s ease 0s; - width: 0; - } - &:hover:after { - text-decoration: underline; - width: 80%; - left: 0; - } - } - .service-count { - color: var(--accent-3); - } - } -} - -.section-icon { - position: absolute; - right: 0; - top: 0; - width: fit-content; - :global(svg) { - width: 2rem; - height: 2rem; - opacity: 0.5; - text-shadow: 3px 3px 0 black; - color: var(--accent-3); - transition: all 0.2s ease-in-out; - } -} + .section-icon { + position: absolute; + right: 0; + top: 0; + width: fit-content; + :global(svg) { + width: 2rem; + height: 2rem; + opacity: 0.5; + text-shadow: 3px 3px 0 black; + color: var(--accent-3); + transition: all 0.2s ease-in-out; + } + } </style> diff --git a/web/src/components/things/ServiceCard.astro b/web/src/components/things/ServiceCard.astro index f7756f5..950a964 100644 --- a/web/src/components/things/ServiceCard.astro +++ b/web/src/components/things/ServiceCard.astro @@ -1,5 +1,4 @@ --- - import { formatLink } from '@utils/parse-markdown'; import type { Service } from 'src/types/Service'; import FontAwesome from '@components/form/FontAwesome.svelte'; @@ -7,86 +6,91 @@ import SaveListing from '@components/things/SaveListing.svelte'; import { slugify } from '@utils/fetch-data'; interface Props { - service: Service; - categoryName: string; - sectionName: string; + service: Service; + categoryName: string; + sectionName: string; } -const { - service, - sectionName, - categoryName, -} = Astro.props; - +const { service, sectionName, categoryName } = Astro.props; --- <script> - document.addEventListener('DOMContentLoaded', () => { - const serviceIcons = document.querySelectorAll<HTMLImageElement>('.service-icon'); - const broke = '/broken-image.png'; + document.addEventListener('DOMContentLoaded', () => { + const serviceIcons = + document.querySelectorAll<HTMLImageElement>('.service-icon'); + const broke = '/broken-image.png'; - serviceIcons.forEach(function(icon) { - icon.onerror = function() { - const imgElement = this as HTMLImageElement; - const serviceUrl = imgElement.getAttribute('data-service-url'); - const newSrcAttribute = (imgElement.src.includes('on.ho') ? broke : `https://icon.horse/icon/${serviceUrl}`); - imgElement.src = imgElement.src !== newSrcAttribute ? newSrcAttribute : broke; - imgElement.onerror = null; - }; - }); -}); + serviceIcons.forEach(function (icon) { + icon.onerror = function () { + const imgElement = this as HTMLImageElement; + const serviceUrl = imgElement.getAttribute('data-service-url'); + const newSrcAttribute = imgElement.src.includes('on.ho') + ? broke + : `https://icon.horse/icon/${serviceUrl}`; + imgElement.src = + imgElement.src !== newSrcAttribute ? newSrcAttribute : broke; + imgElement.onerror = null; + }; + }); + }); </script> - - <div class="service" id={slugify(service.name)}> - <div class="service-head"> - <a class="service-title" href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}> - <h4>{service.name}</h4> - </a> - {service.followWith && <p class="follow-with">({service.followWith})</p> } - </div> + <div class="service-head"> + <a + class="service-title" + href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`} + > + <h4>{service.name}</h4> + </a> + {service.followWith && <p class="follow-with">({service.followWith})</p>} + </div> - <div class="save-listing"> - <SaveListing client:visible - categoryName={categoryName} - sectionName={sectionName} - serviceName={service.name} - /> - </div> + <div class="save-listing"> + <SaveListing + client:visible + categoryName={categoryName} + sectionName={sectionName} + serviceName={service.name} + /> + </div> - <div class="service-body"> - <img - width="40" - height="40" - loading="lazy" - decoding="async" - class="service-icon" - alt={`${service.name} Icon`} - data-service-url={formatLink(service.url)} - src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`} - /> - <div class="service-body"> - <p set:html={service.description}></p> - </div> - </div> + <div class="service-body"> + <img + width="40" + height="40" + loading="lazy" + decoding="async" + class="service-icon" + alt={`${service.name} Icon`} + data-service-url={formatLink(service.url)} + src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`} + /> + <div class="service-body"> + <p set:html={service.description} /> + </div> + </div> - <div class="service-links"> - <a class="link" href={service.url}> - <FontAwesome iconName="website"/> <span>{formatLink(service.url)}</span> - </a> - {service.github && - <a class="link" href={`https://github.com/${service.github}`}> - <FontAwesome iconName="sourceCode"/> GitHub - </a> - } - <a href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}> - <FontAwesome iconName="viewReport" /> View Report ➔ - </a> + <div class="service-links"> + <a class="link" href={service.url}> + <FontAwesome iconName="website" /> + <span>{formatLink(service.url)}</span> + </a> + { + service.github && ( + <a class="link" href={`https://github.com/${service.github}`}> + <FontAwesome iconName="sourceCode" /> GitHub + </a> + ) + } + <a + href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`} + > + <FontAwesome iconName="viewReport" /> View Report ➔ + </a> + </div> +</div> - </div> - </div> - - <style lang="scss"> - @import './service-card.scss'; - </style> +<style lang="scss"> + @import './service-card.scss'; +</style> diff --git a/web/src/components/things/ServiceCard.svelte b/web/src/components/things/ServiceCard.svelte index d76be99..fc8d0ec 100644 --- a/web/src/components/things/ServiceCard.svelte +++ b/web/src/components/things/ServiceCard.svelte @@ -17,7 +17,10 @@ <div class="service" id={serviceRef}> <div class="service-head"> - <a class="service-title" href={`/${categorySlug}/${sectionSlug}/${serviceRef}`}> + <a + class="service-title" + href={`/${categorySlug}/${sectionSlug}/${serviceRef}`} + > <h4>{service.name}</h4> </a> {#if service.followWith} @@ -26,11 +29,7 @@ </div> <div class="save-listing"> - <SaveListing - categoryName={categoryName} - sectionName={sectionName} - serviceName={service.name} - /> + <SaveListing {categoryName} {sectionName} serviceName={service.name} /> </div> <div class="service-body"> diff --git a/web/src/components/things/ServiceList.astro b/web/src/components/things/ServiceList.astro index db4f69b..2be6a08 100644 --- a/web/src/components/things/ServiceList.astro +++ b/web/src/components/things/ServiceList.astro @@ -1,5 +1,4 @@ --- - import Button from '@components/form/Button.astro'; import { parseMarkdown, formatLink } from '@utils/parse-markdown'; import type { Service } from 'src/types/Service'; @@ -11,326 +10,387 @@ import GitHubMetrics from '@components/things/ItemGitHubMetrics.astro'; import SaveListing from '@components/things/SaveListing.svelte'; interface Props { - services: Service[]; - subHeading?: boolean; - buttonLink?: string; - noGitHubMetrics?: boolean; - sectionName: string; - categoryName: string; + services: Service[]; + subHeading?: boolean; + buttonLink?: string; + noGitHubMetrics?: boolean; + sectionName: string; + categoryName: string; } const { - services, - subHeading, - buttonLink, - noGitHubMetrics, - sectionName, - categoryName, + services, + subHeading, + buttonLink, + noGitHubMetrics, + sectionName, + categoryName, } = Astro.props; - --- <script> - document.addEventListener('DOMContentLoaded', () => { - const serviceIcons = document.querySelectorAll<HTMLImageElement>('.service-icon'); - const broke = '/broken-image.png'; + document.addEventListener('DOMContentLoaded', () => { + const serviceIcons = + document.querySelectorAll<HTMLImageElement>('.service-icon'); + const broke = '/broken-image.png'; - serviceIcons.forEach(function(icon) { - icon.onerror = function() { - const imgElement = this as HTMLImageElement; - const serviceUrl = imgElement.getAttribute('data-service-url'); - const newSrcAttribute = (imgElement.src.includes('on.ho') ? broke : `https://icon.horse/icon/${serviceUrl}`); - imgElement.src = imgElement.src !== newSrcAttribute ? newSrcAttribute : broke; - imgElement.onerror = null; - }; - }); -}); + serviceIcons.forEach(function (icon) { + icon.onerror = function () { + const imgElement = this as HTMLImageElement; + const serviceUrl = imgElement.getAttribute('data-service-url'); + const newSrcAttribute = imgElement.src.includes('on.ho') + ? broke + : `https://icon.horse/icon/${serviceUrl}`; + imgElement.src = + imgElement.src !== newSrcAttribute ? newSrcAttribute : broke; + imgElement.onerror = null; + }; + }); + }); </script> - - <section> - {services && services.length > 0 ? ( - <ul> - {services.map((service: Service) => ( - <li id={slugify(service.name)}> - <DeleteListing client:load categoryName={categoryName} sectionName={sectionName} serviceName={service.name} /> - <div class="save-listing"> - <SaveListing client:visible - categoryName={categoryName} - sectionName={sectionName} - serviceName={service.name} - /> - </div> - <div class="service-head"> - <img - width="40" - height="40" - loading="lazy" - decoding="async" - class="service-icon" - alt={`${service.name} Icon`} - data-service-url={formatLink(service.url)} - src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`} - /> + { + services && services.length > 0 ? ( + <ul> + {services.map((service: Service) => ( + <li id={slugify(service.name)}> + <DeleteListing + client:load + categoryName={categoryName} + sectionName={sectionName} + serviceName={service.name} + /> + <div class="save-listing"> + <SaveListing + client:visible + categoryName={categoryName} + sectionName={sectionName} + serviceName={service.name} + /> + </div> + <div class="service-head"> + <img + width="40" + height="40" + loading="lazy" + decoding="async" + class="service-icon" + alt={`${service.name} Icon`} + data-service-url={formatLink(service.url)} + src={ + service.icon || + `https://icon.horse/icon/${formatLink(service.url)}` + } + /> - <a class="service-title" href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}> - {subHeading ? <h4>{service.name}</h4> : <h3>{service.name}</h3>} - </a> - {service.followWith && <p class="follow-with">({service.followWith})</p> } - <a class="service-link" href={service.url}>{formatLink(service.url)}</a> - </div> - <div class="service-body"> - <p set:html={parseMarkdown(service.description)}></p> - <div class="service-stats"> - <div class="left"> - { service.securityAudited && ( - <span class="meta-item great" title={`${service.name} has been security audited by an accredited auditor, with results published publicly`}> - <FontAwesome iconName="securityAudited" /> Security Audited - </span> - )} - { service.acceptsCrypto && ( - <span class="meta-item great" title={`${service.name} accepts anonymous payment methods`}> - <FontAwesome iconName="cryptoAccepted" /> Crypto Payments Accepted - </span> - )} - { service.securityAudited === false && ( - <span class="meta-item warning" title={`${service.name} has not been audited`}> - <FontAwesome iconName="notSecurityAudited" /> No Security Audit - </span> - )} - { (service.openSource === false) && ( - <span class="warning"> - <FontAwesome iconName="closedSource" /> - Not Open Source - </span> - )} - { service.openSource || (service.github && service.openSource !== false) ? ( - <span class="meta-item great" title={`${service.name} is open source`}> - <FontAwesome iconName="openSource" /> Open Source - </span> - ) : null } - { service.github && !noGitHubMetrics && <GitHubMetrics github={service.github} /> } - { service.github && noGitHubMetrics && ( - <span class="meta-item" title={`View ${service.name} on GitHub`}> - <a href={`https://github.com/${service.github}`} target="_blank"> - <FontAwesome iconName="github" /> {service.github} - </a> - </span> - ) } - </div> - <div class="view-service"> - <a href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>View {service.name} Report</a> - </div> - </div> - </div> - </li> - ))} - </ul> - ) : ( - <p class="nothing-yet"> - <strong>⚠️ This section is still a work in progress ⚠️</strong><br /> - Check back soon, or help us complete it by submiting a pull request on GitHub. - <br /> - <span class="quick-submit">Or submit an entry <a href="/submit">here</a></span> - </p> - )} + <a + class="service-title" + href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`} + > + {subHeading ? <h4>{service.name}</h4> : <h3>{service.name}</h3>} + </a> + {service.followWith && ( + <p class="follow-with">({service.followWith})</p> + )} + <a class="service-link" href={service.url}> + {formatLink(service.url)} + </a> + </div> + <div class="service-body"> + <p set:html={parseMarkdown(service.description)} /> + <div class="service-stats"> + <div class="left"> + {service.securityAudited && ( + <span + class="meta-item great" + title={`${service.name} has been security audited by an accredited auditor, with results published publicly`} + > + <FontAwesome iconName="securityAudited" /> Security + Audited + </span> + )} + {service.acceptsCrypto && ( + <span + class="meta-item great" + title={`${service.name} accepts anonymous payment methods`} + > + <FontAwesome iconName="cryptoAccepted" /> Crypto Payments + Accepted + </span> + )} + {service.securityAudited === false && ( + <span + class="meta-item warning" + title={`${service.name} has not been audited`} + > + <FontAwesome iconName="notSecurityAudited" /> No Security + Audit + </span> + )} + {service.openSource === false && ( + <span class="warning"> + <FontAwesome iconName="closedSource" /> + Not Open Source + </span> + )} + {service.openSource || + (service.github && service.openSource !== false) ? ( + <span + class="meta-item great" + title={`${service.name} is open source`} + > + <FontAwesome iconName="openSource" /> Open Source + </span> + ) : null} + {service.github && !noGitHubMetrics && ( + <GitHubMetrics github={service.github} /> + )} + {service.github && noGitHubMetrics && ( + <span + class="meta-item" + title={`View ${service.name} on GitHub`} + > + <a + href={`https://github.com/${service.github}`} + target="_blank" + > + <FontAwesome iconName="github" /> {service.github} + </a> + </span> + )} + </div> + <div class="view-service"> + <a + href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`} + > + View {service.name} Report + </a> + </div> + </div> + </div> + </li> + ))} + </ul> + ) : ( + <p class="nothing-yet"> + <> + <strong>⚠️ This section is still a work in progress ⚠️</strong> + <br /> + </> + Check back soon, or help us complete it by submiting a pull request on + GitHub. + <br /> + <span class="quick-submit"> + Or submit an entry <a href="/submit">here</a> + </span> + </p> + ) + } - {buttonLink && ( - <Button title={`View all ${categoryName}`} className="view-all" text="View More..." url={buttonLink} /> - )} + { + buttonLink && ( + <Button + title={`View all ${categoryName}`} + className="view-all" + text="View More..." + url={buttonLink} + /> + ) + } </section> - <style lang="scss"> + section { + padding: 1rem 0; + position: relative; + &:not(:last-child) { + border-bottom: 2px solid var(--accent-3); + } + } - section { - padding: 1rem 0; - position: relative; - &:not(:last-child) { - border-bottom: 2px solid var(--accent-3); - } - } - - .nothing-yet { - font-size: 1.4rem; - opacity: 0.8; - font-style: italic; - text-align: center; - margin-bottom: 3rem; - .quick-submit { - margin-top: 1rem; - font-size: 0.8rem; - opacity: 0.8; - } - } - - ul { - list-style: none; - padding: 0; - margin: 0 0 3rem 0; - li { - margin-bottom: 1rem; - position: relative; - .save-listing { - position: absolute; - right: 1rem; - top: 1rem; - } - .service-head { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - .service-title { - text-decoration: none; - color: var(--foreground); - h3, h4 { - margin: 0; - font-size: 1.6rem; - } + .nothing-yet { + font-size: 1.4rem; + opacity: 0.8; + font-style: italic; + text-align: center; + margin-bottom: 3rem; + .quick-submit { + margin-top: 1rem; + font-size: 0.8rem; + opacity: 0.8; + } + } - position: relative; - &:after { - background: none repeat scroll 0 0 transparent; - bottom: 0; - content: ""; - display: block; - height: 3px; - left: 50%; - position: absolute; - background: var(--accent-3); - transition: width 0.3s ease 0s, left 0.3s ease 0s; - width: 0; - } - &:hover:after { - width: 100%; - left: 0; - } - } - - .service-icon { - width: 2.5rem; - height: 2.5rem; - border-radius: var(--curve-sm); + ul { + list-style: none; + padding: 0; + margin: 0 0 3rem 0; + li { + margin-bottom: 1rem; + position: relative; + .save-listing { + position: absolute; + right: 1rem; + top: 1rem; + } + .service-head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + .service-title { + text-decoration: none; + color: var(--foreground); + h3, + h4 { + margin: 0; + font-size: 1.6rem; + } - font-size: 10px; - overflow: hidden; - color: var(--accent); - } - .follow-with { - opacity: 0.7; - font-style: italic; - margin: 0; - } - .service-link { - max-width: 300px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } - - .service-body { - margin: 0.5rem 0 2rem; - opacity: 0.8; - :global(p) { - margin: 0; - font-size: 1.2rem; - :global(a) { - color: var(--foregorund); - } - } - .service-stats { - .left { - display: flex; - gap: 1rem; - flex-wrap: wrap; - align-items: center; - } - display: flex; - gap: 1rem; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - } - .view-service { - transition: all 0.2s ease-in-out; - opacity: 0.95; - a { - padding: 0.25rem 0.6rem; - width: fit-content; - right: 1rem; - font-size: 0.9rem; - background: var(--accent-3); - color: var(--accent-fg); - text-decoration: none; - border-radius: var(--curve-md); - } - &:hover { - opacity: 1; - transform: scale(1.05); - } - } + position: relative; + &:after { + background: none repeat scroll 0 0 transparent; + bottom: 0; + content: ''; + display: block; + height: 3px; + left: 50%; + position: absolute; + background: var(--accent-3); + transition: + width 0.3s ease 0s, + left 0.3s ease 0s; + width: 0; + } + &:hover:after { + width: 100%; + left: 0; + } + } - .meta-item, .warning { - display: flex; - align-items: center; - // justify-content: center; - gap: 0.25rem; - // opacity: 0.6; - font-size: 0.9rem; - padding: 0.5rem 0; - :global(svg) { - color: var(--foreground); - width: 1.2rem; - height: 1.2rem; - } - a { - text-decoration: none; - color: var(--foreground); - display: flex; - gap: 0.25rem; - &:hover { - color: var(--accent-3); - :global(svg) { - color: var(--accent-3); - } - } - } - } - .warning { - color: var(--danger); - :global(svg) { - color: var(--danger); - } - } - .great { - color: #0fb953; // var(--success); - :global(svg) { - color: #0fb953; // var(--success); - } - } - } - } - } + .service-icon { + width: 2.5rem; + height: 2.5rem; + border-radius: var(--curve-sm); - section :global(.view-all) { - width: fit-content; - position: absolute; - right: 1rem; - margin-top: -2.5rem; - background: var(--accent-3); - } + font-size: 10px; + overflow: hidden; + color: var(--accent); + } + .follow-with { + opacity: 0.7; + font-style: italic; + margin: 0; + } + .service-link { + max-width: 300px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } - li:hover :global(.actions) { - opacity: 0.6; - :global(a):hover { - opacity: 1; - } - } - :global(.actions a):hover { - opacity: 1; - } - + .service-body { + margin: 0.5rem 0 2rem; + opacity: 0.8; + :global(p) { + margin: 0; + font-size: 1.2rem; + :global(a) { + color: var(--foregorund); + } + } + .service-stats { + .left { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: center; + } + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + } + .view-service { + transition: all 0.2s ease-in-out; + opacity: 0.95; + a { + padding: 0.25rem 0.6rem; + width: fit-content; + right: 1rem; + font-size: 0.9rem; + background: var(--accent-3); + color: var(--accent-fg); + text-decoration: none; + border-radius: var(--curve-md); + } + &:hover { + opacity: 1; + transform: scale(1.05); + } + } + + .meta-item, + .warning { + display: flex; + align-items: center; + // justify-content: center; + gap: 0.25rem; + // opacity: 0.6; + font-size: 0.9rem; + padding: 0.5rem 0; + :global(svg) { + color: var(--foreground); + width: 1.2rem; + height: 1.2rem; + } + a { + text-decoration: none; + color: var(--foreground); + display: flex; + gap: 0.25rem; + &:hover { + color: var(--accent-3); + :global(svg) { + color: var(--accent-3); + } + } + } + } + .warning { + color: var(--danger); + :global(svg) { + color: var(--danger); + } + } + .great { + color: #0fb953; // var(--success); + :global(svg) { + color: #0fb953; // var(--success); + } + } + } + } + } + + section :global(.view-all) { + width: fit-content; + position: absolute; + right: 1rem; + margin-top: -2.5rem; + background: var(--accent-3); + } + + li:hover :global(.actions) { + opacity: 0.6; + :global(a):hover { + opacity: 1; + } + } + :global(.actions a):hover { + opacity: 1; + } </style> diff --git a/web/src/components/things/SmartSuggestions.svelte b/web/src/components/things/SmartSuggestions.svelte index 8dc0609..3df98b1 100644 --- a/web/src/components/things/SmartSuggestions.svelte +++ b/web/src/components/things/SmartSuggestions.svelte @@ -1,7 +1,7 @@ <script lang="ts"> - import { onMount } from "svelte"; - import { writable } from "svelte/store"; - import type { Category, Service } from "../../types/Service"; + import { onMount } from 'svelte'; + import { writable } from 'svelte/store'; + import type { Category, Service } from '../../types/Service'; import { formatLink } from '@utils/parse-markdown'; import { slugify } from '@utils/fetch-data'; @@ -14,21 +14,23 @@ let results = writable<ServiceResult[]>([]); - const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, ''); + const normalize = (str: string) => + str.toLowerCase().replace(/[^a-z0-9]/g, ''); onMount(async () => { - const apiEndpoint = `https://awesome-privacy.as93.workers.dev/${searchTerm}`; const fetchedServices = await fetch(apiEndpoint) .then((response) => response.json()) - .then((data) => (JSON.parse(data) || []).map((servName: string) => normalize(servName))); + .then((data) => + (JSON.parse(data) || []).map((servName: string) => normalize(servName)), + ); const tmpResults: ServiceResult[] = []; categories.forEach((category) => { (category.sections || []).forEach((section) => { (section.services || []).forEach((service) => { if (fetchedServices.includes(normalize(service.name))) { - const path = `/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}` + const path = `/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`; tmpResults.push({ ...service, path }); return; } @@ -45,35 +47,36 @@ {#if $results.length > 1} <h3>Top Results</h3> {/if} - <section> - {#each $results as service (service)} - <a class="service-result" href={service.path}> - <div class="service-head"> - <img - width="40" - height="40" - loading="lazy" - decoding="async" - class="service-icon" - alt={`${service.name} Icon`} - data-service-url={formatLink(service.url)} - src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`} - /> - <div> - <h4> - {service.name} - {#if service.followWith} +<section> + {#each $results as service (service)} + <a class="service-result" href={service.path}> + <div class="service-head"> + <img + width="40" + height="40" + loading="lazy" + decoding="async" + class="service-icon" + alt={`${service.name} Icon`} + data-service-url={formatLink(service.url)} + src={service.icon || + `https://icon.horse/icon/${formatLink(service.url)}`} + /> + <div> + <h4> + {service.name} + {#if service.followWith} <p class="follow-with">({service.followWith})</p> - {/if} - </h4> - <a class="service-link" href={service.url}>{formatLink(service.url)}</a> - </div> + {/if} + </h4> + <a class="service-link" href={service.url} + >{formatLink(service.url)}</a + > </div> - </a> - {/each} - </section> - - + </div> + </a> + {/each} +</section> <style lang="scss"> h3 { diff --git a/web/src/components/things/service-card.scss b/web/src/components/things/service-card.scss index ca97b18..1d0d9cf 100644 --- a/web/src/components/things/service-card.scss +++ b/web/src/components/things/service-card.scss @@ -29,21 +29,23 @@ h4 { text-decoration: none; position: relative; - &:after { + &:after { background: none repeat scroll 0 0 transparent; bottom: 0; - content: ""; + content: ''; display: block; height: 3px; left: 50%; position: absolute; background: var(--accent-3); - transition: width 0.2s ease 0s, left 0.2s ease 0s; + transition: + width 0.2s ease 0s, + left 0.2s ease 0s; width: 0; } - &:hover:after { - width: 100%; - left: 0; + &:hover:after { + width: 100%; + left: 0; } } } @@ -66,7 +68,7 @@ :global(p) { display: -webkit-box; -webkit-line-clamp: 3; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; overflow: hidden; margin: 0.5rem 0; width: calc(100% - 2rem); diff --git a/web/src/layouts/Layout.astro b/web/src/layouts/Layout.astro index c390344..d0c585c 100644 --- a/web/src/layouts/Layout.astro +++ b/web/src/layouts/Layout.astro @@ -1,5 +1,5 @@ --- -import { ViewTransitions } from 'astro:transitions' +import { ViewTransitions } from 'astro:transitions'; import NavBar from '@components/scafold/NavBar.astro'; import Footer from '@components/scafold/Footer.astro'; import config from '../site-config'; @@ -7,14 +7,14 @@ import config from '../site-config'; interface Props { title?: string; // Page title description?: string; // Overide description tag - keywords?: string; // Overide keywords tag + keywords?: string; // Overide keywords tag hideNav?: boolean; // Don't show the navbar (just homepage) author?: string; // Author of the content customSchemaJson?: any; // Custom schema item breadcrumbs?: Array<{ name: string; item: string; - }> + }>; } const { @@ -30,92 +30,117 @@ const { const makeBreadcrumbs = () => { if (!breadcrumbs) return null; return { - "@context": "https://schema.org", - "@type": "BreadcrumbList", - "itemListElement": breadcrumbs.map((breadcrumb, index) => ({ - "@type": "ListItem", - "position": index + 1, - "name": breadcrumb.name, - "item": `https://awesome-privacy.xyz/${breadcrumb.item}` - })) - } -} + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: breadcrumbs.map((breadcrumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: breadcrumb.name, + item: `https://awesome-privacy.xyz/${breadcrumb.item}`, + })), + }; +}; const makeSearchLd = () => { return { - "@context": "https://schema.org", - "@type": "WebSite", - "url": "https://awesome-privacy.xyz/", - "potentialAction": [{ - "@type": "SearchAction", - "target": { - "@type": "EntryPoint", - "urlTemplate": "https://awesome-privacy.xyz/search?q={search_term_string}" + '@context': 'https://schema.org', + '@type': 'WebSite', + url: 'https://awesome-privacy.xyz/', + potentialAction: [ + { + '@type': 'SearchAction', + target: { + '@type': 'EntryPoint', + urlTemplate: + 'https://awesome-privacy.xyz/search?q={search_term_string}', + }, + 'query-input': 'required name=search_term_string', }, - "query-input": "required name=search_term_string" - }] - } + ], + }; }; - --- <!doctype html> <html lang="en" data-theme="dark"> <head> - <ViewTransitions /> - + <!-- Core info --> <title>{title} - - - + + + - + - + - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + src="https://no-track.as93.net/js/script.js"> - {breadcrumbs && ( - + + diff --git a/web/src/pages/api/line-numbers.json.ts b/web/src/pages/api/line-numbers.json.ts index 28ac2ef..f64d189 100644 --- a/web/src/pages/api/line-numbers.json.ts +++ b/web/src/pages/api/line-numbers.json.ts @@ -14,35 +14,47 @@ interface LineNumberData { [service: string]: { lineNumbers: LineNumberRange | null; yaml: string; - } + }; }; }; } -const awesomePrivacyYamlPath = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml'; +const awesomePrivacyYamlPath = + 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml'; /** * Given a service object and an array of string lines from the raw YAML * Find the starting and ending line number for that service */ -const calculateServiceRange = (service: Service, category: Category, yamlLines: string[]): LineNumberRange | null => { +const calculateServiceRange = ( + service: Service, + category: Category, + yamlLines: string[], +): LineNumberRange | null => { const lookFor = `- name: ${service.name}`; - const categoryStart = yamlLines.findIndex(line => line.includes(category.name)); - const start = yamlLines.slice(categoryStart).findIndex(line => line.includes(lookFor)) + categoryStart + 1; + const categoryStart = yamlLines.findIndex((line) => + line.includes(category.name), + ); + const start = + yamlLines.slice(categoryStart).findIndex((line) => line.includes(lookFor)) + + categoryStart + + 1; if (start === -1) return null; const detectEnd = (line: string) => { - return line.trim().length === 0 - || line.startsWith(' - ') - || line.includes('- name:') - || line.includes('notableMentions:') - || line.includes('furtherInfo:') - || line.includes('wordOfWarning:') - } + return ( + line.trim().length === 0 || + line.startsWith(' - ') || + line.includes('- name:') || + line.includes('notableMentions:') || + line.includes('furtherInfo:') || + line.includes('wordOfWarning:') + ); + }; const remainingLines = yamlLines.slice(start); const end = start + remainingLines.findIndex(detectEnd); - + return { start, end }; -} +}; /** * Given a service object, convert it into a correctly formatted YAML string @@ -55,7 +67,10 @@ const convertJsonIntoYaml = (service: Service): string => { * Given the object representation of the YAML and the array of lines from the raw YAML * Organize the data into a format that can be returned as JSON */ -const makeResults = (yamlObject: AwesomePrivacy, yamlLines: string[]): LineNumberData => { +const makeResults = ( + yamlObject: AwesomePrivacy, + yamlLines: string[], +): LineNumberData => { const organizedData: LineNumberData = {}; (yamlObject.categories || []).forEach((category) => { organizedData[category.name] = {}; @@ -70,16 +85,18 @@ const makeResults = (yamlObject: AwesomePrivacy, yamlLines: string[]): LineNumbe }); }); return organizedData; -} +}; export const GET: APIRoute = async () => { - // Fetch the raw YAML from the awesome-privacy repository const yamlContent = await fetch(awesomePrivacyYamlPath) - .then(response => response.text()) - .catch(error => { - return JSON.stringify({ error: "Failed to fetch YAML file", details: error }); - }); + .then((response) => response.text()) + .catch((error) => { + return JSON.stringify({ + error: 'Failed to fetch YAML file', + details: error, + }); + }); // Array of lines from the raw YAML const yamlLines: string[] = yamlContent.split('\n'); @@ -90,7 +107,7 @@ export const GET: APIRoute = async () => { // Make results const results = makeResults(yamlObject, yamlLines); - return new Response( - JSON.stringify(results), { headers: { 'content-type': 'application/json' } } - ) -} + return new Response(JSON.stringify(results), { + headers: { 'content-type': 'application/json' }, + }); +}; diff --git a/web/src/pages/browse.astro b/web/src/pages/browse.astro index 6a0d031..1549b15 100644 --- a/web/src/pages/browse.astro +++ b/web/src/pages/browse.astro @@ -1,5 +1,4 @@ --- - import Layout from '@layouts/Layout.astro'; import SectionList from '@components/things/SectionList.astro'; @@ -7,210 +6,221 @@ import { fetchData } from '@utils/fetch-data'; import type { Category } from '../types/Service'; const categories: Category[] = (await fetchData())?.categories; - --- +
+

Browse

+ + +

+ Press enter for deep search +

+
+
+ -
-

Browse

- - -

Press enter for deep search

-
-
- +
    + { + categories.map((category) => ( +
  • + +
  • + )) + } +
-
    - {categories.map((category) => ( -
  • - -
  • - ))} -
- -
-

Nothing found 😢

-

Try a deep search instead

-
+
+

Nothing found 😢

+

Try a deep search instead

+
- diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index bd29faa..ef6678f 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -1,5 +1,4 @@ --- - import Layout from '@layouts/Layout.astro'; import Hero from '@components/Hero.astro'; import Search from '@components/things/Search.svelte'; @@ -10,14 +9,14 @@ import { fetchData } from '@utils/fetch-data'; import Button from '@components/form/Button.astro'; import type { Category } from 'src/types/Service'; -const categories = (await fetchData())?.categories || [] as Category[]; - -const description = 'Privacy is a fundamental human right; ' - + 'without it, we\'re just open books in a world where everyone\'s ' - + 'watching. Let\'s take control back.\n' - + 'Migrating open-source applications which do not collect, sell or log your data is a great first step.' - + 'Awesome Privacy is a directory of alternative privacy-respecting software and services.'; +const categories = (await fetchData())?.categories || ([] as Category[]); +const description = + 'Privacy is a fundamental human right; ' + + "without it, we're just open books in a world where everyone's " + + "watching. Let's take control back.\n" + + 'Migrating open-source applications which do not collect, sell or log your data is a great first step.' + + 'Awesome Privacy is a directory of alternative privacy-respecting software and services.'; --- @@ -29,11 +28,13 @@ const description = 'Privacy is a fundamental human right; '

Browse

    - {categories.map((category) => ( -
  • - -
  • - ))} + { + categories.map((category) => ( +
  • + +
  • + )) + }
Or, just @@ -48,28 +49,33 @@ const description = 'Privacy is a fundamental human right; '

- Awesome Privacy is a collection of privacy-respecting services and tools. - The aim is to help you escape big tech, and choose software that respects your privacy. + Awesome Privacy is a collection of privacy-respecting services and + tools. The aim is to help you escape big tech, and choose software that + respects your privacy.

- Why? Because privacy is a fundamental human right; without it, we're just open books - in a world where everyone's watching. Let's take control back. + Why? Because privacy is a fundamental human right; without it, we're + just open books in a world where everyone's watching. Let's take control + back.

- Noticed something that should be added / removed / amended? - We're a community-driven resource, so welcome contributions of any nature. - All content and code is open source. + Noticed something that should be added / removed / amended? We're a + community-driven resource, so welcome contributions of any nature. All + content and code is open source.

- If you've found Awesome Privacy useful, help us out by sharing it with others, - contributing, or consider sponsoring me on GitHub. + If you've found Awesome Privacy useful, help us out by sharing it with + others, contributing, or consider sponsoring me on GitHub.

Want to learn more?
- @@ -81,8 +87,8 @@ const description = 'Privacy is a fundamental human right; ' max-width: calc(100% - 5rem); font-size: 20px; line-height: 1.6; - @media(max-width: 768px) { - padding: 0; + @media (max-width: 768px) { + padding: 0; } .view-all { text-align: center; @@ -97,41 +103,43 @@ const description = 'Privacy is a fundamental human right; ' h2 { font-size: 3rem; color: var(--accent-3); - font-family: "Lekton", sans-serif; + font-family: 'Lekton', sans-serif; text-align: center; margin: 3rem 0 1rem 0; a { text-decoration: none; color: var(--accent-3); - font-family: "Lekton", sans-serif; + font-family: 'Lekton', sans-serif; position: relative; - &:after { + &:after { background: none repeat scroll 0 0 transparent; bottom: 0; - content: ""; + content: ''; display: block; height: 3px; left: 50%; position: absolute; background: var(--accent); - transition: width 0.3s ease 0s, left 0.3s ease 0s; + transition: + width 0.3s ease 0s, + left 0.3s ease 0s; width: 0; } - &:hover:after { - width: 100%; - left: 0; + &:hover:after { + width: 100%; + left: 0; } } } .about-summary { background: var(--accent-fg); - border: 2px solid var(--box-outline); - border-radius: var(--curve-sm); - box-shadow: 4px 4px 0 var(--box-outline); - padding: 1rem; - width: 85%; - margin: 0 auto; + border: 2px solid var(--box-outline); + border-radius: var(--curve-sm); + box-shadow: 4px 4px 0 var(--box-outline); + padding: 1rem; + width: 85%; + margin: 0 auto; } .categories { @@ -148,7 +156,7 @@ const description = 'Privacy is a fundamental human right; ' display: inline-flex; flex-direction: column; margin: 1rem; - @media(max-width: 768px) { + @media (max-width: 768px) { margin: 1rem 0; width: 90%; } @@ -157,7 +165,7 @@ const description = 'Privacy is a fundamental human right; ' color: var(--foreground); } h3 { - font-family: "Lekton", sans-serif; + font-family: 'Lekton', sans-serif; font-weight: bold; margin: 0; font-size: 1.8rem; @@ -176,6 +184,4 @@ const description = 'Privacy is a fundamental human right; ' } } } - - diff --git a/web/src/pages/inventory/[...inventoryId].astro b/web/src/pages/inventory/[...inventoryId].astro index 01d261c..70fe320 100644 --- a/web/src/pages/inventory/[...inventoryId].astro +++ b/web/src/pages/inventory/[...inventoryId].astro @@ -1,5 +1,4 @@ --- - import Layout from '@layouts/Layout.astro'; import SavedServices from '@components/things/SavedServices.svelte'; import GetSharableLink from '@components/things/GetSharableLink.svelte'; @@ -9,7 +8,7 @@ import Button from '@components/form/Button.astro'; import EditableTitle from '@components/form/EditableTitle.svelte'; import type { Category } from '../../types/Service'; -const categories = (await fetchData())?.categories || [] as Category[]; +const categories = (await fetchData())?.categories || ([] as Category[]); export const prerender = false; @@ -17,85 +16,92 @@ const inventoryId = Astro.params.inventoryId || 'Inventory'; let cheekyLilError = ''; function makeTitle(input: string): string { - return (input.includes('_') ? input : `mystry_${input}`) - .split('_')[1] - .replace(/-/g, ' ') - .replace(/\b\w/g, (match) => match.toUpperCase()); + return (input.includes('_') ? input : `mystry_${input}`) + .split('_')[1] + .replace(/-/g, ' ') + .replace(/\b\w/g, (match) => match.toUpperCase()); } -const serviceList = await fetch(`https://awesome-privacy-share-api.as93.net/${inventoryId}`).then((res) => res.json()) || []; +const serviceList = + (await fetch( + `https://awesome-privacy-share-api.as93.net/${inventoryId}`, + ).then((res) => res.json())) || []; if (serviceList.error) { - cheekyLilError = serviceList.error; + cheekyLilError = serviceList.error; } - --- -
-

{makeTitle(inventoryId)}

- {cheekyLilError && ( -
-

An error occoured

-

{cheekyLilError}

-

- We're sorry about that.
- Try going back home, - or raising a ticket on - GitHub. -

-
- )} - -
-

Not found what you're looking for?

- -
-
+
+

{makeTitle(inventoryId)}

+ { + cheekyLilError && ( +
+

An error occoured

+

{cheekyLilError}

+

+ We're sorry about that. +
+ Try going back home, or{' '} + + raising a ticket + {' '} + on GitHub. +

+
+ ) + } + +
+

Not found what you're looking for?

+ +
+
diff --git a/web/src/pages/inventory/index.astro b/web/src/pages/inventory/index.astro index 2bb8f9a..196d870 100644 --- a/web/src/pages/inventory/index.astro +++ b/web/src/pages/inventory/index.astro @@ -1,5 +1,4 @@ --- - import Layout from '@layouts/Layout.astro'; import SavedServices from '@components/things/SavedServices.svelte'; import GetSharableLink from '@components/things/GetSharableLink.svelte'; @@ -9,53 +8,52 @@ import Button from '@components/form/Button.astro'; import EditableTitle from '@components/form/EditableTitle.svelte'; import type { Category } from '../../types/Service'; -const categories = (await fetchData())?.categories || [] as Category[]; - +const categories = (await fetchData())?.categories || ([] as Category[]); --- -
-
- - - -
- -
-

Not found what you're looking for?

- -
-
+
+
+ + + +
+ +
+

Not found what you're looking for?

+ +
+
diff --git a/web/src/pages/search/[...searchTerm].astro b/web/src/pages/search/[...searchTerm].astro index 193e40f..e741121 100644 --- a/web/src/pages/search/[...searchTerm].astro +++ b/web/src/pages/search/[...searchTerm].astro @@ -1,5 +1,5 @@ --- - import Fuse from 'fuse.js'; +import Fuse from 'fuse.js'; import Layout from '@layouts/Layout.astro'; import { fetchData, slugify } from '@utils/fetch-data'; @@ -22,171 +22,178 @@ fuse = new Fuse(items, searchOptions); const searchTerm = Astro.params.searchTerm; -const searchResults = fuse.search(searchTerm || '').map(result => result.item); +const searchResults = fuse + .search(searchTerm || '') + .map((result) => result.item); -const services = searchResults.filter(result => result.type === 'Service'); +const services = searchResults.filter((result) => result.type === 'Service'); const putResultsIntoGroups = () => { - const grouped = services.reduce((acc, item) => { - const { category: categoryName, sectionName, ...service } = item; + const grouped = services.reduce((acc, item) => { + const { category: categoryName, sectionName, ...service } = item; - if (!acc[categoryName]) { - acc[categoryName] = { categoryName, sections: {} }; - } + if (!acc[categoryName]) { + acc[categoryName] = { categoryName, sections: {} }; + } - if (!acc[categoryName].sections[sectionName]) { - acc[categoryName].sections[sectionName] = { sectionName, items: [] }; - } + if (!acc[categoryName].sections[sectionName]) { + acc[categoryName].sections[sectionName] = { sectionName, items: [] }; + } - acc[categoryName].sections[sectionName].items.push(service); + acc[categoryName].sections[sectionName].items.push(service); - return acc; - }, {}); + return acc; + }, {}); - // Convert the grouped object into the desired array structure. - // And fuck it, let's use `any` - return Object.values(grouped).map((category: any) => ({ - categoryName: category.categoryName, - sections: Object.values(category.sections) - })); + // Convert the grouped object into the desired array structure. + // And fuck it, let's use `any` + return Object.values(grouped).map((category: any) => ({ + categoryName: category.categoryName, + sections: Object.values(category.sections), + })); }; const beer = putResultsIntoGroups(); - --- -
-

Search

- -
- -
-

Deep Search

-

Showing {services.length} results for "{searchTerm}" sorted by relevence

-
-
- { - beer.map((category: any) => ( -
- -

{category.categoryName}

-
- -
    - {category.sections.map((section: any) => ( -
  • -

    {section.sectionName}

    -
      - {section.items.map((item: Service) => ( -
    • - {item.name} -
    • - ))} -
    -
  • - ))} -
-
- )) - } -
+
+

Search

+ +
+ +
+

Deep Search

+

+ Showing {services.length} results for "{searchTerm}" sorted by relevence +

+
+
+ { + beer.map((category: any) => ( +
+ +

{category.categoryName}

+
+ + + +
    + {category.sections.map((section: any) => ( +
  • +

    {section.sectionName}

    +
      + {section.items.map((item: Service) => ( +
    • + {item.name} +
    • + ))} +
    +
  • + ))} +
+
+ )) + } +
- diff --git a/web/src/pages/search/index.astro b/web/src/pages/search/index.astro index 924b188..da86b36 100644 --- a/web/src/pages/search/index.astro +++ b/web/src/pages/search/index.astro @@ -1,57 +1,55 @@ --- - import Layout from '@layouts/Layout.astro'; import { fetchData } from '@utils/fetch-data'; import Search from '@components/things/Search.svelte'; const categories = (await fetchData())?.categories; - --- -
-

Search

- - -
+
+

Search

+ + +
- diff --git a/web/src/pages/sitemap.astro b/web/src/pages/sitemap.astro index 60c3d11..41024b0 100644 --- a/web/src/pages/sitemap.astro +++ b/web/src/pages/sitemap.astro @@ -1,234 +1,244 @@ --- - - import Layout from '@layouts/Layout.astro'; import type { AwesomePrivacy } from '../types/Service'; import { fetchData, slugify } from '@utils/fetch-data'; -const categories = (await fetchData() as AwesomePrivacy)?.categories || []; - +const categories = ((await fetchData()) as AwesomePrivacy)?.categories || []; --- -
-

Sitemap

-

- Below is a full listing of all pages on this site.
- As reflected in our sitemap.xml -

- - -

Press enter for deep search

-
- -
- +
+

Sitemap

+

+ Below is a full listing of all pages on this site.
+ As reflected in our sitemap.xml +

+ + +

+ Press enter for deep search +

+
+ +
diff --git a/web/src/pages/submit.astro b/web/src/pages/submit.astro index abae8a3..b6eff6f 100644 --- a/web/src/pages/submit.astro +++ b/web/src/pages/submit.astro @@ -2,142 +2,187 @@ import Layout from '@layouts/Layout.astro'; import AddNewService from '@components/things/AddNewService.svelte'; -import { fetchGitHubStats } from '@utils/fetch-repo-info' +import { fetchGitHubStats } from '@utils/fetch-repo-info'; import { formatDate } from '@utils/dates-n-stuff'; -const commits = (await fetchGitHubStats('lissy93/awesome-privacy') || {}).commits; - +const commits = ((await fetchGitHubStats('lissy93/awesome-privacy')) || {}) + .commits; --- -
-

About our Data

-

- All data on Awesome Privacy is community maintained via Git, - this keeps everything transparent, and means anyone can submit edits. - You can learn more about how our data is managed on our about page. -

- You can make ammendments/additions/removals simply by editing the - awesome-privacy.yml file. -
- Before you proceed, please first read our Contributing Docs -

- Awesome Privacy is a community-maintained resource, it's thanks to - contributors like you, that it's able to grow and stay up to date 💜 -

-
-
-

Submit an Addition

- -
+
+

About our Data

+

+ All data on Awesome Privacy is community maintained via Git, this keeps + everything transparent, and means anyone can submit edits. You can learn + more about how our data is managed on our about page. +

+ You can make ammendments/additions/removals simply by editing the + awesome-privacy.yml file. +
+ Before you proceed, please first read our Contributing Docs +

+ Awesome Privacy is a community-maintained resource, it's thanks to contributors + like you, that it's able to grow and stay up to date 💜 +

+
+
+

Submit an Addition

+ +
-
-

Submit a Removal Request

-

- You can submit a removal request by browsing to a given service's page, - and clicking the "Request Removal" button. - This will open a form where you can justify your reasoning, to get it - deleted from the awesome-privacy.yml file. -

-
+
+

Submit a Removal Request

+

+ You can submit a removal request by browsing to a given service's page, + and clicking the "Request Removal" button. This will open a form where you + can justify your reasoning, to get it deleted from the awesome-privacy.yml file. +

+
-
-

Edit a Listing

-

- Edits are welcome! All data is located in - awesome-privacy.yml. -
- To modify an entry, navigate to it's page, scroll to the bottom, and click "Edit". - This will take you to directly to the relevant lines in the file, where you can make your changes. -

-
+
+

Edit a Listing

+

+ Edits are welcome! All data is located in + awesome-privacy.yml. +
+ To modify an entry, navigate to it's page, scroll to the bottom, and click "Edit". + This will take you to directly to the relevant lines in the file, where you + can make your changes. +

+
-
-

Checklist

-
    -
  • You must read the Contributing guidelines before proceeding
  • -
  • All listing must meed our Criteria to be considered privacy-respecting
  • -
  • Double check that your changes haven't already been proposed
  • -
  • If you're associated with a service included, you must declare your affiliation
  • -
  • Before commiting changes, ensure the YAML syntax is valid and it complies with our schema
  • -
  • Please complete the issue or PR description template in full, do not remove any fields
  • -
  • All submissions must be made via our GitHub, do not email/PM maintainers
  • -
-
+
+

Checklist

+
    +
  • + You must read the Contributing guidelines before proceeding +
  • +
  • + All listing must meed our Criteria to be considered + privacy-respecting +
  • +
  • Double check that your changes haven't already been proposed
  • +
  • + If you're associated with a service included, you must declare your + affiliation +
  • +
  • + Before commiting changes, ensure the YAML syntax is valid and it + complies with our schema +
  • +
  • + Please complete the issue or PR description template in full, do not + remove any fields +
  • +
  • + All submissions must be made via our GitHub, do not email/PM maintainers +
  • +
+
- {commits && commits.length > 0 && ( -
-

Recent Changes

-

- You can view a full ledger of all updates made - at github.com/lissy93/awesome-privacy -

- -
- )} - + { + commits && commits.length > 0 && ( +
+

Recent Changes

+

+ You can view a full ledger of all updates made at{' '} + + github.com/lissy93/awesome-privacy + +

+ +
+ ) + }
diff --git a/web/src/site-config.ts b/web/src/site-config.ts index 66fe4f0..1c9a440 100644 --- a/web/src/site-config.ts +++ b/web/src/site-config.ts @@ -31,13 +31,15 @@ export const authorProjects = [ }, { title: 'AdGuardian', - description: 'CLI tool for monitoring your networks traffic and AdGuard DNS stats', + description: + 'CLI tool for monitoring your networks traffic and AdGuard DNS stats', icon: 'https://adguardian.as93.net/favicon.png', link: 'https://github.com/lissy93/adguardian-term', }, { title: 'Bug-Bounties', - description: 'Database of websites which accept responsible vulnerability disclosure', + description: + 'Database of websites which accept responsible vulnerability disclosure', icon: 'https://bug-bounties.as93.net/favicon.png', link: 'https://github.com/lissy93/bug-bounties', }, @@ -82,7 +84,6 @@ export const authorSocials = [ }, ]; - export const aboutOurData = ` All data is stored in [\`awesome-privacy.yml\`](https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml). @@ -167,18 +168,20 @@ by the community, and the drawbacks / anti-features must be clearly listed along Usually these entries go within the "Notable Mentions" section instead._ `; -export const appDescription = 'Privacy is a fundamental human right; ' - + 'without it, we\'re just open books in a world where everyone\'s ' - + 'watching. Let\'s take control back.\n' - + 'Migrating open-source applications which do not collect, sell or log your data is a great first step.' - + 'Awesome Privacy is a directory of alternative privacy-respecting software and services.'; - +export const appDescription = + 'Privacy is a fundamental human right; ' + + "without it, we're just open books in a world where everyone's " + + "watching. Let's take control back.\n" + + 'Migrating open-source applications which do not collect, sell or log your data is a great first step.' + + 'Awesome Privacy is a directory of alternative privacy-respecting software and services.'; export default { title: 'Awesome Privacy | The Ultimate List of Private Apps', - description: 'Your guide to finding privacy-respecting alternatives to popular software and services.', - keywords: 'security, privacy, awesome privacy, data collection, free software, open source, privacy tools, privacy respecting software', - author: 'Alicia Sykes', + description: + 'Your guide to finding privacy-respecting alternatives to popular software and services.', + keywords: + 'security, privacy, awesome privacy, data collection, free software, open source, privacy tools, privacy respecting software', + author: 'Alicia Sykes', authorProjects, authorSocials, aboutOurData, diff --git a/web/src/styles/typography.css b/web/src/styles/typography.css index e6d2b80..43eb4ef 100644 --- a/web/src/styles/typography.css +++ b/web/src/styles/typography.css @@ -3,91 +3,123 @@ /* Rubik Font Faces */ @font-face { - font-family: 'Rubik'; - font-style: normal; - font-weight: 400; - src: local('Rubik'), url('/fonts/Rubik/Rubik-Regular.ttf') format('truetype'); + font-family: 'Rubik'; + font-style: normal; + font-weight: 400; + src: + local('Rubik'), + url('/fonts/Rubik/Rubik-Regular.ttf') format('truetype'); } @font-face { - font-family: 'Rubik'; - font-style: italic; - font-weight: 400; - src: local('Rubik Italic'), url('/fonts/Rubik/Rubik-Italic.ttf') format('truetype'); + font-family: 'Rubik'; + font-style: italic; + font-weight: 400; + src: + local('Rubik Italic'), + url('/fonts/Rubik/Rubik-Italic.ttf') format('truetype'); } @font-face { - font-family: 'Rubik'; - font-style: normal; - font-weight: 500; - src: local('Rubik Medium'), url('/fonts/Rubik/Rubik-Medium.ttf') format('truetype'); + font-family: 'Rubik'; + font-style: normal; + font-weight: 500; + src: + local('Rubik Medium'), + url('/fonts/Rubik/Rubik-Medium.ttf') format('truetype'); } @font-face { - font-family: 'Rubik'; - font-style: italic; - font-weight: 500; - src: local('Rubik Medium Italic'), url('/fonts/Rubik/Rubik-MediumItalic.ttf') format('truetype'); + font-family: 'Rubik'; + font-style: italic; + font-weight: 500; + src: + local('Rubik Medium Italic'), + url('/fonts/Rubik/Rubik-MediumItalic.ttf') format('truetype'); } @font-face { - font-family: 'Rubik'; - font-style: normal; - font-weight: 600; - src: local('Rubik SemiBold'), url('/fonts/Rubik/Rubik-SemiBold.ttf') format('truetype'); + font-family: 'Rubik'; + font-style: normal; + font-weight: 600; + src: + local('Rubik SemiBold'), + url('/fonts/Rubik/Rubik-SemiBold.ttf') format('truetype'); } @font-face { - font-family: 'Rubik'; - font-style: italic; - font-weight: 600; - src: local('Rubik SemiBold Italic'), url('/fonts/Rubik/Rubik-SemiBoldItalic.ttf') format('truetype'); + font-family: 'Rubik'; + font-style: italic; + font-weight: 600; + src: + local('Rubik SemiBold Italic'), + url('/fonts/Rubik/Rubik-SemiBoldItalic.ttf') format('truetype'); } /* Libre Franklin Font Faces */ @font-face { - font-family: 'Libre Franklin'; - font-style: normal; - font-weight: 500; - src: local('Libre Franklin Bold'), url('/fonts/Libre_Franklin/LibreFranklin-Bold.ttf') format('truetype'); + font-family: 'Libre Franklin'; + font-style: normal; + font-weight: 500; + src: + local('Libre Franklin Bold'), + url('/fonts/Libre_Franklin/LibreFranklin-Bold.ttf') format('truetype'); } /* Lekton Font Faces */ @font-face { - font-family: 'Lekton'; - font-style: normal; - font-weight: 700; - src: local('Lekton Bold'), url('/fonts/Lekton/Lekton-Bold.ttf') format('truetype'); + font-family: 'Lekton'; + font-style: normal; + font-weight: 700; + src: + local('Lekton Bold'), + url('/fonts/Lekton/Lekton-Bold.ttf') format('truetype'); } html { - font-family: system-ui, sans-serif; + font-family: system-ui, sans-serif; } code { - font-family: - Menlo, - Monaco, - Lucida Console, - Liberation Mono, - DejaVu Sans Mono, - Bitstream Vera Sans Mono, - Courier New, - monospace; + font-family: + Menlo, + Monaco, + Lucida Console, + Liberation Mono, + DejaVu Sans Mono, + Bitstream Vera Sans Mono, + Courier New, + monospace; } -.heading, h1 { - font-family: "Libre Franklin", sans-serif; - font-optical-sizing: auto; - font-weight: 800; - font-style: normal; +.heading, +h1 { + font-family: 'Libre Franklin', sans-serif; + font-optical-sizing: auto; + font-weight: 800; + font-style: normal; } -.subtitle, h2, h3, h4, h5, h6 { - font-family: "Lekton", sans-serif; - font-weight: 700; - font-style: normal; +.subtitle, +h2, +h3, +h4, +h5, +h6 { + font-family: 'Lekton', sans-serif; + font-weight: 700; + font-style: normal; } -html, body, p, a, ul, ol, li, blockquote, pre, strong, i { - font-family: "Rubik", sans-serif; +html, +body, +p, +a, +ul, +ol, +li, +blockquote, +pre, +strong, +i { + font-family: 'Rubik', sans-serif; } a { - color: var(--accent); + color: var(--accent); } diff --git a/web/src/styles/values.css b/web/src/styles/values.css index 7dca931..eaf4c67 100644 --- a/web/src/styles/values.css +++ b/web/src/styles/values.css @@ -1,53 +1,51 @@ +html { + --accent: #f45397; + --accent-fg: #1e1f21; - html { - --accent: #f45397; - --accent-fg: #1e1f21; + --accent-2: #ffdf60; + --accent-3: #5f53f4; + --accent-4: #28dffd; - --accent-2: #ffdf60; - --accent-3: #5f53f4; - --accent-4: #28dffd; + --foreground: #fff; - --foreground: #fff; + --curve-sm: 4px; + --curve-md: 6px; + --curve-lg: 12px; - --curve-sm: 4px; - --curve-md: 6px; - --curve-lg: 12px; + --danger: #ff0048; + --success: #00ff64; - --danger: #ff0048; - --success: #00ff64; + --transparent-accent: #5f53f482; - --transparent-accent: #5f53f482; + --background: #151517; + --bg-gradient-comp-1: #151517; + --bg-gradient-comp-2: #151517; + --background-form: #19191c; - --background: #151517; - --bg-gradient-comp-1: #151517; - --bg-gradient-comp-2: #151517; - --background-form: #19191c; + --box-outline: #000; - --box-outline: #000; + &[data-theme='light'] { + --accent: #f45397; + --accent-fg: #fff; - &[data-theme='light'] { - --accent: #f45397; - --accent-fg: #fff; - - --accent-2: #ffdf60; - --accent-3: #5f53f4; - --accent-4: #28dffd; - - --foreground: #13151a; - - --curve-sm: 4px; - --curve-md: 6px; - --curve-lg: 12px; - - --danger: #ff0048; - --success: #00ff64; - - --transparent-accent: #5f53f482; - - --background: #feecff; - --bg-gradient-comp-1: #feecff; - --bg-gradient-comp-2: #e1e4fb; - --background-form: #fff; - } + --accent-2: #ffdf60; + --accent-3: #5f53f4; + --accent-4: #28dffd; + + --foreground: #13151a; + + --curve-sm: 4px; + --curve-md: 6px; + --curve-lg: 12px; + + --danger: #ff0048; + --success: #00ff64; + + --transparent-accent: #5f53f482; + + --background: #feecff; + --bg-gradient-comp-1: #feecff; + --bg-gradient-comp-2: #e1e4fb; + --background-form: #fff; + } } - diff --git a/web/src/types/Service.ts b/web/src/types/Service.ts index f509e1e..4a3d00a 100644 --- a/web/src/types/Service.ts +++ b/web/src/types/Service.ts @@ -1,5 +1,3 @@ - - export interface ShortService { name: string; description: string; @@ -42,7 +40,6 @@ export interface Category { sections: Section[]; } - export interface AwesomePrivacy { categories: Array<{ name: string; diff --git a/web/src/utils/config.ts b/web/src/utils/config.ts index f2baad3..3d57197 100644 --- a/web/src/utils/config.ts +++ b/web/src/utils/config.ts @@ -1,11 +1,13 @@ - function cleanUrl(inputString: string) { return inputString.replace(/['";]+/g, '').trim(); } +export const site = cleanUrl( + import.meta.env.SITE_URL || 'https://awesome-privacy.xyz', +); -export const site = cleanUrl(import.meta.env.SITE_URL || 'https://awesome-privacy.xyz'); +export const title = + 'Awesome Privacy | Compare privacy-respecting alternatives to popular software & services'; -export const title = 'Awesome Privacy | Compare privacy-respecting alternatives to popular software & services'; - -export const description = 'Your guide to escaping big tech, protecting your privacy, and reclaiming your digital life.'; +export const description = + 'Your guide to escaping big tech, protecting your privacy, and reclaiming your digital life.'; diff --git a/web/src/utils/data-src-delete-n-edit.ts b/web/src/utils/data-src-delete-n-edit.ts index 00b31f9..e289329 100644 --- a/web/src/utils/data-src-delete-n-edit.ts +++ b/web/src/utils/data-src-delete-n-edit.ts @@ -1,60 +1,82 @@ import { slugify } from '@utils/fetch-data'; -export const makeRemovalRequest = (categoryName: string, sectionName: string, serviceName: string, yaml?: string) => { +export const makeRemovalRequest = ( + categoryName: string, + sectionName: string, + serviceName: string, + yaml?: string, +) => { const title = `[REMOVAL] ${serviceName}`; - const under = `**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}` - + `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`; - const removalData = `&title=${encodeURIComponent(title)}&removal-data=` - + `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`; - const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new' - const baseOptions = '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' - + 'Review&projects=&template=removal.yml' + const under = + `**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}` + + `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`; + const removalData = + `&title=${encodeURIComponent(title)}&removal-data=` + + `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`; + const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new'; + const baseOptions = + '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' + + 'Review&projects=&template=removal.yml'; return `${issueCreate}${baseOptions}${removalData}`; }; -export const makeEditRequest = (categoryName: string, sectionName: string, serviceName: string, yaml?: string) => { +export const makeEditRequest = ( + categoryName: string, + sectionName: string, + serviceName: string, + yaml?: string, +) => { const title = `[AMENDMENT] ${serviceName}`; - const under = `**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}` - + `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`; - const removalData = `&title=${encodeURIComponent(title)}&amendment-data=` - + `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`; - const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new' - const baseOptions = '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' - + 'Review&projects=&template=amendment.yml' + const under = + `**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}` + + `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`; + const removalData = + `&title=${encodeURIComponent(title)}&amendment-data=` + + `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`; + const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new'; + const baseOptions = + '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' + + 'Review&projects=&template=amendment.yml'; return `${issueCreate}${baseOptions}${removalData}`; }; -export const makeAdditionRequest = (formData: { - listingCategory: string; - serviceName: string; - serviceUrl: string; - serviceIcon: string; - serviceDescription: string; - serviceGithub: string; - serviceTosdrId: string; - serviceIosApp: string, - serviceAndroidApp: string, - serviceDiscordInvite: string, - serviceSubreddit: string, - serviceOpenSource: boolean; - serviceSecurityAudited: boolean; - serviceCrypto: boolean; - additionalInfo: string; -}, yamlText?: string) => { - - const userInfo = formData.additionalInfo.split('\n').map(line => `> ${line}`).join('\n'); - const additionalInfoText: string = `\n${userInfo}` - + `\n\n**YAML Content for Addition**\n\n\`\`\`yaml\n${yamlText || '# nothing yet'}\n\`\`\`\n` - + `\n\nThis ticket was submitted via ` - + `awesome-privacy.xyz/submit`; +export const makeAdditionRequest = ( + formData: { + listingCategory: string; + serviceName: string; + serviceUrl: string; + serviceIcon: string; + serviceDescription: string; + serviceGithub: string; + serviceTosdrId: string; + serviceIosApp: string; + serviceAndroidApp: string; + serviceDiscordInvite: string; + serviceSubreddit: string; + serviceOpenSource: boolean; + serviceSecurityAudited: boolean; + serviceCrypto: boolean; + additionalInfo: string; + }, + yamlText?: string, +) => { + const userInfo = formData.additionalInfo + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + const additionalInfoText: string = + `\n${userInfo}` + + `\n\n**YAML Content for Addition**\n\n\`\`\`yaml\n${yamlText || '# nothing yet'}\n\`\`\`\n` + + `\n\nThis ticket was submitted via ` + + `awesome-privacy.xyz/submit`; const issueTitle = `[ADDITION] ${formData.serviceName} (Complete)`; const queryParams = new URLSearchParams({ - 'assignees': 'lissy93,liss-bot', - 'labels': '', - 'projects': '', - 'template': 'complete-addition.yml', - 'title': issueTitle, + assignees: 'lissy93,liss-bot', + labels: '', + projects: '', + template: 'complete-addition.yml', + title: issueTitle, 'listing-category': formData.listingCategory, 'service-name': formData.serviceName, 'service-url': formData.serviceUrl, @@ -63,38 +85,59 @@ export const makeAdditionRequest = (formData: { 'service-github': formData.serviceGithub, 'service-tosdr-id': formData.serviceTosdrId, 'service-opensource': formData.serviceOpenSource ? 'true' : 'false', - 'service-security-audited': formData.serviceSecurityAudited ? 'true' : 'false', + 'service-security-audited': formData.serviceSecurityAudited + ? 'true' + : 'false', 'service-crypto': formData.serviceCrypto ? 'true' : 'false', 'additional-info': additionalInfoText, }); - const issueCreateUrl = 'https://github.com/Lissy93/awesome-privacy/issues/new'; + const issueCreateUrl = + 'https://github.com/Lissy93/awesome-privacy/issues/new'; return `${issueCreateUrl}?${queryParams.toString()}`; }; - -export const makeSourceYamlLink = async (categoryName: string, sectionName: string, serviceName: string) => { +export const makeSourceYamlLink = async ( + categoryName: string, + sectionName: string, + serviceName: string, +) => { const sourceData = await fetchSrcData(categoryName, sectionName, serviceName); const lineNumbers = sourceData.lineNumbers || null; - const numberRange = lineNumbers ? `L${lineNumbers.start}-L${lineNumbers.end}` : ''; - const yamlLink = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; + const numberRange = lineNumbers + ? `L${lineNumbers.start}-L${lineNumbers.end}` + : ''; + const yamlLink = + 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml'; return `${yamlLink}${numberRange}`; }; -export const fetchSrcData = async (categoryName: string, sectionName: string, serviceName: string) => { - const lineNumberData = await fetch('/api/line-numbers.json') - .then((res) => res.json()); +export const fetchSrcData = async ( + categoryName: string, + sectionName: string, + serviceName: string, +) => { + const lineNumberData = await fetch('/api/line-numbers.json').then((res) => + res.json(), + ); - if ( lineNumberData - && lineNumberData[categoryName] - && lineNumberData[categoryName][sectionName] - && lineNumberData[categoryName][sectionName][serviceName] + if ( + lineNumberData && + lineNumberData[categoryName] && + lineNumberData[categoryName][sectionName] && + lineNumberData[categoryName][sectionName][serviceName] ) { return { - lineNumbers: lineNumberData[categoryName][sectionName][serviceName].lineNumbers, + lineNumbers: + lineNumberData[categoryName][sectionName][serviceName].lineNumbers, yamlContent: lineNumberData[categoryName][sectionName][serviceName].yaml, }; } else { - console.error('No line number data found for', categoryName, sectionName, serviceName); + console.error( + 'No line number data found for', + categoryName, + sectionName, + serviceName, + ); return { lineNumbers: [], yamlContent: '' }; } }; diff --git a/web/src/utils/dates-n-stuff.test.ts b/web/src/utils/dates-n-stuff.test.ts new file mode 100644 index 0000000..e212661 --- /dev/null +++ b/web/src/utils/dates-n-stuff.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { formatDate, timestampToDate } from './dates-n-stuff'; + +describe('formatDate', () => { + it('formats an ISO date string to en-GB short format', () => { + const result = formatDate('2024-01-15'); + expect(result).toBe('15 Jan 24'); + }); + + it('formats a different date correctly', () => { + const result = formatDate('2023-12-25'); + expect(result).toBe('25 Dec 23'); + }); + + it('handles full ISO datetime string', () => { + const result = formatDate('2024-06-01T12:00:00Z'); + expect(result).toBe('01 Jun 24'); + }); +}); + +describe('timestampToDate', () => { + it('converts a Unix timestamp (ms) to en-GB short format', () => { + // 2024-01-15T00:00:00Z = 1705276800000 + const result = timestampToDate(1705276800000); + expect(result).toBe('15 Jan 24'); + }); + + it('converts epoch 0 to 01 Jan 70', () => { + const result = timestampToDate(0); + expect(result).toBe('01 Jan 70'); + }); +}); diff --git a/web/src/utils/dates-n-stuff.ts b/web/src/utils/dates-n-stuff.ts index a89fb57..3313720 100644 --- a/web/src/utils/dates-n-stuff.ts +++ b/web/src/utils/dates-n-stuff.ts @@ -2,21 +2,22 @@ export const formatDate = (date: string): string => { return new Date(date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', - year: '2-digit' + year: '2-digit', }); -} +}; export const timestampToDate = (timestamp: number): string => { return new Date(timestamp).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', - year: '2-digit' + year: '2-digit', }); - -} +}; export const timeAgo = (dateStr: string): string => { - const seconds = Math.floor((new Date().getTime() - new Date(dateStr).getTime()) / 1000); + const seconds = Math.floor( + (new Date().getTime() - new Date(dateStr).getTime()) / 1000, + ); const intervals = { year: 31536000, month: 2592000, diff --git a/web/src/utils/do-searchy-searchy.test.ts b/web/src/utils/do-searchy-searchy.test.ts new file mode 100644 index 0000000..dfdef01 --- /dev/null +++ b/web/src/utils/do-searchy-searchy.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { prepareSearchItems } from './do-searchy-searchy'; +import type { Category } from '../types/Service'; + +const makeCategory = (overrides: Partial = {}): Category => + ({ + name: 'Test Category', + sections: [], + ...overrides, + }) as Category; + +describe('prepareSearchItems', () => { + it('returns an empty array for no categories', () => { + expect(prepareSearchItems([])).toEqual([]); + }); + + it('creates a category item', () => { + const categories = [makeCategory({ name: 'Privacy Tools' })]; + const items = prepareSearchItems(categories); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + type: 'Category', + category: 'Privacy Tools', + itemCount: 0, + }); + }); + + it('creates section items with category context', () => { + const categories = [ + makeCategory({ + name: 'Comms', + sections: [ + { name: 'Messaging', intro: 'Secure messaging apps', services: [] }, + ], + }), + ] as Category[]; + const items = prepareSearchItems(categories); + const section = items.find((i: { type: string }) => i.type === 'Section'); + expect(section).toMatchObject({ + type: 'Section', + sectionName: 'Messaging', + description: 'Secure messaging apps', + category: 'Comms', + itemCount: 0, + }); + }); + + it('creates service items with section and category context', () => { + const categories = [ + makeCategory({ + name: 'Comms', + sections: [ + { + name: 'Messaging', + services: [ + { + name: 'Signal', + description: 'Encrypted messenger', + url: 'https://signal.org', + github: 'signalapp/Signal-Android', + icon: 'signal.png', + }, + ], + }, + ], + }), + ] as Category[]; + const items = prepareSearchItems(categories); + const service = items.find((i: { type: string }) => i.type === 'Service'); + expect(service).toMatchObject({ + type: 'Service', + name: 'Signal', + description: 'Encrypted messenger', + url: 'https://signal.org', + github: 'signalapp/Signal-Android', + category: 'Comms', + sectionName: 'Messaging', + logo: 'signal.png', + }); + }); + + it('counts services across sections for category itemCount', () => { + const categories = [ + makeCategory({ + name: 'Tools', + sections: [ + { + name: 'A', + services: [ + { name: 's1', description: '', url: '' }, + { name: 's2', description: '', url: '' }, + ], + }, + { + name: 'B', + services: [{ name: 's3', description: '', url: '' }], + }, + ], + }), + ] as Category[]; + const items = prepareSearchItems(categories); + const cat = items.find((i: { type: string }) => i.type === 'Category'); + expect(cat.itemCount).toBe(3); + }); +}); diff --git a/web/src/utils/do-searchy-searchy.ts b/web/src/utils/do-searchy-searchy.ts index 053ddbc..2eec8e6 100644 --- a/web/src/utils/do-searchy-searchy.ts +++ b/web/src/utils/do-searchy-searchy.ts @@ -3,17 +3,17 @@ import type { Category } from '../types/Service'; export const prepareSearchItems = (categories: Category[]) => { const items: any = []; // Add each category - categories.forEach(category => { + categories.forEach((category) => { items.push({ type: 'Category', category: category.name, itemCount: (category.sections || []).reduce((acc, section) => { - return acc + (section.services || []).length; - }, 0), + return acc + (section.services || []).length; + }, 0), }); // Add section with category context - category.sections.forEach(section => { + category.sections.forEach((section) => { items.push({ type: 'Section', sectionName: section.name, @@ -21,9 +21,9 @@ export const prepareSearchItems = (categories: Category[]) => { category: category.name, itemCount: (section.services || []).length, }); - + // Add service with section and category context - (section.services || []).forEach(service => { + (section.services || []).forEach((service) => { items.push({ type: 'Service', name: service.name, @@ -53,6 +53,6 @@ export const searchOptions = { { name: 'description', weight: 0.1 }, { name: 'intro', weight: 0.1 }, { name: 'furtherInfo', weight: 0.1 }, - { name: 'wordOfWarning', weight: 0.1 }, + { name: 'wordOfWarning', weight: 0.1 }, ], }; diff --git a/web/src/utils/fetch-android-info.ts b/web/src/utils/fetch-android-info.ts index 522a342..8b06d33 100644 --- a/web/src/utils/fetch-android-info.ts +++ b/web/src/utils/fetch-android-info.ts @@ -1,9 +1,10 @@ - const doubleCheckPackageName = (packageStr: string) => { return packageStr.includes('id=') ? packageStr.split('id=')[1] : packageStr; -} +}; -export const fetchAndroidInfo = async (androidPackage: string): Promise => { +export const fetchAndroidInfo = async ( + androidPackage: string, +): Promise => { const endpoint = `https://android-app-privacy.as93.net/${doubleCheckPackageName(androidPackage)}`; try { return await fetch(endpoint).then((res) => res.json()); @@ -43,5 +44,3 @@ export interface AndroidInfo { trackers: Tracker[]; permissions: string[]; } - - diff --git a/web/src/utils/fetch-data.test.ts b/web/src/utils/fetch-data.test.ts new file mode 100644 index 0000000..89eee6d --- /dev/null +++ b/web/src/utils/fetch-data.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { slugify } from './fetch-data'; + +describe('slugify', () => { + it('lowercases and replaces spaces with hyphens', () => { + expect(slugify('Hello World')).toBe('hello-world'); + }); + + it('replaces & with "and"', () => { + expect(slugify('Privacy & Security')).toBe('privacy-and-security'); + }); + + it('replaces + with "and"', () => { + expect(slugify('Tools + Tips')).toBe('tools-and-tips'); + }); + + it('removes question marks', () => { + expect(slugify('What is Privacy?')).toBe('what-is-privacy'); + }); + + it('handles multiple spaces', () => { + expect(slugify('a b c')).toBe('a--b---c'); + }); + + it('returns empty string for empty input', () => { + expect(slugify('')).toBe(''); + }); + + it('returns empty string for undefined-like input', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(slugify(undefined as any)).toBe(''); + }); + + it('handles combined special characters', () => { + expect(slugify('Q&A + FAQ?')).toBe('qanda-and-faq'); + }); +}); diff --git a/web/src/utils/fetch-data.ts b/web/src/utils/fetch-data.ts index 6c95986..6ef3cd4 100644 --- a/web/src/utils/fetch-data.ts +++ b/web/src/utils/fetch-data.ts @@ -1,17 +1,21 @@ - import yaml from 'js-yaml'; import type { AwesomePrivacy } from '../types/Service'; -const awesomePrivacyData = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml'; +const awesomePrivacyData = + 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml'; export const fetchData = async (): Promise => { - return await fetch(awesomePrivacyData) + return (await fetch(awesomePrivacyData) .then((res) => res.text()) .then((data) => yaml.load(data)) - .catch((err) => console.error('ah crap', err)) as AwesomePrivacy; -} + .catch((err) => console.error('ah crap', err))) as AwesomePrivacy; +}; export const slugify = (title: string) => { - return (title || '').toLowerCase().replace(/\s/g, '-').replace(/\+|&/g, 'and').replaceAll('?', ''); -}; + return (title || '') + .toLowerCase() + .replace(/\s/g, '-') + .replace(/\+|&/g, 'and') + .replaceAll('?', ''); +}; diff --git a/web/src/utils/fetch-discord-info.ts b/web/src/utils/fetch-discord-info.ts index bd0c0ce..cfce3ba 100644 --- a/web/src/utils/fetch-discord-info.ts +++ b/web/src/utils/fetch-discord-info.ts @@ -1,5 +1,6 @@ - -export const fetchDiscordInfo = async (discordInvite: string): Promise => { +export const fetchDiscordInfo = async ( + discordInvite: string, +): Promise => { const endpoint = `https://discord-invite-info.as93.net/${discordInvite}`; try { return await fetch(endpoint).then((res) => res.json()); diff --git a/web/src/utils/fetch-docker-instructions.ts b/web/src/utils/fetch-docker-instructions.ts index 739bfe9..bee7688 100644 --- a/web/src/utils/fetch-docker-instructions.ts +++ b/web/src/utils/fetch-docker-instructions.ts @@ -1,6 +1,6 @@ - - -export const fetchDockerData = async (serviceName: string): Promise => { +export const fetchDockerData = async ( + serviceName: string, +): Promise => { const endpoint = `https://docker-info.as93.workers.dev/${serviceName}`; try { return await fetch(endpoint).then((res) => res.json()); diff --git a/web/src/utils/fetch-ios-info.ts b/web/src/utils/fetch-ios-info.ts index 5e1d5fa..fa445ab 100644 --- a/web/src/utils/fetch-ios-info.ts +++ b/web/src/utils/fetch-ios-info.ts @@ -1,5 +1,6 @@ - -export const fetchIosInfo = async (iosUrl: string): Promise => { +export const fetchIosInfo = async ( + iosUrl: string, +): Promise => { const endpoint = `https://ios-app-info.as93.net?appStoreUrl=${iosUrl}`; try { return await fetch(endpoint).then((res) => res.json()); diff --git a/web/src/utils/fetch-privacy-policy.ts b/web/src/utils/fetch-privacy-policy.ts index 2cff5a1..bb8db11 100644 --- a/web/src/utils/fetch-privacy-policy.ts +++ b/web/src/utils/fetch-privacy-policy.ts @@ -1,5 +1,6 @@ - -export const fetchTosdrPrivacy = async (serviceId: string): Promise => { +export const fetchTosdrPrivacy = async ( + serviceId: string, +): Promise => { const endpoint = `https://privacy-policies.as93.workers.dev/${serviceId}`; try { return await fetch(endpoint).then((res) => res.json()); diff --git a/web/src/utils/fetch-reddit-info.ts b/web/src/utils/fetch-reddit-info.ts index 810ec5a..22e18d2 100644 --- a/web/src/utils/fetch-reddit-info.ts +++ b/web/src/utils/fetch-reddit-info.ts @@ -1,5 +1,6 @@ - -export const fetchRedditInfo = async (subreddit: string): Promise => { +export const fetchRedditInfo = async ( + subreddit: string, +): Promise => { const endpoint = `https://subreddit-info.as93.net/${subreddit}`; try { return await fetch(endpoint).then((res) => res.json()); diff --git a/web/src/utils/fetch-repo-info.ts b/web/src/utils/fetch-repo-info.ts index 62a650c..b863a00 100644 --- a/web/src/utils/fetch-repo-info.ts +++ b/web/src/utils/fetch-repo-info.ts @@ -1,7 +1,6 @@ - - - -export const fetchGitHubStats = async (github: string): Promise => { +export const fetchGitHubStats = async ( + github: string, +): Promise => { const endpoint = `https://repo-info.as93.workers.dev/${github}`; try { return await fetch(endpoint).then((res) => res.json()); diff --git a/web/src/utils/fetch-website-info.ts b/web/src/utils/fetch-website-info.ts index 0f236ba..6a89df1 100644 --- a/web/src/utils/fetch-website-info.ts +++ b/web/src/utils/fetch-website-info.ts @@ -1,5 +1,6 @@ - -export const fetchWebsiteInfo = async (url: string): Promise => { +export const fetchWebsiteInfo = async ( + url: string, +): Promise => { const endpoint = `https://site-info-fetch.as93.workers.dev/?url=${url}`; try { return await fetch(endpoint).then((res) => res.json()); @@ -19,10 +20,10 @@ interface DNSRecord { interface DNSRecords { ns: { - records: DNSRecord[]; + records: DNSRecord[]; }; mx: { - records: DNSRecord[]; + records: DNSRecord[]; }; } diff --git a/web/src/utils/parse-markdown.test.ts b/web/src/utils/parse-markdown.test.ts new file mode 100644 index 0000000..64cf38a --- /dev/null +++ b/web/src/utils/parse-markdown.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { formatLink } from './parse-markdown'; + +describe('formatLink', () => { + it('strips https://', () => { + expect(formatLink('https://example.com')).toBe('example.com'); + }); + + it('strips http://', () => { + expect(formatLink('http://example.com')).toBe('example.com'); + }); + + it('strips www.', () => { + expect(formatLink('https://www.example.com')).toBe('example.com'); + }); + + it('strips trailing slash', () => { + expect(formatLink('https://example.com/')).toBe('example.com'); + }); + + it('strips multiple trailing slashes', () => { + expect(formatLink('https://example.com///')).toBe('example.com'); + }); + + it('preserves path segments', () => { + expect(formatLink('https://example.com/path/to/page')).toBe( + 'example.com/path/to/page', + ); + }); + + it('handles bare domain', () => { + expect(formatLink('example.com')).toBe('example.com'); + }); + + it('handles empty string', () => { + expect(formatLink('')).toBe(''); + }); + + it('handles undefined-like input', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(formatLink(undefined as any)).toBe(''); + }); +}); diff --git a/web/src/utils/parse-markdown.ts b/web/src/utils/parse-markdown.ts index 3dffdd8..420bbb4 100644 --- a/web/src/utils/parse-markdown.ts +++ b/web/src/utils/parse-markdown.ts @@ -23,19 +23,24 @@ export const parseMarkdown = (text: string | undefined): string => { // Sanitize the input to remove