├── .build-and-release.sh ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── alfred-workflow-release.yml │ ├── markdownlint.yml │ ├── pr-title.yml │ └── stale-bot.yml ├── .gitignore ├── .markdownlint.yaml ├── .rsync-exclude ├── Justfile ├── LICENSE ├── README.md ├── hackernews.png ├── icon.png ├── info.plist └── scripts ├── browse-subreddit.js ├── determine-next-prev-subreddit.sh ├── open-and-mark-as-opened.js ├── reload-other-subreddits-in-bg.js └── select-subreddit.js /.build-and-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | #─────────────────────────────────────────────────────────────────────────────── 3 | 4 | # goto git root 5 | cd "$(git rev-parse --show-toplevel)" || return 1 6 | 7 | # Prompt for next version number 8 | current_version=$(plutil -extract version xml1 -o - info.plist | sed -n 's/.*\(.*\)<\/string>.*/\1/p') 9 | echo "current version: $current_version" 10 | echo -n " next version: " 11 | read -r next_version 12 | echo "────────────────────────" 13 | 14 | # GUARD 15 | if [[ -z "$next_version" || "$next_version" == "$current_version" ]]; then 16 | print "\033[1;31mInvalid version number.\033[0m" 17 | return 1 18 | fi 19 | 20 | # update version number in THE REPO'S `info.plist` 21 | plutil -replace version -string "$next_version" info.plist 22 | 23 | #─────────────────────────────────────────────────────────────────────────────── 24 | # INFO this assumes the local folder is named the same as the github repo 25 | # 1. update version number in LOCAL `info.plist` 26 | # 2. convenience: copy download link for current version 27 | 28 | # update version number in LOCAL `info.plist` 29 | prefs_location=$(defaults read com.runningwithcrayons.Alfred-Preferences syncfolder | sed "s|^~|$HOME|") 30 | workflow_uid="$(basename "$PWD")" 31 | local_info_plist="$prefs_location/Alfred.alfredpreferences/workflows/$workflow_uid/info.plist" 32 | if [[ -f "$local_info_plist" ]] ; then 33 | plutil -replace version -string "$next_version" "$local_info_plist" 34 | else 35 | print "\033[1;33mCould not increment version, local \`info.plist\` not found: '$local_info_plist'\033[0m" 36 | return 1 37 | fi 38 | 39 | # copy download link for current version 40 | msg="Available in the Alfred Gallery in 1-2 days, or directly by downloading the latest release here:" 41 | github_user=$(git remote --verbose | head -n1 | sed -E 's/.*github.com[:\](.*)\/.*/\1/') 42 | url="https://github.com/$github_user/$workflow_uid/releases/download/$next_version/${workflow_uid}.alfredworkflow" 43 | echo -n "$msg $url" | pbcopy 44 | 45 | #─────────────────────────────────────────────────────────────────────────────── 46 | 47 | # commit and push 48 | git add --all && 49 | git commit -m "release: $next_version" && 50 | git pull --no-progress && 51 | git push --no-progress && 52 | git tag "$next_version" && # pushing a tag triggers the github release action 53 | git push --no-progress origin --tags 54 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # vim: filetype=editorconfig 2 | root = true 3 | 4 | [*] 5 | max_line_length = 100 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | indent_style = tab 10 | indent_size = 3 11 | tab_width = 3 12 | trim_trailing_whitespace = true 13 | 14 | [*.{yml,yaml,scm,cff}] 15 | indent_style = space 16 | indent_size = 2 17 | tab_width = 2 18 | 19 | [*.py] 20 | indent_style = space 21 | indent_size = 4 22 | tab_width = 4 23 | 24 | [*.md] 25 | indent_size = 4 26 | tab_width = 4 27 | trim_trailing_whitespace = false 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/displaying-a-sponsor-button-in-your-repository 2 | 3 | custom: https://www.paypal.me/ChrisGrieser 4 | ko_fi: pseudometa 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | attributes: 9 | label: Bug Description 10 | description: A clear and concise description of the bug. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain your problem. 18 | - type: textarea 19 | id: reproduction-steps 20 | attributes: 21 | label: To Reproduce 22 | description: Steps to reproduce the problem 23 | placeholder: | 24 | For example: 25 | 1. Go to '...' 26 | 2. Click on '...' 27 | 3. Scroll down to '...' 28 | - type: textarea 29 | id: debugging-log 30 | attributes: 31 | label: Debugging Log 32 | description: "You can get a debugging log by opening the workflow in Alfred preferences and pressing `⌘ + D`. A small window will open up which will log everything happening during the execution of the Workflow. Use the malfunctioning part of the workflow once more, copy the content of the log window, and paste it here. If the debugging log is long, please attach it as file instead of pasting everything in here." 33 | render: Text 34 | validations: 35 | required: true 36 | - type: textarea 37 | id: workflow-configuration 38 | attributes: 39 | label: Workflow Configuration 40 | description: "Please add a screenshot of your [workflow configuration](https://www.alfredapp.com/help/workflows/user-configuration/)." 41 | validations: 42 | required: true 43 | - type: checkboxes 44 | id: checklist 45 | attributes: 46 | label: Checklist 47 | options: 48 | - label: I have [updated to the latest version](https://github.com/chrisgrieser/alfred-reddit-browser/releases/latest) of this workflow. 49 | required: true 50 | - label: I am using Alfred 5. (Older versions are not supported anymore.) 51 | required: true 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: "Feature Request: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: feature-requested 8 | attributes: 9 | label: Feature Requested 10 | description: A clear and concise description of the feature. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain the request. 18 | - type: checkboxes 19 | id: checklist 20 | attributes: 21 | label: Checklist 22 | options: 23 | - label: The feature would be useful to more users than just me. 24 | required: true 25 | 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore(dependabot): " 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What problem does this PR solve? 2 | 3 | ## How does the PR solve it? 4 | 5 | ## Checklist 6 | - [ ] Used only `camelCase` variable names. 7 | - [ ] If functionality is added or modified, also made respective changes to the 8 | `README.md` and the internal workflow documentation. 9 | -------------------------------------------------------------------------------- /.github/workflows/alfred-workflow-release.yml: -------------------------------------------------------------------------------- 1 | name: Alfred Workflow Release 2 | 3 | on: 4 | push: 5 | tags: ["*"] 6 | 7 | env: 8 | WORKFLOW_NAME: ${{ github.event.repository.name }} 9 | 10 | #─────────────────────────────────────────────────────────────────────────────── 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | permissions: { contents: write } 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Build .alfredworkflow 21 | run: | 22 | zip --recurse-paths --symlinks "${{ env.WORKFLOW_NAME }}.alfredworkflow" . \ 23 | --exclude "README.md" ".git*" "Justfile" ".build-and-release.sh" \ 24 | ".rsync-exclude" ".editorconfig" ".typos.toml" ".markdownlint.*" 25 | 26 | - name: Create release notes 27 | id: release_notes 28 | uses: mikepenz/release-changelog-builder-action@v5 29 | env: 30 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 31 | with: 32 | mode: "COMMIT" 33 | configurationJson: | 34 | { 35 | "label_extractor": [{ 36 | "pattern": "^(\\w+)(\\([\\w\\-\\.]+\\))?(!)?: .+", 37 | "on_property": "title", 38 | "target": "$1" 39 | }], 40 | "categories": [ 41 | { "title": "## ⚠️ Breaking changes", "labels": ["break"] }, 42 | { "title": "## 🚀 New features", "labels": ["feat", "improv"] }, 43 | { "title": "## 🛠️ Fixes", "labels": ["fix", "perf", "chore"] }, 44 | { "title": "## 👾 Other", "labels": [] } 45 | ], 46 | "ignore_labels": ["release", "bump"] 47 | } 48 | 49 | - name: Release 50 | uses: softprops/action-gh-release@v2 51 | with: 52 | token: ${{ secrets.GITHUB_TOKEN }} 53 | body: ${{ steps.release_notes.outputs.changelog }} 54 | files: ${{ env.WORKFLOW_NAME }}.alfredworkflow 55 | -------------------------------------------------------------------------------- /.github/workflows/markdownlint.yml: -------------------------------------------------------------------------------- 1 | name: Markdownlint check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.md" 8 | - ".github/workflows/markdownlint.yml" 9 | - ".markdownlint.*" # markdownlint config files 10 | pull_request: 11 | paths: 12 | - "**.md" 13 | 14 | jobs: 15 | markdownlint: 16 | name: Markdownlint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: DavidAnson/markdownlint-cli2-action@v20 21 | with: 22 | globs: "**/*.md" 23 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: PR title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | 12 | permissions: 13 | pull-requests: read 14 | 15 | jobs: 16 | semantic-pull-request: 17 | name: Check PR title 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@v5 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | requireScope: false 25 | subjectPattern: ^(?![A-Z]).+$ # disallow title starting with capital 26 | types: | # add `improv` to the list of allowed types 27 | improv 28 | fix 29 | feat 30 | refactor 31 | build 32 | ci 33 | style 34 | test 35 | chore 36 | perf 37 | docs 38 | break 39 | revert 40 | -------------------------------------------------------------------------------- /.github/workflows/stale-bot.yml: -------------------------------------------------------------------------------- 1 | name: Stale bot 2 | on: 3 | schedule: 4 | - cron: "18 04 * * 3" 5 | 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Close stale issues 15 | uses: actions/stale@v9 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | # DOCS https://github.com/actions/stale#all-options 20 | days-before-stale: 180 21 | days-before-close: 7 22 | stale-issue-label: "Stale" 23 | stale-issue-message: | 24 | This issue has been automatically marked as stale. 25 | **If this issue is still affecting you, please leave any comment**, for example "bump", and it will be kept open. 26 | close-issue-message: | 27 | This issue has been closed due to inactivity, and will not be monitored. 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac 2 | .DS_Store 3 | 4 | # Alfred 5 | prefs.plist 6 | *.alfredworkflow 7 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Defaults https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml 2 | # DOCS https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md 3 | #─────────────────────────────────────────────────────────────────────────────── 4 | 5 | # MODIFIED SETTINGS 6 | blanks-around-headings: 7 | lines_below: 0 # space waster 8 | ul-style: { style: sublist } 9 | 10 | # not autofixable 11 | ol-prefix: { style: ordered } 12 | line-length: 13 | tables: false 14 | code_blocks: false 15 | no-inline-html: 16 | allowed_elements: [img, details, summary, kbd, a, br] 17 | 18 | #───────────────────────────────────────────────────────────────────────────── 19 | # DISABLED 20 | ul-indent: false # not compatible with using tabs 21 | no-hard-tabs: false # taken care of by editorconfig 22 | blanks-around-lists: false # space waster 23 | first-line-heading: false # e.g., ignore-comments 24 | no-emphasis-as-heading: false # sometimes useful 25 | -------------------------------------------------------------------------------- /.rsync-exclude: -------------------------------------------------------------------------------- 1 | # vim: ft=gitignore 2 | #─────────────────────────────────────────────────────────────────────────────── 3 | 4 | # git 5 | .git/ 6 | .gitignore 7 | 8 | # Alfred 9 | prefs.plist 10 | .rsync-exclude 11 | 12 | # docs 13 | docs/ 14 | LICENSE 15 | # INFO leading `/` -> ignore only the README in the root, not in subfolders 16 | /README.md 17 | 18 | # build 19 | Justfile 20 | .github/ 21 | .build-and-release.sh 22 | 23 | # linter & types 24 | .typos.toml 25 | .editorconfig 26 | .markdownlint.yaml 27 | jxa-globals.d.ts 28 | jsconfig.json 29 | alfred.d.ts 30 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set quiet := true 2 | 3 | # REQUIRED local workflow uses same folder name 4 | 5 | workflow_uid := `basename "$PWD"` 6 | prefs_location := `defaults read com.runningwithcrayons.Alfred-Preferences syncfolder | sed "s|^~|$HOME|"` 7 | local_workflow := prefs_location / "Alfred.alfredpreferences/workflows" / workflow_uid 8 | 9 | #─────────────────────────────────────────────────────────────────────────────── 10 | 11 | transfer-changes-FROM-local: 12 | #!/usr/bin/env zsh 13 | rsync --archive --delete --exclude-from="$PWD/.rsync-exclude" "{{ local_workflow }}/" "$PWD" 14 | git status --short 15 | 16 | transfer-changes-TO-local: 17 | #!/usr/bin/env zsh 18 | rsync --archive --delete --exclude-from="$PWD/.rsync-exclude" "$PWD/" "{{ local_workflow }}" 19 | cd "{{ local_workflow }}" 20 | print "\e[1;34mChanges at the local workflow:\e[0m" 21 | git status --short . 22 | 23 | [macos] 24 | open-local-workflow-in-alfred: 25 | #!/usr/bin/env zsh 26 | # using JXA and URI for redundancy, as both are not 100 % reliable https://www.alfredforum.com/topic/18390-get-currently-edited-workflow-uri/ 27 | open "alfredpreferences://navigateto/workflows>workflow>{{ workflow_uid }}" 28 | osascript -e 'tell application id "com.runningwithcrayons.Alfred" to reveal workflow "{{ workflow_uid }}"' 29 | 30 | release: 31 | ./.build-and-release.sh 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Christopher Grieser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alfred Reddit Browser 2 | ![GitHub downloads](https://img.shields.io/github/downloads/chrisgrieser/alfred-reddit-browser/total?label=GitHub%20Downloads&style=plastic&logo=github) 3 | ![Alfred gallery downloads](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchrisgrieser%2F.config%2Frefs%2Fheads%2Fmain%2FAlfred.alfredpreferences%2Falfred-workflow-download-count.yaml&query=alfred-reddit-browser&style=plastic&logo=alfred&label=Gallery%20Downloads&color=%235C1F87) 4 | ![Latest release](https://img.shields.io/github/v/release/chrisgrieser/alfred-reddit-browser?label=Latest%20Release&style=plastic) 5 | 6 | ![Showcase subreddit 7 | selection](https://img.shields.io/github/downloads/chrisgrieser/alfred-reddit-browser/total?label=Total%20Downloads&style=plastic) 8 | ![Showcase browsing a 9 | subreddit](https://img.shields.io/github/v/release/chrisgrieser/alfred-reddit-browser?label=Latest%20Release&style=plastic) 10 | 11 | Browse your favorite subreddits via Alfred. 12 | [Featured in the 13 | Alfred Gallery](https://alfred.app/workflows/chrisgrieser/reddit-browser/). 14 | 15 | showcase subreddit selection 16 | 17 | showcase browsing a subreddit 18 | 19 | settings the workflow has to offer 20 | 21 | ## Table of Content 22 | 23 | 24 | 25 | - [Features](#features) 26 | - [Installation](#installation) 27 | - [Usage](#usage) 28 | - ["Blocked by network security"](#blocked-by-network-security) 29 | - [Credits](#credits) 30 | 31 | 32 | 33 | ## Features 34 | - Browse subreddits, switch between subreddits. 35 | - No reddit account needed. 36 | - Save scrolling positions, mark posts as new, old or visited. 37 | - Minimum upvotes to display posts, customizable sorting method. 38 | - Can also browse hackernews. 39 | - Optionally open in posts [old reddit](https://old.reddit.com/). 40 | - Due to smart caching, this workflow should not hit API rate limits (under 41 | normal usage). 42 | 43 | ## Installation 44 | [➡️ Download the latest release.](https://github.com/chrisgrieser/alfred-reddit-browser/releases/latest) 45 | 46 | The workflow updates automatically via the Alfred Gallery. 47 | 48 | ## Usage 49 | - `sub`: Select subreddit to browse. 50 | + : Browse subreddit in Alfred. 51 | + ⌘⏎: Open subreddit in browser. 52 | - `rr`: Browse the current subreddit. 53 | + : Open Post on reddit. 54 | + ⌘⏎: Switch to next subreddit. 55 | + ⇧⌘⏎: Switch to previous subreddit. 56 | + ⌥⏎: Copy URL of post to clipboard. 57 | + ⇧⏎: Open URL (if external link). 58 | + or ⌘Y: Preview the result. Works with [Alfred Extra 59 | Pane](https://github.com/mr-pennyworth/alfred-extra-pane). 60 | - `:reddit-reload`: Force reload the cache. Only needed for debugging purposes. 61 | 62 | ## "Blocked by network security" 63 | Sometimes, there will be the error message "You have been blocked by network 64 | security." Unfortunately, I am not certain what exactly causes this. Usually, 65 | the workflow will work again after a few hours. If you are a developer, help 66 | solving this is welcome. 67 | 68 | ## Credits 69 | In my day job, I am a sociologist studying the social mechanisms underlying the 70 | digital economy. For my PhD project, I investigate the governance of the app 71 | economy and how software ecosystems manage the tension between innovation and 72 | compatibility. If you are interested in this subject, feel free to get in touch. 73 | 74 | - [Academic website](https://chris-grieser.de/) 75 | - [Mastodon](https://pkm.social/@pseudometa) 76 | - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser) 77 | - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/) 78 | 79 | Buy Me a Coffee at ko-fi.com 82 | -------------------------------------------------------------------------------- /hackernews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/alfred-reddit-browser/69647e80ff0fe0f92260f8912ea62b830b73f31b/hackernews.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/alfred-reddit-browser/69647e80ff0fe0f92260f8912ea62b830b73f31b/icon.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | de.chris-grieser.reddit-browser 7 | category 8 | ⭐️ 9 | connections 10 | 11 | 09B3D6D5-85DC-4788-B940-EDFB8EC74EE4 12 | 13 | 14 | destinationuid 15 | 8851C420-3E80-4797-80F1-31999E4C2F54 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 4899D6A4-F955-432A-9827-067B8770B78D 25 | 26 | 27 | destinationuid 28 | 7FEF0F5A-0E99-4D48-9901-40A455892790 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | 5852A551-EFDF-47BB-A256-13A0D3975AA5 38 | 39 | 40 | destinationuid 41 | 26F00E99-5D45-4FCE-AF6F-EA4A468CDBB9 42 | modifiers 43 | 1048576 44 | modifiersubtext 45 | ⌘: Open subreddit in browser 46 | vitoclose 47 | 48 | 49 | 50 | destinationuid 51 | 09B3D6D5-85DC-4788-B940-EDFB8EC74EE4 52 | modifiers 53 | 0 54 | modifiersubtext 55 | 56 | vitoclose 57 | 58 | 59 | 60 | 7FEF0F5A-0E99-4D48-9901-40A455892790 61 | 62 | 63 | destinationuid 64 | 193EC95F-B8DE-4F8D-8448-A7BF242687ED 65 | modifiers 66 | 0 67 | modifiersubtext 68 | 69 | vitoclose 70 | 71 | 72 | 73 | 8343AFB0-A436-46E5-BA05-959A1BAE7392 74 | 75 | 76 | destinationuid 77 | 26F00E99-5D45-4FCE-AF6F-EA4A468CDBB9 78 | modifiers 79 | 0 80 | modifiersubtext 81 | 82 | vitoclose 83 | 84 | 85 | 86 | destinationuid 87 | FF2B7000-6AD9-42B6-825C-E952CD707CFE 88 | modifiers 89 | 0 90 | modifiersubtext 91 | 92 | vitoclose 93 | 94 | 95 | 96 | 8851C420-3E80-4797-80F1-31999E4C2F54 97 | 98 | 99 | destinationuid 100 | EB3BD689-69E5-4385-8983-379774F8848E 101 | modifiers 102 | 131072 103 | modifiersubtext 104 | ⇧: Open Post directly 105 | vitoclose 106 | 107 | 108 | 109 | destinationuid 110 | 8343AFB0-A436-46E5-BA05-959A1BAE7392 111 | modifiers 112 | 0 113 | modifiersubtext 114 | 115 | vitoclose 116 | 117 | 118 | 119 | destinationuid 120 | E640FB36-75C7-4B36-A8AC-0F56468145CC 121 | modifiers 122 | 1048576 123 | modifiersubtext 124 | ⌘: Browse next subreddit 125 | vitoclose 126 | 127 | 128 | 129 | destinationuid 130 | 93464D83-F4C4-44EC-852C-956A903A99C9 131 | modifiers 132 | 1179648 133 | modifiersubtext 134 | ⌘⇧: Browse previous subreddit 135 | vitoclose 136 | 137 | 138 | 139 | destinationuid 140 | 6887CA13-E491-46B2-9AA3-D36BEC604521 141 | modifiers 142 | 524288 143 | modifiersubtext 144 | ⌥: Copy URL 145 | vitoclose 146 | 147 | 148 | 149 | 93464D83-F4C4-44EC-852C-956A903A99C9 150 | 151 | 152 | destinationuid 153 | E640FB36-75C7-4B36-A8AC-0F56468145CC 154 | modifiers 155 | 0 156 | modifiersubtext 157 | 158 | vitoclose 159 | 160 | 161 | 162 | A567D598-1CEC-4307-A5B7-CA7E5193BA78 163 | 164 | E640FB36-75C7-4B36-A8AC-0F56468145CC 165 | 166 | 167 | destinationuid 168 | 3381D52B-F37A-422A-BB73-2654C0280799 169 | modifiers 170 | 0 171 | modifiersubtext 172 | 173 | vitoclose 174 | 175 | 176 | 177 | EB3BD689-69E5-4385-8983-379774F8848E 178 | 179 | 180 | destinationuid 181 | 8343AFB0-A436-46E5-BA05-959A1BAE7392 182 | modifiers 183 | 0 184 | modifiersubtext 185 | 186 | vitoclose 187 | 188 | 189 | 190 | FF2B7000-6AD9-42B6-825C-E952CD707CFE 191 | 192 | 193 | destinationuid 194 | A567D598-1CEC-4307-A5B7-CA7E5193BA78 195 | modifiers 196 | 0 197 | modifiersubtext 198 | 199 | vitoclose 200 | 201 | 202 | 203 | 204 | createdby 205 | Chris Grieser 206 | description 207 | Browse your favorite subreddits via Alfred 208 | disabled 209 | 210 | name 211 | Reddit Browser 212 | objects 213 | 214 | 215 | config 216 | 217 | browser 218 | 219 | skipqueryencode 220 | 221 | skipvarencode 222 | 223 | spaces 224 | 225 | url 226 | 227 | 228 | type 229 | alfred.workflow.action.openurl 230 | uid 231 | 26F00E99-5D45-4FCE-AF6F-EA4A468CDBB9 232 | version 233 | 1 234 | 235 | 236 | config 237 | 238 | alfredfiltersresults 239 | 240 | alfredfiltersresultsmatchmode 241 | 2 242 | argumenttreatemptyqueryasnil 243 | 244 | argumenttrimmode 245 | 0 246 | argumenttype 247 | 1 248 | escaping 249 | 0 250 | keyword 251 | sub 252 | queuedelaycustom 253 | 3 254 | queuedelayimmediatelyinitially 255 | 256 | queuedelaymode 257 | 0 258 | queuemode 259 | 1 260 | runningsubtext 261 | 262 | script 263 | 264 | scriptargtype 265 | 1 266 | scriptfile 267 | ./scripts/select-subreddit.js 268 | subtext 269 | loading subreddit icons… 270 | title 271 | {const:alfred_workflow_name} 272 | type 273 | 8 274 | withspace 275 | 276 | 277 | type 278 | alfred.workflow.input.scriptfilter 279 | uid 280 | 5852A551-EFDF-47BB-A256-13A0D3975AA5 281 | version 282 | 3 283 | 284 | 285 | config 286 | 287 | concurrently 288 | 289 | escaping 290 | 0 291 | script 292 | 293 | scriptargtype 294 | 1 295 | scriptfile 296 | ./scripts/reload-other-subreddits-in-bg.js 297 | type 298 | 8 299 | 300 | type 301 | alfred.workflow.action.script 302 | uid 303 | A567D598-1CEC-4307-A5B7-CA7E5193BA78 304 | version 305 | 2 306 | 307 | 308 | config 309 | 310 | concurrently 311 | 312 | escaping 313 | 0 314 | script 315 | 316 | scriptargtype 317 | 1 318 | scriptfile 319 | ./scripts/open-and-mark-as-opened.js 320 | type 321 | 8 322 | 323 | type 324 | alfred.workflow.action.script 325 | uid 326 | FF2B7000-6AD9-42B6-825C-E952CD707CFE 327 | version 328 | 2 329 | 330 | 331 | type 332 | alfred.workflow.utility.junction 333 | uid 334 | 8343AFB0-A436-46E5-BA05-959A1BAE7392 335 | version 336 | 1 337 | 338 | 339 | type 340 | alfred.workflow.utility.junction 341 | uid 342 | EB3BD689-69E5-4385-8983-379774F8848E 343 | version 344 | 1 345 | 346 | 347 | config 348 | 349 | alfredfiltersresults 350 | 351 | alfredfiltersresultsmatchmode 352 | 2 353 | argumenttreatemptyqueryasnil 354 | 355 | argumenttrimmode 356 | 0 357 | argumenttype 358 | 1 359 | escaping 360 | 0 361 | keyword 362 | rr 363 | queuedelaycustom 364 | 3 365 | queuedelayimmediatelyinitially 366 | 367 | queuedelaymode 368 | 0 369 | queuemode 370 | 1 371 | runningsubtext 372 | 373 | script 374 | 375 | scriptargtype 376 | 1 377 | scriptfile 378 | ./scripts/browse-subreddit.js 379 | subtext 380 | loading subreddit… 381 | title 382 | {const:alfred_workflow_name} 383 | type 384 | 8 385 | withspace 386 | 387 | 388 | inboundconfig 389 | 390 | externalid 391 | browse 392 | inputmode 393 | 1 394 | 395 | type 396 | alfred.workflow.input.scriptfilter 397 | uid 398 | 8851C420-3E80-4797-80F1-31999E4C2F54 399 | version 400 | 3 401 | 402 | 403 | config 404 | 405 | concurrently 406 | 407 | escaping 408 | 0 409 | script 410 | 411 | scriptargtype 412 | 1 413 | scriptfile 414 | ./scripts/determine-next-prev-subreddit.sh 415 | type 416 | 8 417 | 418 | type 419 | alfred.workflow.action.script 420 | uid 421 | E640FB36-75C7-4B36-A8AC-0F56468145CC 422 | version 423 | 2 424 | 425 | 426 | config 427 | 428 | externaltriggerid 429 | browse 430 | passinputasargument 431 | 432 | passvariables 433 | 434 | workflowbundleid 435 | self 436 | 437 | type 438 | alfred.workflow.output.callexternaltrigger 439 | uid 440 | 3381D52B-F37A-422A-BB73-2654C0280799 441 | version 442 | 1 443 | 444 | 445 | config 446 | 447 | argument 448 | 449 | passthroughargument 450 | 451 | variables 452 | 453 | selected_subreddit 454 | {query} 455 | 456 | 457 | type 458 | alfred.workflow.utility.argument 459 | uid 460 | 09B3D6D5-85DC-4788-B940-EDFB8EC74EE4 461 | version 462 | 1 463 | 464 | 465 | type 466 | alfred.workflow.utility.junction 467 | uid 468 | 93464D83-F4C4-44EC-852C-956A903A99C9 469 | version 470 | 1 471 | 472 | 473 | config 474 | 475 | autopaste 476 | 477 | clipboardtext 478 | {query} 479 | ignoredynamicplaceholders 480 | 481 | transient 482 | 483 | 484 | type 485 | alfred.workflow.output.clipboard 486 | uid 487 | 6887CA13-E491-46B2-9AA3-D36BEC604521 488 | version 489 | 3 490 | 491 | 492 | config 493 | 494 | argumenttype 495 | 2 496 | keyword 497 | :reddit-reload 498 | subtext 499 | {const:alfred_workflow_name} 500 | text 501 | Force reload all caches 502 | withspace 503 | 504 | 505 | type 506 | alfred.workflow.input.keyword 507 | uid 508 | 4899D6A4-F955-432A-9827-067B8770B78D 509 | version 510 | 1 511 | 512 | 513 | config 514 | 515 | tasksettings 516 | 517 | target_path 518 | {const:alfred_workflow_cache} 519 | use_finder 520 | 521 | 522 | taskuid 523 | com.alfredapp.automation.core/files-and-folders/path.trash 524 | 525 | type 526 | alfred.workflow.automation.task 527 | uid 528 | 193EC95F-B8DE-4F8D-8448-A7BF242687ED 529 | version 530 | 1 531 | 532 | 533 | config 534 | 535 | lastpathcomponent 536 | 537 | onlyshowifquerypopulated 538 | 539 | removeextension 540 | 541 | text 542 | 🔁 Cache reloaded 543 | title 544 | {const:alfred_workflow_name} 545 | 546 | type 547 | alfred.workflow.output.notification 548 | uid 549 | 7FEF0F5A-0E99-4D48-9901-40A455892790 550 | version 551 | 1 552 | 553 | 554 | readme 555 | ## "Blocked by network security" 556 | Sometimes, there will be the error message "You have been blocked by network 557 | security." Unfortunately, I am not certain what exactly causes this. Usually, 558 | the workflow will work again after a few hours. If you are a developer, help 559 | solving this is welcome. 560 | 561 | ## Usage 562 | - `sub`: Select subreddit to browse. 563 | + <kbd>⏎</kbd>: Browse subreddit in Alfred. 564 | + <kbd>⌘⏎</kbd>: Open subreddit in browser. 565 | - `rr`: Browse the current subreddit. 566 | + <kbd>⏎</kbd>: Open Post on reddit. 567 | + <kbd>⌘⏎</kbd>: Switch to next subreddit. 568 | + <kbd>⇧⌘⏎</kbd>: Switch to previous subreddit. 569 | + <kbd>⌥⏎</kbd>: Copy URL of post to clipboard. 570 | + <kbd>⇧⏎</kbd>: Open external URL (if there is one). 571 | + <kbd>⇧</kbd> or <kbd>⌘Y</kbd>: Preview the result. Works with [Alfred Extra 572 | Pane](https://github.com/mr-pennyworth/alfred-extra-pane). 573 | - `:reddit-reload`: Force reload the cache. Only needed for debugging purposes. 574 | 575 | --- 576 | 577 | Created by [Chris Grieser](https://chris-grieser.de/). 578 | uidata 579 | 580 | 09B3D6D5-85DC-4788-B940-EDFB8EC74EE4 581 | 582 | colorindex 583 | 2 584 | note 585 | store selection 586 | xpos 587 | 220 588 | ypos 589 | 310 590 | 591 | 193EC95F-B8DE-4F8D-8448-A7BF242687ED 592 | 593 | colorindex 594 | 11 595 | note 596 | delete custom workflow cache 597 | xpos 598 | 1100 599 | ypos 600 | 680 601 | 602 | 26F00E99-5D45-4FCE-AF6F-EA4A468CDBB9 603 | 604 | colorindex 605 | 2 606 | xpos 607 | 600 608 | ypos 609 | 15 610 | 611 | 3381D52B-F37A-422A-BB73-2654C0280799 612 | 613 | colorindex 614 | 2 615 | xpos 616 | 765 617 | ypos 618 | 280 619 | 620 | 4899D6A4-F955-432A-9827-067B8770B78D 621 | 622 | colorindex 623 | 11 624 | note 625 | refresh cache 626 | xpos 627 | 800 628 | ypos 629 | 680 630 | 631 | 5852A551-EFDF-47BB-A256-13A0D3975AA5 632 | 633 | colorindex 634 | 2 635 | xpos 636 | 30 637 | ypos 638 | 15 639 | 640 | 6887CA13-E491-46B2-9AA3-D36BEC604521 641 | 642 | colorindex 643 | 2 644 | xpos 645 | 470 646 | ypos 647 | 450 648 | 649 | 7FEF0F5A-0E99-4D48-9901-40A455892790 650 | 651 | colorindex 652 | 11 653 | xpos 654 | 950 655 | ypos 656 | 680 657 | 658 | 8343AFB0-A436-46E5-BA05-959A1BAE7392 659 | 660 | colorindex 661 | 2 662 | xpos 663 | 520 664 | ypos 665 | 155 666 | 667 | 8851C420-3E80-4797-80F1-31999E4C2F54 668 | 669 | colorindex 670 | 2 671 | xpos 672 | 295 673 | ypos 674 | 280 675 | 676 | 93464D83-F4C4-44EC-852C-956A903A99C9 677 | 678 | colorindex 679 | 2 680 | xpos 681 | 515 682 | ypos 683 | 360 684 | 685 | A567D598-1CEC-4307-A5B7-CA7E5193BA78 686 | 687 | colorindex 688 | 2 689 | note 690 | PERF background reload caches of other subreddit 691 | xpos 692 | 760 693 | ypos 694 | 125 695 | 696 | E640FB36-75C7-4B36-A8AC-0F56468145CC 697 | 698 | colorindex 699 | 2 700 | note 701 | determine next/prev subreddit 702 | xpos 703 | 600 704 | ypos 705 | 280 706 | 707 | EB3BD689-69E5-4385-8983-379774F8848E 708 | 709 | colorindex 710 | 2 711 | xpos 712 | 435 713 | ypos 714 | 155 715 | 716 | FF2B7000-6AD9-42B6-825C-E952CD707CFE 717 | 718 | colorindex 719 | 2 720 | note 721 | mark reading position 722 | xpos 723 | 600 724 | ypos 725 | 125 726 | 727 | 728 | userconfigurationconfig 729 | 730 | 731 | config 732 | 733 | default 734 | 735 | required 736 | 737 | trim 738 | 739 | verticalsize 740 | 9 741 | 742 | description 743 | List of subreddits, one per line 744 | label 745 | subreddits 746 | type 747 | textarea 748 | variable 749 | subreddits 750 | 751 | 752 | config 753 | 754 | default 755 | hot 756 | pairs 757 | 758 | 759 | hot 760 | hot 761 | 762 | 763 | new 764 | new 765 | 766 | 767 | controversial 768 | controversial 769 | 770 | 771 | top 772 | top 773 | 774 | 775 | 776 | description 777 | How the results are sorted. (Note that "top" filters out a lot of posts.) 778 | label 779 | Sorting 780 | type 781 | popupbutton 782 | variable 783 | sort_type 784 | 785 | 786 | config 787 | 788 | defaultvalue 789 | 25 790 | markercount 791 | 20 792 | maxvalue 793 | 100 794 | minvalue 795 | 5 796 | onlystoponmarkers 797 | 798 | showmarkers 799 | 800 | 801 | description 802 | Number of posts to show per subreddit 803 | label 804 | Posts 805 | type 806 | slider 807 | variable 808 | pages_to_request 809 | 810 | 811 | config 812 | 813 | default 814 | 815 | required 816 | 817 | text 818 | Hide 819 | 820 | description 821 | 822 | label 823 | Stickied Posts 824 | type 825 | checkbox 826 | variable 827 | hide_stickied 828 | 829 | 830 | config 831 | 832 | defaultvalue 833 | 0 834 | markercount 835 | 6 836 | maxvalue 837 | 100 838 | minvalue 839 | 0 840 | onlystoponmarkers 841 | 842 | showmarkers 843 | 844 | 845 | description 846 | Minimum score of posts to display, posts with a score below that number are not shown. Use "0" to show all posts. 847 | label 848 | Minimum Upvotes 849 | type 850 | slider 851 | variable 852 | min_upvotes 853 | 854 | 855 | config 856 | 857 | default 858 | none 859 | pairs 860 | 861 | 862 | none 863 | none 864 | 865 | 866 | old posts 867 | old 868 | 869 | 870 | new posts 871 | new 872 | 873 | 874 | 875 | description 876 | A post is considered old when is was already listed before the refreshing. If it was not listed before refreshing, it is considered new. 877 | label 878 | Age Icon 879 | type 880 | popupbutton 881 | variable 882 | age_icon 883 | 884 | 885 | config 886 | 887 | default 888 | 889 | required 890 | 891 | text 892 | Save 893 | 894 | description 895 | Upon visiting a link, move the item and all items above it to the bottom. 896 | label 897 | Reading Position 898 | type 899 | checkbox 900 | variable 901 | save_scroll_position 902 | 903 | 904 | config 905 | 906 | default 907 | www.reddit.com 908 | pairs 909 | 910 | 911 | www.reddit.com 912 | www.reddit.com 913 | 914 | 915 | new.reddit.com 916 | new.reddit.com 917 | 918 | 919 | old.reddit.com 920 | old.reddit.com 921 | 922 | 923 | 924 | description 925 | Redirect to an alternative reddit frontend. 926 | label 927 | Frontend 928 | type 929 | popupbutton 930 | variable 931 | reddit_frontend 932 | 933 | 934 | config 935 | 936 | defaultvalue 937 | 20 938 | markercount 939 | 12 940 | maxvalue 941 | 60 942 | minvalue 943 | 5 944 | onlystoponmarkers 945 | 946 | showmarkers 947 | 948 | 949 | description 950 | After this many minutes, the cache will be refreshed, and reading positions will be reset. 951 | label 952 | Refresh Internal 953 | type 954 | slider 955 | variable 956 | cache_age_threshold 957 | 958 | 959 | config 960 | 961 | default 962 | 963 | filtermode 964 | 1 965 | placeholder 966 | 967 | required 968 | 969 | 970 | description 971 | Advanced: Directory where this workflow will save the subreddit icons. You can override the icons in that folder to customize subreddit icons. (Leave empty to use the default subreddit icons.) 972 | label 973 | Subreddit Icons Location 974 | type 975 | filepicker 976 | variable 977 | custom_subreddit_icons 978 | 979 | 980 | config 981 | 982 | default 983 | 984 | required 985 | 986 | text 987 | Include 988 | 989 | description 990 | This makes hackernews appear in the list of subreddits, treating it as if it was another subreddit. (The reddit-specific settings are not applied to the hackernews feed.) 991 | label 992 | Hackernews 993 | type 994 | checkbox 995 | variable 996 | add_hackernews 997 | 998 | 999 | config 1000 | 1001 | default 1002 | https://news.ycombinator.com/item?id= 1003 | pairs 1004 | 1005 | 1006 | original (ycombinator) 1007 | https://news.ycombinator.com/item?id= 1008 | 1009 | 1010 | ycombinato 1011 | https://news.ycombinato.com/item?id= 1012 | 1013 | 1014 | hckrnws 1015 | https://www.hckrnws.com/stories/ 1016 | 1017 | 1018 | Hackerweb 1019 | https://hackerweb.app/#/item/ 1020 | 1021 | 1022 | hw.leftium (Hackerweb fork) 1023 | https://hw.leftium.com/#/item/ 1024 | 1025 | 1026 | 1027 | description 1028 | frontend to use for hackerend 1029 | label 1030 | 1031 | type 1032 | popupbutton 1033 | variable 1034 | hackernews_frontend_url 1035 | 1036 | 1037 | version 1038 | 1.6.15 1039 | webaddress 1040 | https://chris-grieser.de/ 1041 | 1042 | 1043 | -------------------------------------------------------------------------------- /scripts/browse-subreddit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | ObjC.import("stdlib"); 3 | const app = Application.currentApplication(); 4 | app.includeStandardAdditions = true; 5 | //────────────────────────────────────────────────────────────────────────────── 6 | 7 | const fileExists = (/** @type {string} */ filePath) => Application("Finder").exists(Path(filePath)); 8 | 9 | /** @param {string} path */ 10 | function readFile(path) { 11 | const data = $.NSFileManager.defaultManager.contentsAtPath(path); 12 | const str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); 13 | return ObjC.unwrap(str); 14 | } 15 | 16 | /** @param {string} filepath @param {string} text */ 17 | function writeToFile(filepath, text) { 18 | const str = $.NSString.alloc.initWithUTF8String(text); 19 | str.writeToFileAtomicallyEncodingError(filepath, true, $.NSUTF8StringEncoding, null); 20 | } 21 | 22 | function ensureCacheFolderExists() { 23 | const finder = Application("Finder"); 24 | const cacheDir = $.getenv("alfred_workflow_cache"); 25 | if (!finder.exists(Path(cacheDir))) { 26 | const cacheDirBasename = $.getenv("alfred_workflow_bundleid"); 27 | const cacheDirParent = cacheDir.slice(0, -cacheDirBasename.length); 28 | finder.make({ 29 | new: "folder", 30 | at: Path(cacheDirParent), 31 | withProperties: { name: cacheDirBasename }, 32 | }); 33 | } 34 | } 35 | 36 | /** @param {string} path */ 37 | function cacheIsOutdated(path) { 38 | const cacheAgeThresholdMins = Number.parseInt($.getenv("cache_age_threshold")); 39 | const cacheObj = Application("System Events").aliases[path]; 40 | if (!cacheObj.exists()) return true; 41 | const cacheAgeMins = (Date.now() - +cacheObj.creationDate()) / 1000 / 60; 42 | return cacheAgeMins > cacheAgeThresholdMins; 43 | } 44 | 45 | /** 46 | * @param {string} firstPath 47 | * @param {string} secondPath 48 | * @returns {boolean} firstPathOlderThanSecond 49 | */ 50 | function olderThan(firstPath, secondPath) { 51 | const firstItem = Application("System Events").aliases[firstPath]; 52 | if (!firstItem.exists()) return true; 53 | const secondItem = Application("System Events").aliases[secondPath]; 54 | if (!secondItem.exists()) return false; 55 | const firstPathOlderThanSecond = 56 | +firstItem.modificationDate() - +secondItem.modificationDate() < 0; 57 | return firstPathOlderThanSecond; 58 | } 59 | 60 | //────────────────────────────────────────────────────────────────────────────── 61 | 62 | function getSettings() { 63 | return { 64 | minUpvotes: Number.parseInt($.getenv("min_upvotes")), 65 | redditFrontend: $.getenv("reddit_frontend"), 66 | hnFrontendUrl: $.getenv("hackernews_frontend_url"), 67 | iconFolder: $.getenv("custom_subreddit_icons") || $.getenv("alfred_workflow_data"), 68 | sortType: $.getenv("sort_type") || "hot", 69 | hideStickied: $.getenv("hide_stickied") === "1", 70 | pagesToRequest: Number.parseInt($.getenv("pages_to_request")), 71 | }; 72 | } 73 | 74 | /** @typedef {Object} hackerNewsItem 75 | * @property {string} objectID 76 | * @property {string} title 77 | * @property {string} url 78 | * @property {number} num_comments 79 | * @property {number} points 80 | * @property {string} author 81 | * @property {string[]} _tags 82 | */ 83 | 84 | /** 85 | * @param {AlfredItem[]} oldItems for marker for old posts 86 | * @returns {AlfredItem[]|string}} 87 | */ 88 | function getHackernewsPosts(oldItems) { 89 | const opts = getSettings(); 90 | 91 | // DOCS https://hn.algolia.com/api 92 | // alternative "https://hacker-news.firebaseio.com/v0/topstories.json"; 93 | const url = `https://hn.algolia.com/api/v1/search_by_date?tags=front_page&hitsPerPage=${opts.pagesToRequest}`; 94 | let response; 95 | const apiResponse = app.doShellScript(`curl -sL "${url}"`); 96 | try { 97 | response = JSON.parse(apiResponse); 98 | } catch (_error) { 99 | return `Error parsing JSON. curl response was: ${apiResponse}`; 100 | } 101 | 102 | const oldUrls = oldItems.map((item) => item.arg); 103 | const oldTitles = oldItems.map((item) => item.title); 104 | 105 | /** @type{AlfredItem[]} */ 106 | const hits = response.hits.reduce( 107 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: okay here 108 | (/** @type {AlfredItem[]} */ acc, /** @type {hackerNewsItem} */ item) => { 109 | if (item.points < opts.minUpvotes) return acc; 110 | 111 | const externalUrl = item.url || ""; 112 | const commentUrl = opts.hnFrontendUrl + item.objectID; 113 | 114 | // filter out jobs 115 | if (item._tags.some((tag) => tag === "job")) return acc; 116 | 117 | // prevent app store URLs auto-open `App Store.app` 118 | const externalUrlNotAppStore = 119 | externalUrl && !externalUrl.startsWith("https://apps.apple.com/"); 120 | const quicklookUrl = externalUrlNotAppStore ? externalUrl : commentUrl; 121 | 122 | // age & visitation icon 123 | const postIsOld = oldUrls.includes(commentUrl); 124 | // HACK since visitation status is only stored as icon, the only way to 125 | // determine it is via checking for the respective icon 126 | const postIsVisited = postIsOld && oldTitles.includes("🟪 " + item.title); 127 | let ageIcon = ""; 128 | if ($.getenv("age_icon") === "old" && postIsOld) ageIcon = "🕓 "; 129 | if ($.getenv("age_icon") === "new" && !postIsOld) ageIcon = "🆕 "; 130 | const visitationIcon = postIsVisited ? "🟪 " : ""; 131 | 132 | // subtitle 133 | /** @type {string|undefined} */ 134 | let category = item._tags.find((tag) => tag === "show_hn" || tag === "ask_hn"); 135 | category = (category ? `[${category}]` : "") 136 | .replace("show_hn", "Show HN") 137 | .replace("ask_hn", "Ask HN"); 138 | const comments = item.num_comments || 0; 139 | const subtitle = `${ageIcon}${item.points}↑ ${comments}● ${category}`; 140 | 141 | /** @type{AlfredItem} */ 142 | const post = { 143 | title: visitationIcon + item.title, 144 | subtitle: subtitle, 145 | arg: commentUrl, 146 | quicklookurl: quicklookUrl, 147 | icon: { path: "hackernews.png" }, 148 | mods: { 149 | cmd: { arg: "next" }, 150 | "cmd+shift": { arg: "prev" }, 151 | shift: { 152 | arg: externalUrl, 153 | valid: Boolean(externalUrl), 154 | subtitle: externalUrl ? "⇧: Open External URL" : "⇧: ⛔ No External URL", 155 | }, 156 | }, 157 | }; 158 | acc.push(post); 159 | return acc; 160 | }, 161 | [], 162 | ); 163 | 164 | return hits; 165 | } 166 | 167 | //────────────────────────────────────────────────────────────────────────────── 168 | 169 | /** @typedef {object} redditPost 170 | * @property {string} kind 171 | * @property {object} data 172 | * @property {string} data.subreddit 173 | * @property {string} data.title 174 | * @property {boolean} data.is_reddit_media_domain 175 | * @property {string} data.link_flair_text 176 | * @property {number} data.score 177 | * @property {boolean} data.is_self 178 | * @property {string} data.domain 179 | * @property {boolean} data.over_18 180 | * @property {string} data.author 181 | * @property {number} data.num_comments 182 | * @property {string} data.permalink 183 | * @property {string} data.url 184 | * @property {boolean} data.stickied 185 | * @property {number} data.num_crossposts 186 | * @property {any} data.preview 187 | * @property {string} data.media.type 188 | */ 189 | 190 | // INFO free API calls restricted to 10 per minute 191 | // https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki 192 | 193 | /** 194 | * @param {string} subredditName 195 | * @param {AlfredItem[]} oldItems for marker for old posts 196 | * @returns {AlfredItem[]|string}} 197 | */ 198 | function getRedditPosts(subredditName, oldItems) { 199 | const opts = getSettings(); 200 | 201 | // DOCS https://www.reddit.com/dev/api#GET_new 202 | // SIC try `curl` with and without user agent, since sometimes one is 203 | // blocked, sometimes the other? 204 | const apiUrl = `https://www.reddit.com/r/${subredditName}/${opts.sortType}.json?limit=${opts.pagesToRequest}`; 205 | let curlCommand = `curl -sL -H "User-Agent: Chrome/117.0.0.0" "${apiUrl}"`; 206 | let response; 207 | try { 208 | response = JSON.parse(app.doShellScript(curlCommand)); 209 | if (response.error) { 210 | curlCommand = `curl -sL "${apiUrl}"`; 211 | response = JSON.parse(app.doShellScript(curlCommand)); 212 | if (response.error) { 213 | const errorMsg = `Error ${response.error}: ${response.message}`; 214 | return errorMsg; 215 | } 216 | } 217 | } catch (_error) { 218 | console.log("Failed curl command: " + curlCommand); 219 | const apiResponse = app.doShellScript(curlCommand); 220 | try { 221 | curlCommand = `curl -sL "${apiUrl}"`; 222 | response = JSON.parse(apiResponse); 223 | } catch (_error) { 224 | console.log("Failed curl command: " + curlCommand); 225 | const errorMsg = `Error parsing JSON. curl response was: ${apiResponse}`; 226 | console.log(errorMsg); 227 | return errorMsg; 228 | } 229 | } 230 | 231 | const oldUrls = oldItems.map((item) => item.arg); 232 | const oldTitles = oldItems.map((item) => item.title); 233 | 234 | let iconPath = `${opts.iconFolder}/${subredditName}.png`; 235 | if (!fileExists(iconPath)) iconPath = "icon.png"; // not cached 236 | 237 | /** @type{AlfredItem[]} */ 238 | const redditPosts = response.data.children.reduce( 239 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: okay here 240 | (/** @type {AlfredItem[]} */ acc, /** @type {redditPost} */ data) => { 241 | const item = data.data; 242 | if (item.score < opts.minUpvotes) return acc; 243 | if (item.stickied && opts.hideStickied) return acc; 244 | 245 | const commentUrl = `https://${opts.redditFrontend}${item.permalink}`; 246 | const isOnReddit = item.domain.includes("redd.it") || item.domain.startsWith("self."); 247 | const externalUrl = isOnReddit ? "" : item.url; 248 | let postTypeIcon = ""; 249 | if (!isOnReddit) postTypeIcon = "🔗 "; 250 | 251 | // prevent app store URLs auto-open `App Store.app` 252 | const externalUrlNotAppStore = 253 | externalUrl && !externalUrl.startsWith("https://apps.apple.com/"); 254 | const quicklookUrl = externalUrlNotAppStore ? externalUrl : commentUrl; 255 | 256 | // age & visited icon 257 | const postIsOld = oldUrls.includes(commentUrl); 258 | let ageIcon = ""; 259 | const postIsVisited = postIsOld && oldTitles.includes("🟪 " + item.title); 260 | if ($.getenv("age_icon") === "old" && postIsOld) ageIcon = "🕓 "; 261 | if ($.getenv("age_icon") === "new" && !postIsOld) ageIcon = "🆕 "; 262 | const visitedIcon = postIsVisited ? "🟪 " : ""; 263 | 264 | // subtitle 265 | const stickyIcon = item.stickied ? "📌 " : ""; 266 | let category = item.link_flair_text ? `[${item.link_flair_text}]` : ""; 267 | if (item.over_18) category += " [NSFW]"; 268 | const comments = item.num_comments ?? 0; 269 | const crossposts = item.num_crossposts ? ` ${item.num_crossposts}↗` : ""; 270 | const subtitle = `${stickyIcon}${postTypeIcon}${ageIcon}${item.score}↑ ${comments}● ${crossposts} ${category}`; 271 | 272 | const cleanTitle = item.title 273 | .replaceAll("<", "<") 274 | .replaceAll(">", ">") 275 | .replaceAll("&", "&") 276 | .trim(); 277 | 278 | /** @type{AlfredItem} */ 279 | const post = { 280 | title: visitedIcon + cleanTitle, 281 | subtitle: subtitle, 282 | arg: commentUrl, 283 | icon: { path: iconPath }, 284 | quicklookurl: quicklookUrl, 285 | mods: { 286 | cmd: { arg: "next" }, 287 | "cmd+shift": { arg: "prev" }, 288 | shift: { 289 | valid: !isOnReddit, 290 | arg: externalUrl, 291 | subtitle: isOnReddit ? "⇧: ⛔ No External URL" : "⇧: Open External URL", 292 | }, 293 | }, 294 | }; 295 | acc.push(post); 296 | return acc; 297 | }, 298 | [], 299 | ); 300 | return redditPosts; 301 | } 302 | 303 | //────────────────────────────────────────────────────────────────────────────── 304 | 305 | /** @type {AlfredRun} */ 306 | // biome-ignore lint/correctness/noUnusedVariables: Alfred run 307 | function run() { 308 | // DETERMINE SUBREDDIT 309 | const subreddits = $.getenv("subreddits") 310 | .trim() 311 | .replace(/^\/?r\//gm, "") // can be `r/` or `/r/` https://www.alfredforum.com/topic/20813-reddit-browser/page/2/#comment-114645// can be r/ or /r/ https://www.alfredforum.com/topic/20813-reddit-browser/page/2/#comment-114645 312 | .split("\n"); 313 | if ($.getenv("add_hackernews") === "1") subreddits.push("hackernews"); 314 | const cachePath = $.getenv("alfred_workflow_cache"); 315 | 316 | /** @type {string?} */ 317 | let prevSubreddit = readFile(cachePath + "/current_subreddit"); 318 | // if user removed subreddit from config, do not display it 319 | if (!subreddits.includes(prevSubreddit)) prevSubreddit = null; 320 | const selectedWithAlfred = 321 | $.NSProcessInfo.processInfo.environment.objectForKey("selected_subreddit").js; 322 | const subredditName = selectedWithAlfred || prevSubreddit || subreddits[0]; 323 | 324 | ensureCacheFolderExists(); 325 | writeToFile(cachePath + "/current_subreddit", subredditName); 326 | 327 | // READ POSTS FROM CACHE 328 | const pathOfThisWorkflow = 329 | $.getenv("alfred_preferences") + "/workflows/" + $.getenv("alfred_workflow_uid"); 330 | const subredditCache = `${cachePath}/${subredditName}.json`; 331 | const refreshIntervalPassed = cacheIsOutdated(subredditCache); 332 | const userPrefsUnchanged = olderThan(`${pathOfThisWorkflow}/prefs.plist`, subredditCache); 333 | const cachedItems = fileExists(subredditCache) ? JSON.parse(readFile(subredditCache)) : []; 334 | if (!refreshIntervalPassed && userPrefsUnchanged) { 335 | return JSON.stringify({ 336 | variables: { cacheWasUpdated: "false" }, // Alfred vars always strings 337 | skipknowledge: true, // workflow handles order to remember reading positions 338 | items: cachedItems, 339 | }); 340 | } 341 | 342 | // REQUEST NEW POSTS FROM API 343 | console.log(`Writing new cache for "${subredditName}"`); 344 | const posts = 345 | subredditName === "hackernews" 346 | ? getHackernewsPosts(cachedItems) 347 | : getRedditPosts(subredditName, cachedItems); 348 | 349 | // GUARD Error or no posts left after filtering 350 | if (typeof posts === "string") { 351 | const blockedByNs = posts.includes("blocked by network security"); 352 | const errorMsg = blockedByNs ? "You have been blocked by network security." : posts; 353 | const info = blockedByNs 354 | ? "Usually, the workflow will work again in a few hours." 355 | : "See debugging console for details."; 356 | return JSON.stringify({ 357 | items: [ 358 | { 359 | title: errorMsg, 360 | subtitle: info, 361 | valid: false, 362 | mods: { cmd: { valid: true } }, 363 | }, 364 | { 365 | title: "Open subreddit in the browser", 366 | subtitle: "r/" + subredditName, 367 | arg: `https://reddit.com/r/${subredditName}`, 368 | }, 369 | ], 370 | }); 371 | } 372 | if (posts.length === 0) { 373 | const msg = "No posts higher than minimum upvote count."; 374 | return JSON.stringify({ items: [{ title: msg, valid: false }] }); 375 | } 376 | 377 | // WRITE CACHE & RETURN POSTS 378 | writeToFile(subredditCache, JSON.stringify(posts)); 379 | return JSON.stringify({ 380 | variables: { cacheWasUpdated: "true" }, // Alfred vars always strings 381 | items: posts, 382 | }); 383 | } 384 | -------------------------------------------------------------------------------- /scripts/determine-next-prev-subreddit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | # shellcheck disable=2154 3 | 4 | direction="$1" 5 | cur_subreddit=$(cat "$alfred_workflow_cache/current_subreddit") 6 | list_of_subreddits=$(echo "$subreddits" | 7 | sed -E 's|^/?r/||') # can be r/ or /r/ https://www.alfredforum.com/topic/20813-reddit-browser/page/2/#comment-114645 8 | 9 | [[ "$add_hackernews" == "1" ]] && list_of_subreddits="$list_of_subreddits\nhackernews" 10 | 11 | #─────────────────────────────────────────────────────────────────────────────── 12 | 13 | 14 | if [[ "$direction" == "next" ]]; then 15 | next_subreddit=$(echo "$list_of_subreddits" | 16 | grep --after-context=1 --extended-regexp "^$cur_subreddit$" | 17 | tail -n1) 18 | 19 | # if already last subreddit, go back to first subreddit 20 | if [[ "$next_subreddit" == "$cur_subreddit" ]]; then 21 | next_subreddit=$(echo "$list_of_subreddits" | head -n1) 22 | fi 23 | 24 | elif [[ "$direction" == "prev" ]]; then 25 | next_subreddit=$(echo "$list_of_subreddits" | 26 | grep --before-context=1 --extended-regexp "^$cur_subreddit$" | 27 | head -n1) 28 | 29 | # if already first subreddit, go back to last subreddit 30 | if [[ "$next_subreddit" == "$cur_subreddit" ]]; then 31 | next_subreddit=$(echo "$list_of_subreddits" | tail -n1) 32 | fi 33 | fi 34 | 35 | #─────────────────────────────────────────────────────────────────────────────── 36 | 37 | # save for Alfred-loop 38 | echo -n "$next_subreddit" > "$alfred_workflow_cache/current_subreddit" 39 | -------------------------------------------------------------------------------- /scripts/open-and-mark-as-opened.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | ObjC.import("stdlib"); 3 | const app = Application.currentApplication(); 4 | app.includeStandardAdditions = true; 5 | 6 | /** @param {string} filepath @param {string} text */ 7 | function writeToFile(filepath, text) { 8 | const str = $.NSString.alloc.initWithUTF8String(text); 9 | str.writeToFileAtomicallyEncodingError(filepath, true, $.NSUTF8StringEncoding, null); 10 | } 11 | 12 | /** @param {string} path */ 13 | function readFile(path) { 14 | const data = $.NSFileManager.defaultManager.contentsAtPath(path); 15 | const str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); 16 | return ObjC.unwrap(str); 17 | } 18 | 19 | const fileExists = (/** @type {string} */ filePath) => Application("Finder").exists(Path(filePath)); 20 | 21 | //────────────────────────────────────────────────────────────────────────────── 22 | 23 | /** @type {AlfredRun} */ 24 | // biome-ignore lint/correctness/noUnusedVariables: Alfred run 25 | function run(argv) { 26 | // identify position of selected item in the cache 27 | const selectedUrl = argv[0]; 28 | const curSubreddit = readFile($.getenv("alfred_workflow_cache") + "/current_subreddit"); 29 | const subredditCachePath = `${$.getenv("alfred_workflow_cache")}/${curSubreddit}.json`; 30 | 31 | if (!fileExists(subredditCachePath)) { 32 | console.log("No subreddit cache found"); 33 | return; 34 | } 35 | 36 | /** @type{AlfredItem[]} */ 37 | const subredditCache = JSON.parse(readFile(subredditCachePath)); 38 | const selectedItemIdx = subredditCache.findIndex( 39 | (item) => item.arg === selectedUrl || item.mods.shift.arg === selectedUrl, 40 | ); 41 | 42 | // mark the selected item as visited 43 | const visitedIcon = "🟪 "; 44 | subredditCache[selectedItemIdx].title = visitedIcon + subredditCache[selectedItemIdx].title; 45 | 46 | // change the order, so that the part scrolled over goes to the bottom, and 47 | // the part not scrolled over gets to the top. 48 | const reorderItems = $.getenv("save_scroll_position") === "1"; 49 | if (reorderItems) { 50 | // 1. Using `splice` over `slice` so we also change the original array in-place 51 | // 2. for the readCache, we also remove the "new" icons 52 | // 3. `subredditCache` then represents the unread items, and is therefore kept on top 53 | const readCache = subredditCache.splice(0, selectedItemIdx + 1).map((item) => { 54 | item.subtitle = item.subtitle.replace("🆕 ", ""); 55 | return item; 56 | }); 57 | const reOrderedCache = subredditCache.concat(readCache); 58 | writeToFile(subredditCachePath, JSON.stringify(reOrderedCache)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/reload-other-subreddits-in-bg.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | ObjC.import("stdlib"); 3 | const app = Application.currentApplication(); 4 | app.includeStandardAdditions = true; 5 | 6 | //────────────────────────────────────────────────────────────────────────────── 7 | 8 | /** @param {string} path */ 9 | function readFile(path) { 10 | const data = $.NSFileManager.defaultManager.contentsAtPath(path); 11 | const str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); 12 | return ObjC.unwrap(str); 13 | } 14 | 15 | const fileExists = (/** @type {string} */ filePath) => Application("Finder").exists(Path(filePath)); 16 | 17 | /** @param {string} filepath @param {string} text */ 18 | function writeToFile(filepath, text) { 19 | const str = $.NSString.alloc.initWithUTF8String(text); 20 | str.writeToFileAtomicallyEncodingError(filepath, true, $.NSUTF8StringEncoding, null); 21 | } 22 | 23 | //────────────────────────────────────────────────────────────────────────────── 24 | 25 | /** @type {AlfredRun} */ 26 | // biome-ignore lint/correctness/noUnusedVariables: Alfred run 27 | function run() { 28 | // guard: cache was not updated 29 | const cachesUpToDate = $.getenv("cacheWasUpdated") === "false"; 30 | if (cachesUpToDate) return; 31 | 32 | // IMPORT SUBREDDIT-LOADING-FUNCTIONS 33 | // HACK read + eval, since JXA knows no import keyword 34 | const fileToImport = 35 | $.getenv("alfred_preferences") + 36 | "/workflows/" + 37 | $.getenv("alfred_workflow_uid") + // = foldername 38 | "/scripts/get-new-posts.js"; 39 | eval(readFile(fileToImport)); 40 | 41 | // determine the other subreddits 42 | const curSubreddit = readFile($.getenv("alfred_workflow_cache") + "/current_subreddit"); 43 | const allSubreddits = $.getenv("subreddits").split("\n"); 44 | if ($.getenv("add_hackernews") === "1") allSubreddits.push("hackernews"); 45 | const otherSubreddits = allSubreddits.filter((subreddit) => subreddit !== curSubreddit); 46 | 47 | // reload cache for them 48 | for (const subredditName of otherSubreddits) { 49 | const subredditCache = `${$.getenv("alfred_workflow_cache")}/${subredditName}.json`; 50 | console.log("Reloading cache for " + subredditName); 51 | 52 | // read old cache 53 | const oldCache = fileExists(subredditCache) ? JSON.parse(readFile(subredditCache)) : []; 54 | 55 | const posts = 56 | // biome-ignore lint/correctness/noUndeclaredVariables: import HACK 57 | subredditName === "hackernews" ? getHackernewsPosts(oldCache) : getRedditPosts(subredditName, oldCache); 58 | 59 | writeToFile(subredditCache, JSON.stringify(posts)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /scripts/select-subreddit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | ObjC.import("stdlib"); 3 | const app = Application.currentApplication(); 4 | app.includeStandardAdditions = true; 5 | //────────────────────────────────────────────────────────────────────────────── 6 | 7 | const fileExists = (/** @type {string} */ filePath) => Application("Finder").exists(Path(filePath)); 8 | 9 | /** @param {string} filepath @param {string} text */ 10 | function writeToFile(filepath, text) { 11 | const str = $.NSString.alloc.initWithUTF8String(text); 12 | str.writeToFileAtomicallyEncodingError(filepath, true, $.NSUTF8StringEncoding, null); 13 | } 14 | 15 | /** @param {string} path */ 16 | function readFile(path) { 17 | const data = $.NSFileManager.defaultManager.contentsAtPath(path); 18 | const str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); 19 | return ObjC.unwrap(str); 20 | } 21 | 22 | function ensureCacheFolderExists() { 23 | const finder = Application("Finder"); 24 | const cacheDir = $.getenv("alfred_workflow_cache"); 25 | if (!finder.exists(Path(cacheDir))) { 26 | const cacheDirBasename = $.getenv("alfred_workflow_bundleid"); 27 | const cacheDirParent = cacheDir.slice(0, -cacheDirBasename.length); 28 | finder.make({ 29 | new: "folder", 30 | at: Path(cacheDirParent), 31 | withProperties: { name: cacheDirBasename }, 32 | }); 33 | } 34 | } 35 | 36 | //────────────────────────────────────────────────────────────────────────────── 37 | 38 | /** gets subreddit icon 39 | * @param {string} iconPath 40 | * @param {string} subredditName 41 | */ 42 | function cacheAndReturnSubIcon(iconPath, subredditName) { 43 | // HACK reddit API does not like curl (lol) 44 | const redditApiCall = `curl -sL -H "User-Agent: Chrome/115.0.0.0" "https://www.reddit.com/r/${subredditName}/about.json"`; 45 | const subredditInfo = JSON.parse(app.doShellScript(redditApiCall)); 46 | if (subredditInfo.error) { 47 | console.log(`${subredditInfo.error}: ${subredditInfo.message}`); 48 | return false; 49 | } 50 | 51 | // for some subreddits saved as icon_img, for others as community_icon 52 | let onlineIcon = subredditInfo.data.icon_img || subredditInfo.data.community_icon; 53 | if (!onlineIcon) return true; // has no icon 54 | onlineIcon = onlineIcon.replace(/\?.*$/, ""); // clean url for curl 55 | 56 | // cache icon 57 | app.doShellScript(`curl -sL "${onlineIcon}" --create-dirs --output "${iconPath}"`); 58 | return true; 59 | } 60 | 61 | /** @param {string} subredditName */ 62 | function cacheAndReturnSubCount(subredditName) { 63 | const redditApiCall = `curl -sL -H "User-Agent: Chrome/115.0.0.0" "https://www.reddit.com/r/${subredditName}/about.json"`; 64 | const subredditInfo = JSON.parse(app.doShellScript(redditApiCall)); 65 | if (subredditInfo.error) { 66 | console.log(`${subredditInfo.error}: ${subredditInfo.message}`); 67 | return undefined; 68 | } 69 | 70 | ensureCacheFolderExists(); 71 | const subscriberCount = subredditInfo.data.subscribers 72 | .toString() 73 | .replace(/\B(?=(\d{3})+(?!\d))/g, " "); 74 | const subscriberData = JSON.parse( 75 | readFile($.getenv("alfred_workflow_cache") + "/subscriberCount.json") || "{}", 76 | ); 77 | subscriberData[subredditName] = subscriberCount; 78 | writeToFile( 79 | `${$.getenv("alfred_workflow_cache")}/subscriberCount.json`, 80 | JSON.stringify(subscriberData), 81 | ); 82 | return subscriberCount; // = no error 83 | } 84 | 85 | //────────────────────────────────────────────────────────────────────────────── 86 | 87 | /** @type {AlfredRun} */ 88 | // biome-ignore lint/correctness/noUnusedVariables: Alfred run 89 | function run() { 90 | const iconFolder = $.getenv("custom_subreddit_icons") || $.getenv("alfred_workflow_data"); 91 | const subredditConfig = $.getenv("subreddits") 92 | .trim() 93 | .replace(/^\/?r\//gm, ""); // can be `r/` or `/r/` https://www.alfredforum.com/topic/20813-reddit-browser/page/2/#comment-114645 94 | 95 | const subreddits = subredditConfig.split("\n").map((subredditName) => { 96 | let subtitle = ""; 97 | 98 | // cache subreddit image 99 | let iconPath = `${iconFolder}/${subredditName}.png`; 100 | if (!fileExists(iconPath)) { 101 | const success = cacheAndReturnSubIcon(iconPath, subredditName); 102 | if (!fileExists(iconPath)) iconPath = "icon.png"; // if icon cannot be cached, use default 103 | if (!success) subtitle += "⚠️ subreddit icon error "; 104 | } 105 | 106 | // subscriber count 107 | const subscriberData = JSON.parse( 108 | readFile($.getenv("alfred_workflow_cache") + "/subscriberCount.json") || "{}", 109 | ); 110 | const subscriberCount = subscriberData[subredditName] || cacheAndReturnSubCount(subredditName); 111 | if (!subscriberCount) subtitle += "⚠️ subscriber count error "; 112 | subtitle += `👥 ${subscriberCount}`; 113 | 114 | /** @type AlfredItem */ 115 | const alfredItem = { 116 | title: `r/${subredditName}`, 117 | subtitle: subtitle, 118 | arg: subredditName, 119 | icon: { path: iconPath }, 120 | mods: { 121 | cmd: { arg: `https://www.reddit.com/r/${subredditName}/` }, 122 | }, 123 | }; 124 | return alfredItem; 125 | }); 126 | 127 | // add hackernews as pseudo-subreddit 128 | const addHackernews = $.getenv("add_hackernews") === "1"; 129 | if (addHackernews) { 130 | subreddits.push({ 131 | title: "Hackernews", 132 | arg: "hackernews", 133 | icon: { path: "hackernews.png" }, 134 | mods: { 135 | cmd: { arg: "https://news.ycombinator.com/" }, 136 | }, 137 | }); 138 | } 139 | 140 | return JSON.stringify({ items: subreddits }); 141 | } 142 | --------------------------------------------------------------------------------