├── .github └── workflows │ └── continuous_integration.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── images ├── list.png ├── reader-terminal.png └── table.png ├── pyproject.toml ├── readercli ├── __init__.py ├── __main__.py ├── api.py ├── commands.py ├── constants.py ├── data.py ├── layout.py ├── models.py ├── py.typed ├── reading_list │ ├── __init__.py │ └── extractors.py └── utils.py ├── requirements.txt ├── tests ├── __init__.py ├── resources │ ├── sample_api_results │ │ ├── document.json │ │ ├── highlghts.json │ │ └── notes.json │ └── sample_reading_lists │ │ ├── BadURL.html │ │ ├── CustomReadingList.html │ │ └── test-file.csv ├── test_api.py ├── test_layout.py └── test_utils.py └── tox.ini /.github/workflows/continuous_integration.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | 14 | name: Unit tests | Python ${{ matrix.python }} 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python: ["3.10", "3.11"] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Setup Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install tox and any other packages 27 | run: pip install tox 28 | - name: Run tox 29 | # Run tox using the version of Python in `PATH` 30 | run: tox -e py 31 | - name: Upload coverage to Codecov 32 | uses: codecov/codecov-action@v3 33 | env: 34 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 35 | with: 36 | file: coverage.xml 37 | 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | #VSCode 163 | .vscode 164 | 165 | #Other 166 | .DS_Store 167 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.7.0 4 | hooks: 5 | - id: black -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Scott S. Carvalho 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test clean 2 | 3 | test: 4 | pytest 5 | 6 | clean: clean-dist 7 | rm -rf __pycache__ .pytest_cache .mypy_cache ./**/__pycache__ 8 | rm -f .coverage coverage.xml ./**/*.pyc 9 | rm -rf .tox 10 | 11 | clean-dist: 12 | rm -rf dist readwise_reader_cli.egg-info 13 | 14 | check-dist: 15 | twine check dist/* 16 | 17 | build-dist: clean-dist 18 | python -m build 19 | 20 | upload-dist: 21 | twine upload dist/* 22 | 23 | upload-test-dist: 24 | twine upload -r testpypi dist/* 25 | 26 | test-publish: test clean-dist build-dist check-dist upload-test-dist clean-dist 27 | 28 | publish: test clean-dist build-dist upload-dist check-dist 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reader API Command-Line Interface 2 | 3 | ![](./images/reader-terminal.png) 4 | 5 | This repository provides a command-line interface (CLI) for interacting with [Readwise's Reader API](https://readwise.io/reader_api). This tool allows you to interact with the API directly from your command line, making it easy to `add` and `list` documents from your Reader library. 6 | 7 | Also, you can `upload` documents from your browser reading list, such as Chrome ReadingList. 8 | 9 | Please note that future updates will include support for additional browsers. 10 | 11 | ## Installation 12 | 13 | Set up a virtual environment and then run: 14 | 15 | ```bash 16 | pip install readwise-reader-cli 17 | ``` 18 | 19 | ## Usage 20 | 21 | Before using the CLI, make sure to set the READER_API_TOKEN environment variable. You can obtain your API token [here](https://readwise.io/access_token). 22 | 23 | ```bash 24 | export READER_API_TOKEN={your_api_token} 25 | ``` 26 | 27 | The CLI provides the following commands: 28 | 29 | ```bash 30 | Usage: python -m readercli [OPTIONS] COMMAND [ARGS]... 31 | 32 | Interact with your Reader Library 33 | 34 | Options: 35 | --help Show this message and exit. 36 | 37 | Commands: 38 | add Add Document 39 | lib Library breakdown 40 | list List Documents 41 | upload Upload Reading List File 42 | validate Validate token 43 | ``` 44 | 45 | ### List Documents 46 | 47 | ```bash 48 | Usage: python -m readercli list [OPTIONS] 49 | 50 | List Documents 51 | 52 | Options: 53 | -l, --location [new|archive|later|feed] 54 | Document(s) location 55 | -c, --category [article|tweet|pdf|epub|email|note|video|highlight|rss] 56 | Document(s) category 57 | -a, --update-after [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S] 58 | Updated after date in ISO format. Default: 59 | last 24hrs. 60 | -d, --date-range TEXT View documents updated after choosen time: 61 | today, week, month. 62 | -L, --layout [table|list] Display documents either as a list or table. 63 | Default: table. 64 | -n, --num-results INTEGER The number of documents to show. 65 | -P, --pager Use to page output. 66 | --help Show this message and exit. 67 | ``` 68 | 69 | Examples: 70 | 71 | ```bash 72 | python -m readercli list --location archive 73 | ``` 74 | 75 | ```bash 76 | python -m readercli list --location archive --category article 77 | ``` 78 | 79 | ```bash 80 | python -m readercli list --location archive --category article --update-after 2023-01-01 81 | ``` 82 | 83 | ```bash 84 | python -m readercli list --location archive --category article --update-after 2023-01-01 --layout list 85 | ``` 86 | 87 | ```bash 88 | python -m readercli list --location archive --category article --date-range week 89 | ``` 90 | 91 | ### Layouts 92 | 93 | ![table](./images/table.png) 94 | 95 | ![list_table](./images/list.png) 96 | 97 | ### Upload a Reading List (Google Chrome support only) 98 | 99 | **THINGS TO NOTE:** 100 | 101 | - **RATE LIMIT - Due to Reader's API rate limit of 20 requests per minute, a larger list will take a few minutes to upload.** 102 | 103 | - **LACK OF READING LIST APIs - There is no API to pull your ReadingList from Google, but it is being looked at [here](https://bugs.chromium.org/p/chromium/issues/detail?id=1238372).** 104 | 105 | To `upload` your Chrome Reading List, you first need to download your data from your account, then follow these steps: 106 | 107 | 1. Navigate to the [Data & Privacy](https://myaccount.google.com/data-and-privacy) section. 108 | 2. Find the "Download your data" option and click on it. 109 | 3. A list of data to export will appear. Click "Deselect all" and then locate the Chrome section. 110 | 4. Click "All Chrome data Included" and select ONLY "ReadingList". 111 | 5. Save the downloaded `.html` file to your preferred directory and take note of the file path. 112 | 6. Run the `import` command. 113 | 114 | ```bash 115 | Usage: python -m readercli upload [OPTIONS] INPUT_FILE 116 | 117 | Upload Reading List File 118 | 119 | Options: 120 | --file-type [html|csv] 121 | --help Show this message and exit. 122 | ``` 123 | 124 | Examples: 125 | 126 | ```bash 127 | python -m readercli upload /path/to/ReadingList.html 128 | ``` 129 | 130 | ```bash 131 | python -m readercli upload --file-type csv /path/to/ReadingList.csv 132 | ``` 133 | 134 | ### Add Document 135 | 136 | ```bash 137 | Usage: python -m readercli add [OPTIONS] URL 138 | 139 | Add Document 140 | 141 | Options: 142 | --help Show this message and exit. 143 | ``` 144 | 145 | Example: 146 | 147 | ```bash 148 | python -m readercli add http://www.example.com 149 | ``` 150 | 151 | ### Library Overview 152 | 153 | ```bash 154 | Usage: python -m readercli lib [OPTIONS] 155 | 156 | Library breakdown 157 | 158 | Options: 159 | -V, --view [category|location|tags] 160 | --help Show this message and exit. 161 | ``` 162 | 163 | ```bash 164 | python -m readercli lib 165 | 166 | Category Breakdown 167 | ┏━━━━━━━━━━━━━┳━━━━━━━┓ 168 | ┃ Name ┃ Count ┃ 169 | ┡━━━━━━━━━━━━━╇━━━━━━━┩ 170 | │ 🖍️ highlight│ 724 │ 171 | │ 📡️ rss │ 391 │ 172 | │ ✉️ email │ 363 │ 173 | │ 📰️ article │ 264 │ 174 | │ 📝️ note │ 140 │ 175 | │ 📄️ pdf │ 83 │ 176 | │ 🐦️ tweet │ 25 │ 177 | │ 📹️ video │ 10 │ 178 | │ 📖️ epub │ 0 │ 179 | └─────────────┴───────┘ 180 | 181 | python -m readercli lib --view [location | tags] 182 | 183 | Location Breakdown 184 | ┏━━━━━━━━━━━┳━━━━━━━┓ 185 | ┃ Name ┃ Count ┃ 186 | ┡━━━━━━━━━━━╇━━━━━━━┩ 187 | │ 🗄️ archive│ 1124 │ 188 | │ 🕑️ later │ 241 │ 189 | │ ⭐️ new │ 10 │ 190 | │ 📥️ feed │ 2 │ 191 | └───────────┴───────┘ 192 | 193 | python -m readercli lib --view tags 194 | 195 | Tags Breakdown 196 | ┏━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓ 197 | ┃ Name ┃ Count ┃ 198 | ┡━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩ 199 | │ python │ 32 │ 200 | │ documentation │ 9 │ 201 | │ programming │ 7 │ 202 | │ github │ 7 │ 203 | │ git │ 6 │ 204 | │ packages │ 6 │ 205 | │ design-patterns │ 6 │ 206 | │ mac │ 1 │ 207 | └────────────────────────┴───────┘ 208 | ``` 209 | 210 | ### Validate Token 211 | 212 | ```bash 213 | Usage: python -m readercli validate [OPTIONS] TOKEN 214 | 215 | Validate token 216 | 217 | Options: 218 | --help Show this message and exit. 219 | ``` 220 | 221 | ## Main Third-Party Libraries 222 | 223 | - [click](https://github.com/pallets/click) 224 | - [pydantic](https://github.com/pydantic/pydantic) 225 | - [requests](https://github.com/psf/requests) 226 | - [rich](https://github.com/Textualize/rich) 227 | 228 | ## Inspiration 229 | 230 | - [starcli](https://github.com/hedyhli/starcli) 231 | 232 | ## License 233 | 234 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 235 | -------------------------------------------------------------------------------- /images/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scarvy/readwise-reader-cli/90de5a30890f307088c5fa1e3d75bd9aec79a69d/images/list.png -------------------------------------------------------------------------------- /images/reader-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scarvy/readwise-reader-cli/90de5a30890f307088c5fa1e3d75bd9aec79a69d/images/reader-terminal.png -------------------------------------------------------------------------------- /images/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scarvy/readwise-reader-cli/90de5a30890f307088c5fa1e3d75bd9aec79a69d/images/table.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "readwise-reader-cli" 7 | description = "Engage with your Readwise Reader library through your command-line." 8 | readme = "README.md" 9 | authors = [{name = "Scott Carvalho", email = "scottcarvalho71@gmail.com"}] 10 | license = { file = "LICENSE" } 11 | dynamic = ["version"] 12 | classifiers = [ 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3 :: Only", 21 | ] 22 | 23 | keywords = ["api", "readwise", "cli", "python"] 24 | dependencies = [ 25 | "click >= 8.1.3", 26 | "python-dateutil", 27 | "requests", 28 | "beautifulsoup4", 29 | "python-dotenv", 30 | "xdg-base-dirs", 31 | "rich", 32 | "pydantic" 33 | ] 34 | 35 | requires-python = ">=3.8" 36 | 37 | [project.optional-dependencies] 38 | test = [ 39 | "pre-commit", 40 | "pytest", 41 | ] 42 | dev = [ 43 | "black", 44 | "isort", 45 | ] 46 | 47 | [project.urls] 48 | Homepage = "https://github.com/Scarvy/readwise-reader-cli" 49 | 50 | [project.scripts] 51 | calc = "readercli.__main__:cli" 52 | 53 | [tool.flit.sdist] 54 | exclude = [ 55 | "tests/resources/", 56 | ] 57 | 58 | [tool.flit.module] 59 | name = "readercli" -------------------------------------------------------------------------------- /readercli/__init__.py: -------------------------------------------------------------------------------- 1 | """Readwise Reader CLI""" 2 | __version__ = "1.0.0" 3 | -------------------------------------------------------------------------------- /readercli/__main__.py: -------------------------------------------------------------------------------- 1 | """The main CLI module of readercli""" 2 | import click 3 | 4 | from . import commands 5 | 6 | 7 | @click.group(help="Interact with your Reader Library") 8 | def cli(): 9 | pass 10 | 11 | 12 | # Commands 13 | cli.add_command(commands.add) # Add command 14 | cli.add_command(commands.list) # List command 15 | cli.add_command(commands.lib) # Library command 16 | cli.add_command(commands.upload) # Upload command 17 | cli.add_command(commands.validate) # Validate command 18 | 19 | if __name__ == "__main__": 20 | cli() 21 | -------------------------------------------------------------------------------- /readercli/api.py: -------------------------------------------------------------------------------- 1 | """Provides code to fetch and manage document information.""" 2 | import logging 3 | import os 4 | import time 5 | from datetime import datetime 6 | from functools import wraps 7 | from typing import Dict, Iterable, List, Optional, Tuple, Union 8 | 9 | import dotenv 10 | import requests 11 | import urllib3 12 | from click import secho 13 | from requests import Response 14 | 15 | from .constants import ( 16 | AUTH_TOKEN_URL, 17 | BASE_URL, 18 | CREATE_ENDPOINT, 19 | LIST_ENDPOINT, 20 | TOKEN_URL, 21 | ) 22 | from .models import CategoryEnum, DocumentInfo, ListParameters, LocationEnum 23 | 24 | urllib3.disable_warnings() 25 | dotenv.load_dotenv() 26 | 27 | STATUS_ACTIONS = { 28 | "invalid_params": "Invalid request. Modify request before sending again.", 29 | "invalid_token": f"Invalid token - check your token at {TOKEN_URL}", 30 | "retry": "Too many requests. Retring in {} seconds...", 31 | "unknown": "Unkown request error.", 32 | } 33 | 34 | HTTP_CODE_HANDLING = { 35 | 200: "valid", 36 | 201: "valid", 37 | 204: "valid_token", 38 | 400: "invalid_params", 39 | 401: "invalid_token", 40 | 429: "retry", 41 | } 42 | 43 | 44 | def build_log_message(func, *args, **kwargs): 45 | if func.__name__ == "list_documents": 46 | request_type = "GET" 47 | category = kwargs.get("category") 48 | location = kwargs.get("location") 49 | updated_after = kwargs.get("updated_after") 50 | msg = f"Making {request_type} request - parameters: category {category} location {location} updated-after {updated_after}" 51 | elif func.__name__ == "add_document": 52 | request_type = "POST" 53 | doc_info = kwargs.get("doc_info") 54 | url = doc_info.url 55 | msg = f"Making {request_type} request - document info: URL {str(url)}" 56 | elif func.__name__ == "validate_token": 57 | request_type = "GET" 58 | token = kwargs.get("token") 59 | msg = f"Making {request_type} request - Token: {token}" 60 | else: 61 | request_type = "Unknown" 62 | msg = f"Making {request_type} request" 63 | return msg 64 | 65 | 66 | def log(func): 67 | @wraps(func) 68 | def logger(*args, **kwargs): 69 | debug = kwargs.pop("debug", False) 70 | if debug: 71 | logging.basicConfig( 72 | level=logging.DEBUG, 73 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 74 | ) 75 | logger = logging.getLogger(__name__) 76 | 77 | msg = build_log_message(func, *args, **kwargs) 78 | 79 | logger.debug(msg) 80 | 81 | result = func(*args, **kwargs) 82 | 83 | return result 84 | 85 | return logger 86 | 87 | 88 | def list_parameter_jsonify(params: ListParameters) -> Dict[str, Union[str, None]]: 89 | return params.model_dump(exclude_unset=True, mode="json", by_alias=True) 90 | 91 | 92 | def doc_info_jsonify(doc_info: DocumentInfo) -> Dict[str, Union[str, None]]: 93 | return doc_info.model_dump(exclude_unset=True, mode="json") 94 | 95 | 96 | def _get_list(params: Dict[str, Union[str, None]]) -> Response: 97 | resp = requests.get( 98 | url=f"{BASE_URL}{LIST_ENDPOINT}", 99 | params=params, 100 | headers={"Authorization": f"Token {os.getenv('READER_API_TOKEN')}"}, 101 | verify=False, 102 | ) 103 | return resp 104 | 105 | 106 | def _create_doc(info: Dict[str, Union[str, None]]) -> Response: 107 | resp = requests.post( 108 | url=f"{BASE_URL}{CREATE_ENDPOINT}", 109 | headers={"Authorization": f"Token {os.getenv('READER_API_TOKEN')}"}, 110 | json=info, 111 | ) 112 | return resp 113 | 114 | 115 | def _handle_http_status( 116 | resp: Response, retry_after_default: int = 5 117 | ) -> Tuple[str, int]: 118 | handling_code = HTTP_CODE_HANDLING.get(resp.status_code, "unknown") 119 | retry_after = int(resp.headers.get("Retry-After", retry_after_default)) 120 | return handling_code, retry_after 121 | 122 | 123 | def _fetch_results( 124 | params: Dict[str, Union[str, None]], retry_after_default: int = 5 125 | ) -> Iterable[List[dict]]: 126 | next_page_cursor = None 127 | while True: 128 | params["pageCursor"] = next_page_cursor 129 | 130 | resp = _get_list(params=params) 131 | 132 | handling_code, retry_after = _handle_http_status(resp, retry_after_default) 133 | 134 | if not handling_code == "valid": 135 | if handling_code == "retry": 136 | time.sleep(retry_after) 137 | msg = STATUS_ACTIONS[handling_code] 138 | secho(msg.format(retry_after), fg="bright_yellow") 139 | elif handling_code in STATUS_ACTIONS: 140 | msg = STATUS_ACTIONS[handling_code] 141 | secho(msg, fg="yellow") 142 | break 143 | else: 144 | break 145 | 146 | yield resp.json().get("results", []) 147 | 148 | next_page_cursor = resp.json().get("nextPageCursor") 149 | if not next_page_cursor: 150 | break 151 | 152 | 153 | @log 154 | def list_documents( 155 | id: Optional[str] = None, 156 | category: Optional[CategoryEnum] = None, 157 | location: Optional[LocationEnum] = None, 158 | updated_after: Optional[datetime] = None, 159 | debug: bool = False, 160 | ) -> Optional[List[DocumentInfo]]: 161 | """Fetches a list of `DocumentInfo` objects. 162 | 163 | Args: 164 | id (str, optional): document unique identifier 165 | category (str, optional): The category to filter documents by 166 | location (str, optional): The location to filter documents by 167 | updated_after (datetime, optional): Update after datetime object 168 | 169 | Returns: 170 | List[DocumentInfo]: A list of `DocumentInfo` objects 171 | """ 172 | 173 | params = list_parameter_jsonify( 174 | ListParameters( 175 | id=id, 176 | category=category, 177 | location=location, 178 | update_after=updated_after, 179 | next_page_cursor=None, 180 | ) 181 | ) 182 | 183 | return [ 184 | DocumentInfo(**doc_info) 185 | for results in _fetch_results(params=params) 186 | for doc_info in results 187 | ] 188 | 189 | 190 | @log 191 | def add_document(doc_info: DocumentInfo, debug: bool = False) -> Response: 192 | """Adds a document to a users Reader account. 193 | 194 | Args: 195 | doc_info (dict): `DocumentInfo` object 196 | """ 197 | 198 | doc_info_json = doc_info_jsonify(doc_info=doc_info) 199 | 200 | while True: 201 | resp = _create_doc(info=doc_info_json) 202 | 203 | handling_code, retry_after = _handle_http_status(resp=resp) 204 | 205 | if not handling_code == "valid": 206 | if handling_code == "retry": 207 | time.sleep(retry_after) 208 | msg = STATUS_ACTIONS[handling_code] 209 | secho(msg.format(retry_after), fg="bright_yellow") 210 | else: 211 | msg = STATUS_ACTIONS[handling_code] 212 | secho(msg, fg="bright_red") 213 | break 214 | else: 215 | break 216 | return resp 217 | 218 | 219 | @log 220 | def validate_token(token: str, debug: bool = False) -> bool: 221 | """Check that a token is valid.""" 222 | 223 | response = requests.get( 224 | AUTH_TOKEN_URL, 225 | headers={"Authorization": f"Token {token}"}, 226 | ) 227 | handling_code = HTTP_CODE_HANDLING[response.status_code] 228 | if not handling_code == "valid_token": 229 | invalid_token_msg = STATUS_ACTIONS[handling_code] 230 | secho(invalid_token_msg, fg="bright_red") 231 | return False 232 | return True 233 | -------------------------------------------------------------------------------- /readercli/commands.py: -------------------------------------------------------------------------------- 1 | """Subcommands of the main CLI module""" 2 | import json 3 | import os 4 | from datetime import datetime, timedelta 5 | 6 | import click 7 | from click import secho 8 | from xdg_base_dirs import xdg_data_home 9 | 10 | from .api import add_document, list_documents, validate_token 11 | from .constants import VALID_CATEGORY_OPTIONS, VALID_LOCATION_OPTIONS 12 | from .data import fetch_full_library 13 | from .layout import print_results, print_view_results 14 | from .models import DocumentInfo 15 | from .reading_list import build_reading_list 16 | from .utils import ( 17 | batch_add_documents, 18 | convert_date_range, 19 | count_category_values, 20 | count_location_values, 21 | count_tag_values, 22 | ) 23 | 24 | DEFAULT_CATEGORY_NAME = "all" 25 | 26 | CACHE_DIR = xdg_data_home() / "reader" 27 | CACHED_RESULT_PATH = CACHE_DIR / "library.json" 28 | CACHE_EXPIRATION = 1 # Minutes 29 | 30 | 31 | @click.command(help="List Documents") 32 | @click.option( 33 | "--location", 34 | "-l", 35 | type=click.Choice(tuple(VALID_LOCATION_OPTIONS), case_sensitive=True), 36 | help="Document(s) location", 37 | ) 38 | @click.option( 39 | "--category", 40 | "-c", 41 | type=click.Choice(tuple(VALID_CATEGORY_OPTIONS), case_sensitive=True), 42 | help="Document(s) category", 43 | ) 44 | @click.option( 45 | "--update-after", 46 | "-a", 47 | default=(datetime.now() - timedelta(days=1)), 48 | type=click.DateTime(), 49 | help="Updated after date in ISO format. Default: last 24hrs.", 50 | ) 51 | @click.option( 52 | "--date-range", 53 | "-d", 54 | type=str, 55 | help="View documents updated after choosen time: day, week, month.", 56 | ) 57 | @click.option( 58 | "--layout", 59 | "-L", 60 | type=click.Choice(["table", "list"], case_sensitive=True), 61 | help="Display documents either as a list or table. Default: table.", 62 | ) 63 | @click.option( 64 | "--num-results", 65 | "-n", 66 | type=int, 67 | help="The number of documents to show.", 68 | ) 69 | @click.option("--pager", "-P", is_flag=True, default=False, help="Use to page output.") 70 | @click.option("--debug", is_flag=True, default=False, hidden=True) 71 | @click.option( # Don't hit Reader API 72 | "--no-api", 73 | is_flag=True, 74 | default=False, 75 | hidden=True, 76 | ) 77 | def list( 78 | location, 79 | category, 80 | update_after, 81 | date_range, 82 | layout, 83 | num_results, 84 | pager=False, 85 | debug=False, 86 | no_api=False, 87 | ): 88 | if date_range: 89 | update_after = convert_date_range(date_range=date_range) 90 | 91 | update_after_str = update_after.strftime("%Y-%m-%d") 92 | 93 | options_key = f"{location}_{(DEFAULT_CATEGORY_NAME if not category else category)}_{update_after_str}" 94 | 95 | if no_api: # check options_key 96 | click.echo(options_key) 97 | 98 | tmp_docs = None 99 | 100 | if os.path.exists(CACHED_RESULT_PATH): 101 | with open(CACHED_RESULT_PATH, "r") as f: 102 | json_file = json.load(f) 103 | result = json_file.get(options_key) 104 | if result: 105 | t = result[-1].get("time") 106 | time = datetime.strptime(t, "%Y-%m-%d %H:%M:%S.%f") 107 | diff = datetime.now() - time 108 | if diff < timedelta(minutes=CACHE_EXPIRATION): 109 | if debug: 110 | print("Using cache") 111 | tmp_docs = result 112 | 113 | if not tmp_docs: # If cache expired or results not yet cached 114 | if no_api: 115 | return 116 | 117 | tmp_docs = list_documents( 118 | category=category, 119 | location=location, 120 | updated_after=update_after, 121 | debug=debug, 122 | ) 123 | 124 | if len(tmp_docs) == 0: # if list of documents is empty 125 | return 126 | 127 | else: # Cache documents 128 | tmp_docs = [doc.model_dump(mode="json") for doc in tmp_docs] 129 | 130 | tmp_docs.append({"time": str(datetime.now())}) 131 | os.makedirs(CACHE_DIR, exist_ok=True) 132 | 133 | with open(CACHED_RESULT_PATH, "a+") as f: 134 | if os.path.getsize(CACHED_RESULT_PATH) == 0: # file is empty 135 | result_dict = {options_key: tmp_docs} 136 | f.write(json.dumps(result_dict, indent=4)) 137 | else: 138 | f.seek(0) 139 | result_dict = json.load(f) 140 | result_dict[options_key] = tmp_docs 141 | f.truncate(0) 142 | f.write(json.dumps(result_dict, indent=4)) 143 | 144 | if num_results: 145 | docs = tmp_docs[ 146 | 0 : max(1, num_results) 147 | ] # Prevent removing all documents from the list 148 | else: 149 | docs = tmp_docs[:-1] # Slice off the time key before passing to layout 150 | 151 | print_results(docs, page=pager, layout=layout, category=category) 152 | 153 | 154 | @click.command(help="Library breakdown") 155 | @click.option( 156 | "--view", 157 | "-V", 158 | default="category", 159 | type=click.Choice(["category", "location", "tags"], case_sensitive=True), 160 | ) 161 | @click.option("--debug", is_flag=True, default=False, hidden=True) 162 | def lib(view, debug=False): 163 | full_data = fetch_full_library(debug=debug) 164 | 165 | if full_data: 166 | if view == "location": 167 | stats = count_location_values(full_data) 168 | elif view == "tags": 169 | stats = count_tag_values(full_data) 170 | else: 171 | stats = count_category_values(full_data) 172 | 173 | print_view_results(stats=stats, view=view) 174 | else: 175 | print("Library is empty.") 176 | 177 | 178 | @click.command(help="Add Document") 179 | @click.argument("url") 180 | @click.option("--debug", is_flag=True, default=False, hidden=True) 181 | def add(url, debug=False): 182 | response = add_document(doc_info=DocumentInfo(url=url), debug=debug) 183 | if response.status_code == 200: 184 | secho("Already Exists.", fg="yellow") 185 | else: 186 | secho("Added!", fg="bright_green") 187 | 188 | 189 | @click.command(help="Upload Reading List File") 190 | @click.argument("input_file", type=click.Path(exists=True)) 191 | @click.option("--file-type", type=click.Choice(["html", "csv"]), default="html") 192 | @click.option("--debug", is_flag=True, default=False, hidden=True) 193 | def upload(input_file, file_type, debug=False): 194 | click.echo(f"Adding Document(s) from: {input_file}") 195 | 196 | reading_list = build_reading_list(input_file=input_file, file_type=file_type) 197 | 198 | batch_add_documents(reading_list, debug=debug) 199 | 200 | 201 | @click.command(help="Validate token") 202 | @click.argument("token", type=str) 203 | @click.option("--debug", is_flag=True, default=False, hidden=True) 204 | def validate(token, debug=False): 205 | is_valid = validate_token(token=token, debug=debug) 206 | if is_valid: 207 | secho("Token is valid", fg="bright_green") 208 | -------------------------------------------------------------------------------- /readercli/constants.py: -------------------------------------------------------------------------------- 1 | VALID_LOCATION_OPTIONS = {"new", "later", "archive", "feed"} 2 | VALID_CATEGORY_OPTIONS = { 3 | "article", 4 | "email", 5 | "rss", 6 | "highlight", 7 | "note", 8 | "pdf", 9 | "epub", 10 | "tweet", 11 | "video", 12 | } 13 | 14 | TOKEN_URL = "https://readwise.io/access_token" 15 | 16 | BASE_URL = "https://readwise.io/api/v3/" 17 | 18 | AUTH_TOKEN_URL = "https://readwise.io/api/v2/auth/" 19 | LIST_ENDPOINT = "list" 20 | CREATE_ENDPOINT = "save" 21 | -------------------------------------------------------------------------------- /readercli/data.py: -------------------------------------------------------------------------------- 1 | """Provides code to fetch all documents, notes, and highlights from a user's Reader Library.""" 2 | 3 | import json 4 | import os 5 | from datetime import datetime, timedelta 6 | from typing import List, Optional 7 | 8 | from xdg_base_dirs import xdg_data_home 9 | 10 | from .api import list_documents 11 | from .models import DocumentInfo 12 | 13 | CACHE_DIR = xdg_data_home() / "reader" 14 | CACHED_RESULT_PATH = CACHE_DIR / "full_library.json" 15 | CACHE_EXPIRATION = 1 # Day 16 | 17 | 18 | def todays_date(): 19 | now = datetime.now() 20 | return now.strftime("%Y-%m-%d") 21 | 22 | 23 | def load_library(date: str) -> List[dict]: 24 | with open(CACHED_RESULT_PATH, "r") as f: 25 | json_file = json.load(f) 26 | return json_file.get(date) 27 | 28 | 29 | def get_cache_time(cache: list[dict]) -> datetime | None: 30 | t = cache[-1].get("time") 31 | if t: 32 | return datetime.strptime(t, "%Y-%m-%d %H:%M:%S.%f") 33 | return t 34 | 35 | 36 | def use_cache(t: datetime) -> bool: 37 | diff = datetime.now() - t 38 | if diff < timedelta(days=CACHE_EXPIRATION): 39 | return True 40 | return False 41 | 42 | 43 | def fetch_full_library(debug=False) -> Optional[List[DocumentInfo]]: 44 | """Fetch the full library including documents, notes, and highlights. 45 | 46 | Returns: 47 | List[DocumentInfo]: A list of `DocumentInfo` objects. 48 | """ 49 | 50 | tmp_library: Optional[List[DocumentInfo]] = None 51 | 52 | today = todays_date() 53 | 54 | if os.path.exists(CACHED_RESULT_PATH): 55 | result = load_library(date=today) 56 | if result: 57 | time = get_cache_time(cache=result) 58 | if not time: 59 | raise ValueError(time) 60 | if use_cache(t=time): 61 | if debug: 62 | print("Using cache") 63 | tmp_library = [DocumentInfo(**doc_info) for doc_info in result[:-1]] 64 | 65 | if tmp_library is None: 66 | tmp_library = list_documents( 67 | debug=debug 68 | ) # fetch full library including all documents, notes, and highlights 69 | 70 | if tmp_library is None or len(tmp_library) == 0: 71 | return tmp_library 72 | else: 73 | tmp_library_json = [doc.model_dump(mode="json") for doc in tmp_library] 74 | 75 | tmp_library_json.append({"time": str(datetime.now())}) 76 | os.makedirs(CACHE_DIR, exist_ok=True) 77 | 78 | with open(CACHED_RESULT_PATH, "a+") as f: 79 | if os.path.getsize(CACHED_RESULT_PATH) == 0: # file is empty 80 | result_dict = {today: tmp_library_json} 81 | f.write(json.dumps(result_dict, indent=4)) 82 | else: 83 | f.seek(0) 84 | result_dict = json.load(f) 85 | result_dict[today] = tmp_library_json 86 | f.truncate(0) 87 | f.write(json.dumps(result_dict, indent=4)) 88 | 89 | full_library = tmp_library 90 | 91 | return full_library 92 | -------------------------------------------------------------------------------- /readercli/layout.py: -------------------------------------------------------------------------------- 1 | """Provides code to print layouts to the command-line.""" 2 | 3 | from datetime import datetime 4 | from typing import Dict, List, Union 5 | 6 | from dateutil import parser, tz 7 | from rich.align import Align 8 | from rich.console import Console, group 9 | from rich.rule import Rule 10 | from rich.table import Table 11 | from rich.text import Text 12 | 13 | console = Console() 14 | 15 | emoji_mapping_category = { 16 | "article": ":newspaper-emoji: article", 17 | "email": ":envelope-emoji: email", 18 | "rss": ":satellite_antenna-emoji: rss", 19 | "highlight": ":crayon-emoji: highlight", 20 | "note": ":memo-emoji: note", 21 | "pdf": ":page_facing_up-emoji: pdf", 22 | "epub": ":book-emoji: epub", 23 | "tweet": ":bird-emoji: tweet", 24 | "video": ":video_camera-emoji: video", 25 | } 26 | 27 | emoji_mapping_location = { 28 | "new": ":star-emoji: new", 29 | "later": ":clock2-emoji: later", 30 | "archive": ":file_cabinet-emoji: archive", 31 | "feed": ":inbox_tray-emoji: feed", 32 | } 33 | 34 | emoji_mapping = { 35 | "category": emoji_mapping_category, 36 | "location": emoji_mapping_location, 37 | } 38 | 39 | 40 | def format_reading_progress(reading_progress: float) -> str: 41 | """Format reading progress percentage""" 42 | 43 | percentage_str = f"{round(reading_progress * 100, 2)}%" 44 | return percentage_str 45 | 46 | 47 | def format_published_date(timestamp_miliseconds: Union[float, str]) -> str: 48 | """Format published date of a document""" 49 | 50 | if isinstance(timestamp_miliseconds, float): 51 | timestamp_seconds = ( 52 | timestamp_miliseconds / 1_000 53 | ) # Convert microseconds to seconds 54 | 55 | datetime_obj = datetime.fromtimestamp(timestamp_seconds, tz=tz.tzlocal()) 56 | 57 | return datetime_obj.strftime("%Y-%m-%d") 58 | 59 | elif isinstance(timestamp_miliseconds, str): 60 | return timestamp_miliseconds[:9] 61 | 62 | 63 | def format_updated_at_date(updated_at: str) -> str: 64 | """Format updated at date""" 65 | 66 | parsed_time = parser.isoparse(updated_at) 67 | 68 | local_time = parsed_time.astimezone(tz.tzlocal()) 69 | 70 | return local_time.strftime("%Y-%m-%d") 71 | 72 | 73 | def table_layout(documents: List[Dict], category: str = ""): 74 | """Displays documents in a table format using rich""" 75 | 76 | table = Table(leading=1) 77 | 78 | if category in ("note", "highlight"): 79 | table.add_column(":link: Highlight Link") 80 | table.add_column(":file_folder: Category", justify="center") 81 | table.add_column(":clipboard: Content") 82 | table.add_column(":label: Tags") 83 | table.add_column(":world_map: Location", justify="center") 84 | table.add_column(":clock1: Last Update", justify="right") 85 | 86 | for document in documents: 87 | ctgry: Union[Text, str] = ( 88 | emoji_mapping_category[document["category"]] 89 | if document["location"] 90 | else ":x: category" 91 | ) 92 | content = Text(document["content"], style="#e4938e") 93 | 94 | title = Text("link", style="#FFE761") 95 | title.stylize(f"#FFE761 link {document['url']}") 96 | 97 | if document["tags"]: 98 | doc_tags: List[str] = list(document["tags"].keys()) 99 | list_of_tags = ", ".join([tag for tag in doc_tags]) 100 | 101 | tags: Union[Text, str] = Text(list_of_tags, style="#5278FE") 102 | else: 103 | tags = ":x: tags" 104 | 105 | location = ( 106 | emoji_mapping_location[document["location"]] 107 | if document["location"] 108 | else ":x: None" 109 | ) 110 | 111 | last_update = Text( 112 | format_updated_at_date(document["updated_at"]), no_wrap=True 113 | ) 114 | 115 | table.add_row( 116 | title, 117 | ctgry, 118 | content, 119 | tags, 120 | location, 121 | last_update, 122 | ) 123 | 124 | else: 125 | # make the columns 126 | table.add_column(":bookmark: Title") 127 | table.add_column(":bust_in_silhouette: Author") 128 | table.add_column(":file_folder: Category", justify="center") 129 | table.add_column(":clipboard: Summary") 130 | table.add_column(":label: Tags") 131 | table.add_column(":world_map: Location", justify="center") 132 | table.add_column(":hourglass: Reading Progress", justify="right") 133 | table.add_column(":clock1: Last Update", justify="right") 134 | 135 | for document in documents: 136 | if ( 137 | document["category"] == "highlight" or document["category"] == "note" 138 | ): # skip highlights and notes 139 | continue 140 | author = ( 141 | Text(document["author"]) 142 | if document["author"] 143 | else Text("no author", style="italic #EF476F") 144 | ) 145 | ctgry = ( 146 | emoji_mapping_category[document["category"]] 147 | if document["category"] 148 | else Text("no category", style="italic") 149 | ) 150 | summary: Union[Text, str] = ( 151 | Text(document["summary"], style="#e4938e") 152 | if document["summary"] 153 | else ":x: no summary" 154 | ) 155 | 156 | reading_progress = Text( 157 | format_reading_progress(document["reading_progress"]), 158 | style="bold #06D6A0", 159 | ) 160 | 161 | title = ( 162 | Text(document["title"], style="#FFE761") 163 | if document["title"] 164 | else Text("no title", style="italic #FFE761") 165 | ) 166 | title.stylize(f"#FFE761 link {document['url']}") 167 | 168 | if document["tags"]: 169 | doc_tags = list(document["tags"].keys()) 170 | list_of_tags = ", ".join([tag for tag in doc_tags]) 171 | 172 | tags = Text(list_of_tags, style="#5278FE") 173 | else: 174 | tags = ":x: tags" 175 | 176 | location = ( 177 | emoji_mapping_location[document["location"]] 178 | if document["location"] 179 | else ":x: None" 180 | ) 181 | 182 | last_update = Text( 183 | format_updated_at_date(document["updated_at"]), no_wrap=True 184 | ) 185 | 186 | table.add_row( 187 | title, 188 | author, 189 | ctgry, 190 | summary, 191 | tags, 192 | location, 193 | reading_progress, 194 | last_update, 195 | ) 196 | 197 | console.print(table) 198 | 199 | 200 | def list_layout(documents: List[Dict], category: str = ""): 201 | """Display documents in a list layout using rich""" 202 | 203 | width = 88 204 | 205 | @group() 206 | def render_document(document): 207 | """Yields renderables for a single document.""" 208 | yield Rule(style="#FFE761") 209 | yield "" 210 | # Table with summary and reading progress 211 | title_table = Table.grid(padding=(0, 1)) 212 | title_table.expand = True 213 | title = Text(document["title"], overflow="fold", style="#FFE761") 214 | title.stylize(f"#FFE761 link {document['url']}") 215 | 216 | reading_progress = format_reading_progress(document["reading_progress"]) 217 | date_range_col = ( 218 | format_published_date(document["published_date"]) 219 | if document["published_date"] 220 | else "No Publish Date" 221 | ) 222 | 223 | title_table.add_row(title, Text(reading_progress, style="italic #06D6A0")) 224 | title_table.columns[1].no_wrap = True 225 | title_table.columns[1].justify = "right" 226 | yield title_table 227 | yield "" 228 | summary_table = Table.grid(padding=(0, 1)) 229 | summary_table.expand = True 230 | summary_col = ( 231 | Text(document["summary"], style="#e4938e") 232 | if document["summary"] 233 | else Text("no summary") 234 | ) 235 | summary_table.add_row(summary_col, date_range_col) 236 | summary_table.columns[1].justify = "right" 237 | yield summary_table 238 | yield "" 239 | # tags 240 | if document["tags"]: 241 | doc_tags = list(document["tags"].keys()) 242 | list_of_tags = ", ".join([tag for tag in doc_tags]) 243 | yield Text(list_of_tags, style="#5278FE") 244 | else: 245 | yield ":x: No tags" 246 | yield "" 247 | 248 | def column(renderable): 249 | """Constrain width and align to center to create a column.""" 250 | return Align.center(renderable, width=width, pad=False) 251 | 252 | for document in documents: 253 | if ( 254 | document["category"] == "highlight" or document["category"] == "note" 255 | ): # skip highlights and notes 256 | continue 257 | console.print(column(render_document(document))) 258 | console.print(column(Rule(style="#FFE761"))) 259 | 260 | 261 | def print_view_results(stats: Dict, view: str = ""): 262 | if not view == "tags": 263 | emojis = emoji_mapping[view] 264 | 265 | table = Table(title=f"{view.title()} Breakdown") 266 | 267 | sorted_tag_counts = dict( 268 | sorted(stats.items(), key=lambda item: item[1], reverse=True) 269 | ) 270 | 271 | table.add_column("Name", justify="left", no_wrap=True) 272 | table.add_column("Count", justify="right", style="cyan", no_wrap=True) 273 | 274 | for name, value in sorted_tag_counts.items(): 275 | if view == "tags": 276 | table.add_row(name, str(value)) 277 | else: 278 | table.add_row(emojis[name], str(value)) 279 | 280 | console = Console() 281 | console.print(table) 282 | 283 | 284 | def print_results( 285 | docuemnts: List[Dict], page=False, layout: str = "", category: str = "" 286 | ) -> None: 287 | """Use a layout to print or page the fetched documents""" 288 | if page: 289 | with console.pager(styles=True): 290 | print_layout(docuemnts, layout=layout, category=category) 291 | return 292 | print_layout(docuemnts, layout=layout, category=category) 293 | 294 | 295 | def print_layout(documents: List[Dict], category: str = "", layout: str = "table"): 296 | """Use listed layout""" 297 | if layout == "list": 298 | list_layout(documents, category=category) 299 | else: 300 | table_layout(documents, category=category) 301 | -------------------------------------------------------------------------------- /readercli/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from enum import Enum 3 | from typing import Dict, List, Optional, Union 4 | 5 | from pydantic import ( 6 | AnyUrl, 7 | BaseModel, 8 | Field, 9 | HttpUrl, 10 | field_serializer, 11 | field_validator, 12 | ) 13 | 14 | 15 | class LocationEnum(str, Enum): 16 | new = "new" 17 | later = "later" 18 | archive = "archive" 19 | feed = "feed" 20 | shortlist = "shortlist" 21 | 22 | 23 | class CategoryEnum(str, Enum): 24 | article = "article" 25 | email = "email" 26 | rss = "rss" 27 | highlight = "highlight" 28 | note = "note" 29 | pdf = "pdf" 30 | epub = "epub" 31 | tweet = "tweet" 32 | video = "video" 33 | 34 | 35 | class TagInfo(BaseModel): 36 | name: str 37 | type: str 38 | created: datetime 39 | 40 | 41 | class ListParameters(BaseModel): 42 | id: Optional[str] = None 43 | update_after: Optional[datetime] = Field(None, serialization_alias="updatedAfter") 44 | category: Optional[CategoryEnum] = None 45 | location: Optional[LocationEnum] = None 46 | next_page_cursor: Optional[str] = Field(None, serialization_alias="pageCursor") 47 | 48 | 49 | class DocumentInfo(BaseModel): 50 | id: Optional[str] = None 51 | url: HttpUrl 52 | title: Optional[str] = None 53 | author: Optional[str] = None 54 | source: Optional[str] = None 55 | category: Optional[CategoryEnum] = None 56 | location: Optional[LocationEnum] = None 57 | tags: Optional[Union[List[str], Dict[str, TagInfo]]] = None 58 | site_name: Optional[str] = None 59 | word_count: Optional[int] = None 60 | created_at: Optional[datetime] = None 61 | updated_at: Optional[datetime] = None 62 | published_date: Optional[Union[date, datetime]] = None 63 | summary: Optional[str] = None 64 | image_url: Optional[Union[AnyUrl, str, None]] = None 65 | content: Optional[str] = None 66 | source_url: Optional[AnyUrl] = None 67 | notes: Optional[str] = None 68 | parent_id: Optional[str] = None 69 | reading_progress: Optional[float] = 0.0 70 | 71 | @field_validator("reading_progress") 72 | def validate_reading_progress(cls, value): 73 | if not 0.0 <= value <= 1.0: 74 | raise ValueError("Reading progress must be between 0 and 100") 75 | return value 76 | 77 | @field_serializer("url") 78 | def serialize_url(self, url: HttpUrl): 79 | return str(url) 80 | 81 | @field_serializer("published_date") 82 | def serialize_dt(self, dt: Union[date, datetime, None]): 83 | if dt: 84 | return dt.strftime("%Y-%m-%dT%H:%M:%S%z") 85 | -------------------------------------------------------------------------------- /readercli/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scarvy/readwise-reader-cli/90de5a30890f307088c5fa1e3d75bd9aec79a69d/readercli/py.typed -------------------------------------------------------------------------------- /readercli/reading_list/__init__.py: -------------------------------------------------------------------------------- 1 | from .extractors import build_reading_list 2 | 3 | __all__ = ["build_reading_list"] 4 | -------------------------------------------------------------------------------- /readercli/reading_list/extractors.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from abc import ABC, abstractmethod 3 | from typing import Any, List 4 | 5 | from bs4 import BeautifulSoup 6 | 7 | from ..models import DocumentInfo 8 | 9 | 10 | class ReadingListExtractor(ABC): 11 | """Abstract class for ReadingListExtractors""" 12 | 13 | @abstractmethod 14 | def extract_document_info(self, input_file: str) -> List[DocumentInfo]: 15 | pass 16 | 17 | 18 | class HTMLReadingListExtractor(ReadingListExtractor): 19 | """ 20 | Extract document information from html file 21 | 22 | Example: 23 | html 24 | Reading List 25 |

Reading List

26 |

27 |

Example Title 28 |

29 | """ 30 | 31 | def extract_document_info(self, input_file: str) -> List[DocumentInfo]: 32 | documents = [] 33 | 34 | with open(input_file, "r") as f: 35 | content = f.read() 36 | 37 | soup = BeautifulSoup(content, "html.parser") 38 | 39 | for link in soup.find_all("a"): 40 | url = link.get("href") 41 | title = link.get_text() 42 | 43 | document = DocumentInfo(title=title, url=url) 44 | documents.append(document) 45 | 46 | return documents 47 | 48 | 49 | class CSVReadingListExtractor(ReadingListExtractor): 50 | """ 51 | Extract document information from CSV file 52 | 53 | Example: 54 | csv 55 | URL,Title, 56 | https://www.example.com,Example Domain 57 | """ 58 | 59 | def extract_document_info(self, input_file: str) -> List[DocumentInfo]: 60 | documents = [] 61 | 62 | with open(input_file, "r") as f: 63 | reader = csv.reader(f) 64 | next(reader, None) # skip header row 65 | 66 | for row in reader: 67 | url: Any = row[0] 68 | title = row[1] if len(row) >= 2 else None 69 | 70 | document = DocumentInfo(title=title, url=url) 71 | documents.append(document) 72 | 73 | return documents 74 | 75 | 76 | def create_extractor(file_type: str) -> ReadingListExtractor: 77 | extractor_map = {"html": HTMLReadingListExtractor, "csv": CSVReadingListExtractor} 78 | if file_type not in extractor_map: 79 | raise ValueError(f"Invalid file type: {file_type}") 80 | return extractor_map[file_type]() 81 | 82 | 83 | def build_reading_list(input_file: str, file_type: str) -> List[DocumentInfo]: 84 | """Builds a reading list from a given file. 85 | 86 | Args: 87 | input_file (str): a file path 88 | file_type (str): file type (ex. .csv) 89 | 90 | Raises: 91 | ValueError: If file type is not supported 92 | 93 | Returns: 94 | List[DocumentInfo]: A list of `DocumentInfo` objects 95 | """ 96 | extractor = create_extractor(file_type) 97 | 98 | return extractor.extract_document_info(input_file) 99 | -------------------------------------------------------------------------------- /readercli/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | from datetime import datetime, timedelta 3 | from typing import Dict, List 4 | 5 | from click import secho 6 | from rich.progress import Progress 7 | 8 | from .api import add_document 9 | from .constants import VALID_CATEGORY_OPTIONS, VALID_LOCATION_OPTIONS 10 | from .models import DocumentInfo 11 | 12 | DATE_RANGE_MAP = {"today": {"days": 1}, "week": {"weeks": 1}, "month": {"days": 30}} 13 | 14 | 15 | def convert_date_range(date_range: str) -> datetime: 16 | return datetime.now() - timedelta(**DATE_RANGE_MAP[date_range]) 17 | 18 | 19 | def count_category_values(documents: List[DocumentInfo]) -> Dict[str, int]: 20 | category_counts = {category: 0 for category in VALID_CATEGORY_OPTIONS} 21 | 22 | for doc in documents: 23 | doc_ctgry = doc.model_dump(include={"category"}) 24 | category = doc_ctgry.get("category") 25 | if category: 26 | if category in category_counts: 27 | category_counts[category] += 1 28 | 29 | return category_counts 30 | 31 | 32 | def count_location_values(documents: List[DocumentInfo]) -> Dict[str, int]: 33 | location_counts = {location: 0 for location in VALID_LOCATION_OPTIONS} 34 | 35 | for document in documents: 36 | document_loc = document.model_dump(include={"location"}) 37 | location = document_loc.get("location") 38 | if location: 39 | if location in location_counts: 40 | location_counts[location] += 1 41 | 42 | return location_counts 43 | 44 | 45 | def count_tag_values(documents: List[DocumentInfo]) -> Dict[str, int]: 46 | tag_counts: Dict[str, int] = {} 47 | 48 | for doc in documents: 49 | doc_tags = doc.model_dump(include={"tags"}) 50 | tags = doc_tags.get("tags") 51 | if tags: 52 | for tag_name, _ in tags.items(): 53 | if tag_name in tag_counts: 54 | tag_counts[tag_name] += 1 55 | else: 56 | tag_counts[tag_name] = 1 57 | 58 | sorted_tag_counts = dict( 59 | sorted(tag_counts.items(), key=lambda item: item[1], reverse=True) 60 | ) 61 | 62 | return sorted_tag_counts 63 | 64 | 65 | def print_report(adds: int, exists: int, failures: int, total: int) -> None: 66 | secho("Report:") 67 | secho(f"Additions: {adds} out of {total}", fg="bright_green") 68 | secho(f"Already Exists: {exists}", fg="bright_yellow") 69 | secho(f"Failures: {failures}", fg="bright_red") 70 | 71 | 72 | def batch_add_documents(documents: List[DocumentInfo], debug=False) -> None: 73 | """Batch documents to add to Reader Library. 74 | 75 | Args: 76 | documents (List[DocumentInfo]): A list of `DocumentInfo` objects 77 | """ 78 | number_of_documents = len(documents) 79 | 80 | # track counts 81 | adds = 0 82 | exists = 0 83 | failures = 0 84 | 85 | with Progress() as progress: 86 | task = progress.add_task("Uploading...", total=number_of_documents) 87 | 88 | for document in documents: 89 | response = add_document(doc_info=document, debug=debug) 90 | 91 | if response.status_code == 201: 92 | adds += 1 93 | progress.update(task, advance=1, description="Success") 94 | elif response.status_code == 200: 95 | adds += 1 96 | exists += 1 97 | progress.update(task, advance=1, description="Already Exists") 98 | else: 99 | failures += 1 100 | progress.update(task, advance=1, description="Failure") 101 | 102 | print_report(adds, exists, failures, number_of_documents) 103 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | rich 2 | click 3 | requests 4 | beautifulsoup4 5 | python-dotenv 6 | xdg-base-dirs 7 | python-dateutil 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scarvy/readwise-reader-cli/90de5a30890f307088c5fa1e3d75bd9aec79a69d/tests/__init__.py -------------------------------------------------------------------------------- /tests/resources/sample_api_results/document.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "01h8707jqgjsfzznh281xxashb", 4 | "url": "https://read.readwise.io/read/01h8707jqgjsfzznh281xxashb", 5 | "title": "Large language models, explained with a minimum of math and jargon", 6 | "author": "Timothy B Lee", 7 | "source": null, 8 | "category": "article", 9 | "location": "new", 10 | "tags": { 11 | "shortlist": { 12 | "name": "shortlist", 13 | "type": "manual", 14 | "created": 1692640034398 15 | } 16 | }, 17 | "site_name": "understandingai.org", 18 | "word_count": 6117, 19 | "created_at": "2023-08-19T13:37:24.567027+00:00", 20 | "updated_at": "2023-08-21T18:34:24.731100+00:00", 21 | "published_date": 1690416000000, 22 | "summary": "Want to really understand how large language models work? Here’s a gentle primer.", 23 | "image_url": "https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc3e159bd-1228-4205-b1eb-5898ab9172d3_1600x856.png", 24 | "content": null, 25 | "source_url": "https://link.sbstck.com/redirect/08f86d22-19a6-4cc1-b449-0eebe03603dd?j=eyJ1IjoiY2gzcTgifQ.mfKJkm1MFmHg0LGeKdRGUAyRwWsVKwDUCVREGN0XyUw", 26 | "notes": "A model so vast,\nWords flow like a river wide,\nIntelligence reigns.", 27 | "parent_id": null, 28 | "reading_progress": 0.035575772085228635 29 | }, 30 | { 31 | "id": "01h7jnwdem0v454jfz6jt3999z", 32 | "url": "https://read.readwise.io/read/01h7jnwdem0v454jfz6jt3999z", 33 | "title": "Python Design Patterns¶", 34 | "author": "python-patterns.guide", 35 | "source": "Readwise web highlighter", 36 | "category": "article", 37 | "location": "new", 38 | "tags": { 39 | "python": { 40 | "name": "Python", 41 | "type": "manual", 42 | "created": 1691770320268 43 | }, 44 | "resources": { 45 | "name": "resources", 46 | "type": "manual", 47 | "created": 1691770341678 48 | }, 49 | "shortlist": { 50 | "name": "shortlist", 51 | "type": "manual", 52 | "created": 1691771937889 53 | }, 54 | "programming": { 55 | "name": "programming", 56 | "type": "manual", 57 | "created": 1691770334734 58 | }, 59 | "design-patterns": { 60 | "name": "design-patterns", 61 | "type": "manual", 62 | "created": 1691770329111 63 | } 64 | }, 65 | "site_name": "python-patterns.guide", 66 | "word_count": 118, 67 | "created_at": "2023-08-11T16:11:45.191410+00:00", 68 | "updated_at": "2023-08-16T23:29:30.819847+00:00", 69 | "published_date": null, 70 | "summary": "I’m Brandon Rhodes (website, Twitter) and this is my evolving guide to design patterns in the Python programming language.", 71 | "image_url": "", 72 | "content": null, 73 | "source_url": "https://python-patterns.guide/", 74 | "notes": "", 75 | "parent_id": null, 76 | "reading_progress": 1 77 | }, 78 | { 79 | "id": "01h7gwmcthew5qn35wvdkpv8aj", 80 | "url": "https://read.readwise.io/read/01h7gwmcthew5qn35wvdkpv8aj", 81 | "title": "Python Command-Line Arguments", 82 | "author": "Real Python", 83 | "source": "Readwise web highlighter", 84 | "category": "article", 85 | "location": "new", 86 | "tags": { 87 | "shortlist": { 88 | "name": "shortlist", 89 | "type": "manual", 90 | "created": 1691725386688 91 | } 92 | }, 93 | "site_name": "realpython.com", 94 | "word_count": 12352, 95 | "created_at": "2023-08-10T23:31:12.964479+00:00", 96 | "updated_at": "2023-08-21T17:48:55.708477+00:00", 97 | "published_date": 1580860800000, 98 | "summary": "Python command-line arguments are the key to converting your programs into useful and enticing tools that are ready to be used in the terminal of your operating system. In this step-by-step tutorial, you'll learn their origins, standards, and basics, and how to implement them in your program.", 99 | "image_url": "https://files.realpython.com/media/Python-Command-Line-Arguments_Watermarked.33cee612a4ae.jpg", 100 | "content": null, 101 | "source_url": "https://realpython.com/python-command-line-arguments/", 102 | "notes": "", 103 | "parent_id": null, 104 | "reading_progress": 0.9019759274910384 105 | }, 106 | { 107 | "id": "01h65f74nbcywcc37v29rkymrj", 108 | "url": "https://read.readwise.io/read/01h65f74nbcywcc37v29rkymrj", 109 | "title": "RESTful Web Services", 110 | "author": "Leonard Richardson", 111 | "source": "File Upload", 112 | "category": "pdf", 113 | "location": "new", 114 | "tags": { 115 | "url": {"name": "URL", "type": "manual", "created": 1690397218500}, 116 | "xml": {"name": "XML", "type": "manual", "created": 1690397222773}, 117 | "http": {"name": "HTTP", "type": "manual", "created": 1690397212606}, 118 | "reading": { 119 | "name": "reading", 120 | "type": "manual", 121 | "created": 1691725499900 122 | }, 123 | "shortlist": { 124 | "name": "shortlist", 125 | "type": "manual", 126 | "created": 1691725489429 127 | }, 128 | "restful-api": { 129 | "name": "RESTful-API", 130 | "type": "manual", 131 | "created": 1690397205219 132 | } 133 | }, 134 | "site_name": "readwise.io", 135 | "word_count": 152889, 136 | "created_at": "2023-07-25T02:49:27.806466+00:00", 137 | "updated_at": "2023-08-21T18:34:54.375418+00:00", 138 | "published_date": 1297900800000, 139 | "summary": "", 140 | "image_url": "", 141 | "content": null, 142 | "source_url": "https://readwise.io/reader/document_raw_content/68888514", 143 | "notes": "", 144 | "parent_id": null, 145 | "reading_progress": 0.033482142857142856 146 | }, 147 | { 148 | "id": "01h65f6zw9y1b0kx4png80mmv1", 149 | "url": "https://read.readwise.io/read/01h65f6zw9y1b0kx4png80mmv1", 150 | "title": "Web API Design", 151 | "author": "Helen Whelan", 152 | "source": "File Upload", 153 | "category": "pdf", 154 | "location": "new", 155 | "tags": { 156 | "api": {"name": "API", "type": "manual", "created": 1690397106792}, 157 | "web": {"name": "Web", "type": "manual", "created": 1690397114367}, 158 | "ebook": {"name": "ebook", "type": "manual", "created": 1690397149271}, 159 | "shortlist": { 160 | "name": "shortlist", 161 | "type": "manual", 162 | "created": 1691725432090 163 | } 164 | }, 165 | "site_name": "readwise.io", 166 | "word_count": 7661, 167 | "created_at": "2023-07-25T02:49:21.239292+00:00", 168 | "updated_at": "2023-08-21T18:34:50.675757+00:00", 169 | "published_date": 1332288000000, 170 | "summary": "", 171 | "image_url": "", 172 | "content": null, 173 | "source_url": "https://readwise.io/reader/document_raw_content/74070770", 174 | "notes": "", 175 | "parent_id": null, 176 | "reading_progress": 0.6842105263157895 177 | }, 178 | { 179 | "id": "01h8ajwcytqbhv627ag9cg2a01", 180 | "url": "https://read.readwise.io/read/01h8ajwcytqbhv627ag9cg2a01", 181 | "title": null, 182 | "author": null, 183 | "source": null, 184 | "category": "highlight", 185 | "location": "later", 186 | "tags": null, 187 | "site_name": null, 188 | "word_count": 0, 189 | "created_at": "2023-08-20T23:01:04.928012+00:00", 190 | "updated_at": "2023-08-20T23:01:04.928026+00:00", 191 | "published_date": null, 192 | "summary": null, 193 | "image_url": null, 194 | "content": "Almost two-thirds of respondents said their APIs generate revenue. Of those respondents, 43% said APIs generate over a quarter of company revenue. In the financial services and advertising, API revenue was closely measured. It was judged the second-most important metric of public API success, just after usage.", 195 | "source_url": null, 196 | "notes": "", 197 | "parent_id": "01h8ajqppjyrwfa8gvw1gg3x8a", 198 | "reading_progress": 0 199 | } 200 | ] 201 | -------------------------------------------------------------------------------- /tests/resources/sample_api_results/highlghts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "01h7mzsdxnr5jhkk9phq4e7r6f", 4 | "url": "https://read.readwise.io/read/01h7mzsdxnr5jhkk9phq4e7r6f", 5 | "title": null, 6 | "author": null, 7 | "source": "reader-mobile-app", 8 | "category": "highlight", 9 | "location": null, 10 | "tags": {}, 11 | "site_name": null, 12 | "word_count": null, 13 | "created_at": "2023-08-12T13:43:21.391437+00:00", 14 | "updated_at": "2023-08-12T13:43:28.966254+00:00", 15 | "published_date": null, 16 | "summary": null, 17 | "image_url": null, 18 | "content": "pragmatism", 19 | "source_url": null, 20 | "notes": "", 21 | "parent_id": "01h7mqyeq11jt98epzcfbpde9e", 22 | "reading_progress": 0 23 | }, 24 | { 25 | "id": "01h8ppzy1frk0e7gpheef5n177", 26 | "url": "https://read.readwise.io/read/01h8ppzy1frk0e7gpheef5n177", 27 | "title": null, 28 | "author": null, 29 | "source": "reader-web-app", 30 | "category": "highlight", 31 | "location": "later", 32 | "tags": { 33 | "funny": {"name": "funny", "type": "manual", "created": 1692979442334}, 34 | "cartoon": { 35 | "name": "cartoon", 36 | "type": "manual", 37 | "created": 1692979444972 38 | }, 39 | "drawing": { 40 | "name": "drawing", 41 | "type": "manual", 42 | "created": 1692979447744 43 | } 44 | }, 45 | "site_name": null, 46 | "word_count": 0, 47 | "created_at": "2023-08-25T16:03:47.588823+00:00", 48 | "updated_at": "2023-08-25T16:04:08.343874+00:00", 49 | "published_date": null, 50 | "summary": null, 51 | "image_url": null, 52 | "content": "\n\n\n\n", 53 | "source_url": null, 54 | "notes": "", 55 | "parent_id": "01h8p7edag0ax4hnx4ks8vw8he", 56 | "reading_progress": 0 57 | }, 58 | { 59 | "id": "01h8pprff81qa4n10bfxkyc3nk", 60 | "url": "https://read.readwise.io/read/01h8pprff81qa4n10bfxkyc3nk", 61 | "title": null, 62 | "author": null, 63 | "source": "reader-web-app", 64 | "category": "highlight", 65 | "location": "later", 66 | "tags": { 67 | "stoic": {"name": "stoic", "type": "manual", "created": 1692979205575}, 68 | "marcus-aurelius": { 69 | "name": "marcus-aurelius", 70 | "type": "manual", 71 | "created": 1692979201653 72 | } 73 | }, 74 | "site_name": null, 75 | "word_count": 0, 76 | "created_at": "2023-08-25T15:59:43.430440+00:00", 77 | "updated_at": "2023-08-25T16:00:05.956298+00:00", 78 | "published_date": null, 79 | "summary": null, 80 | "image_url": null, 81 | "content": "character is fate.", 82 | "source_url": null, 83 | "notes": "", 84 | "parent_id": "01h8nzjf1sgrpgp4zk58d9zhw6", 85 | "reading_progress": 0 86 | } 87 | ] 88 | -------------------------------------------------------------------------------- /tests/resources/sample_api_results/notes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "01h7mzsn5tbe0bjqnr3gmj2bv9", 4 | "url": "https://read.readwise.io/read/01h7mzsn5tbe0bjqnr3gmj2bv9", 5 | "title": null, 6 | "author": null, 7 | "source": "reader-mobile-app", 8 | "category": "note", 9 | "location": null, 10 | "tags": null, 11 | "site_name": null, 12 | "word_count": null, 13 | "created_at": "2023-08-12T13:43:28.919967+00:00", 14 | "updated_at": "2023-08-12T13:43:28.919980+00:00", 15 | "published_date": null, 16 | "summary": null, 17 | "image_url": null, 18 | "content": "Pragmatism is a philosophical approach that emphasizes practicality and usefulness over abstract theories or principles. It originated in the United States in the late 19th century and was developed by philosophers such as William James and John Dewey. Pragmatists believe that the value of an idea or belief lies in its ability to solve problems and improve people's lives. They emphasize experimentation, observation, and experience as the basis for knowledge and reject the idea of absolute truth or certainty. Pragmatism has had a significant influence on American culture and has been applied in fields such as education, politics, and business.", 19 | "source_url": null, 20 | "notes": "", 21 | "parent_id": "01h7mzsdxnr5jhkk9phq4e7r6f", 22 | "reading_progress": 0 23 | }, 24 | { 25 | "id": "01h5fkadcpf5ea8srh9y1b9ws6", 26 | "url": "https://read.readwise.io/read/01h5fkadcpf5ea8srh9y1b9ws6", 27 | "title": null, 28 | "author": null, 29 | "source": "reader-web-app", 30 | "category": "note", 31 | "location": null, 32 | "tags": null, 33 | "site_name": null, 34 | "word_count": null, 35 | "created_at": "2023-07-16T14:58:36.391838+00:00", 36 | "updated_at": "2023-07-16T14:58:36.391851+00:00", 37 | "published_date": null, 38 | "summary": null, 39 | "image_url": null, 40 | "content": "Write for a particular person in mind", 41 | "source_url": null, 42 | "notes": "", 43 | "parent_id": "01h5fk9y1shfjvvvtr7z0hvc9n", 44 | "reading_progress": 0 45 | }, 46 | { 47 | "id": "01h8m0et3vf7f3a0c7nbmhkxjg", 48 | "url": "https://read.readwise.io/read/01h8m0et3vf7f3a0c7nbmhkxjg", 49 | "title": null, 50 | "author": null, 51 | "source": "reader-web-app", 52 | "category": "note", 53 | "location": "later", 54 | "tags": null, 55 | "site_name": null, 56 | "word_count": 0, 57 | "created_at": "2023-08-24T14:51:29.042109+00:00", 58 | "updated_at": "2023-08-24T14:51:29.042125+00:00", 59 | "published_date": null, 60 | "summary": null, 61 | "image_url": null, 62 | "content": "Begets: Begets is a verb that means to cause or bring about something. It is often used in the context of a negative cycle or pattern that perpetuates itself, such as violence begets violence or ignorance begets ignorance. In programming, the phrase bad code begets bad code suggests that poorly written code can lead to more poorly written code, creating a cycle of inefficiency and difficulty in maintaining the program.", 63 | "source_url": null, 64 | "notes": "", 65 | "parent_id": "01h8m0eks760p1bj583wf9jcas", 66 | "reading_progress": 0 67 | }, 68 | { 69 | "id": "01h8jjvrk39s3hhvc3wf7ns1d4", 70 | "url": "https://read.readwise.io/read/01h8jjvrk39s3hhvc3wf7ns1d4", 71 | "title": null, 72 | "author": null, 73 | "source": "reader-mobile-app", 74 | "category": "note", 75 | "location": "later", 76 | "tags": null, 77 | "site_name": null, 78 | "word_count": 0, 79 | "created_at": "2023-08-24T01:34:39.239540+00:00", 80 | "updated_at": "2023-08-24T01:34:39.239552+00:00", 81 | "published_date": null, 82 | "summary": null, 83 | "image_url": null, 84 | "content": "Sentry is a tool that helps developers fix problems with their computer programs. It tells them when something is wrong with the code and helps them figure out what caused the problem. Sentry can also track the health of different parts of the program and can help find the root cause of issues. This is important because programs are becoming more complex and it's harder for developers to keep track of everything. With Sentry, developers can make sure the program is working properly and giving customers a good experience.", 85 | "source_url": null, 86 | "notes": "", 87 | "parent_id": "01h8jjvevyktpjpsb0pkf0edmy", 88 | "reading_progress": 0 89 | } 90 | ] 91 | -------------------------------------------------------------------------------- /tests/resources/sample_reading_lists/BadURL.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Reading List 7 |

Reading List

8 |

9 |

Information Software and the Graphical Interface 10 |

11 | -------------------------------------------------------------------------------- /tests/resources/sample_reading_lists/CustomReadingList.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Reading List 7 |

Reading List

8 |

9 |

‘OOTP Baseball:’ How a German programmer created the deepest baseball sim ever made - The Athletic 10 |
How virtual environments work 11 |
How ‘open’ should your open source be? 12 |
I'm a software engineer who struggled with procrastination until I tried 'monk mode' — here's how it saves me up to 3 hours a day 13 |
Icahn, Under Federal Investigation, Blasts Short Seller - WSJ 14 |
Improving Ourselves to Death 15 |
Improving code quality with linting in Python 16 |
Inflation 17 |
Information Software and the Graphical Interface 18 |

19 | -------------------------------------------------------------------------------- /tests/resources/sample_reading_lists/test-file.csv: -------------------------------------------------------------------------------- 1 | URL,title,add_date 2 | https://www.wsj.com/tech/elon-musk-x-twitter-town-hall-9dba6796?mod=tech_lead_pos1,My Title, 3 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from readercli.api import _fetch_results # list_documents, 6 | from readercli.api import ( 7 | _create_doc, 8 | _get_list, 9 | _handle_http_status, 10 | add_document, 11 | doc_info_jsonify, 12 | list_parameter_jsonify, 13 | validate_token, 14 | ) 15 | 16 | # from readercli.models import DocumentInfo 17 | 18 | 19 | def test_list_parameter_jsonify(): 20 | mock_params = Mock() 21 | mock_params.model_dump.return_value = {"key": "value"} 22 | assert list_parameter_jsonify(mock_params) == {"key": "value"} 23 | 24 | 25 | def test_doc_info_jsonify(): 26 | mock_doc_info = Mock() 27 | mock_doc_info.model_dump.return_value = {"key": "value"} 28 | assert doc_info_jsonify(mock_doc_info) == {"key": "value"} 29 | 30 | 31 | @patch("readercli.api.requests.get") 32 | def test__get_list(mock_get): 33 | mock_get.return_value = {"key1": "value1", "key2": [{"key1": "value1"}]} 34 | assert _get_list({"key": "value"}) == { 35 | "key1": "value1", 36 | "key2": [{"key1": "value1"}], 37 | } 38 | 39 | 40 | @patch("readercli.api.requests.post") 41 | def test__create_doc(mock_post): 42 | mock_post.return_value = {"key1": "value1"} 43 | assert _create_doc({"key": "value"}) == {"key1": "value1"} 44 | 45 | 46 | def test__handle_http_status(): 47 | mock_response = Mock() 48 | mock_response.status_code = 200 49 | mock_response.headers.get.return_value = 5 50 | assert _handle_http_status(mock_response) == ("valid", 5) 51 | 52 | 53 | @patch("readercli.api._get_list") 54 | @patch("readercli.api._handle_http_status") 55 | def test__fetch_results(mock_handle_status, mock_get_list): 56 | mock_handle_status.return_value = ("valid", 5) 57 | mock_get_list.return_value = Mock( 58 | json=lambda: {"results": [], "nextPageCursor": None} 59 | ) 60 | assert list(_fetch_results({"key": "value"})) == [[]] 61 | 62 | 63 | # @patch("readercli.api.list_documents") 64 | # def test_list_documents(mock_fetch_results): 65 | # mock_fetch_results.return_value = [ 66 | # DocumentInfo(**{"url": "https://www.example.com"}) 67 | # ] 68 | # assert list_documents() == [DocumentInfo(**{"url": "https://www.example.com"})] 69 | 70 | 71 | @patch("readercli.api._create_doc") 72 | @patch("readercli.api._handle_http_status") 73 | def test_add_document(mock_handle_status, mock_create_doc): 74 | mock_handle_status.return_value = ("valid", 5) 75 | mock_create_doc.return_value = "response" 76 | assert add_document(Mock()) == "response" 77 | 78 | 79 | @patch("readercli.api.requests.get") 80 | def test_validate_token(mock_get): 81 | mock_get.return_value = Mock(status_code=204) 82 | assert validate_token("token") == True 83 | -------------------------------------------------------------------------------- /tests/test_layout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from readercli.layout import ( 3 | format_reading_progress, 4 | format_published_date, 5 | ) 6 | 7 | 8 | @pytest.mark.parametrize("progress, expected", [(0.035575772085228635, "3.56%")]) 9 | def test_format_reading_progress(progress, expected): 10 | assert format_reading_progress(progress) == expected 11 | 12 | 13 | # Fix publish date issue 14 | # @pytest.mark.parametrize( 15 | # "timestamp_milliseconds, expected", 16 | # [(1690416000000.0, "2023-07-26")], 17 | # ) 18 | # def test_format_published_date(timestamp_milliseconds, expected): 19 | # assert format_published_date(timestamp_milliseconds) == expected 20 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from readercli.utils import convert_date_range 6 | 7 | 8 | @pytest.fixture( 9 | params=[ 10 | ("today", timedelta(days=1)), 11 | ("week", timedelta(weeks=1)), 12 | ("month", timedelta(days=30)), 13 | ] 14 | ) 15 | def date_range_fixture(request): 16 | return request.param 17 | 18 | 19 | def test_convert_date_range(date_range_fixture): 20 | date_range_option, expected_timedelta = date_range_fixture 21 | expected_date = datetime.now() - expected_timedelta 22 | actual_date = convert_date_range(date_range_option) 23 | assert abs((expected_date - actual_date).total_seconds()) < 1 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4.0 3 | env_list = 4 | py311 5 | py310 6 | type 7 | lint 8 | 9 | [testenv] 10 | description = run unit tests 11 | deps = 12 | pytest 13 | pytest-cov 14 | allowlist_externals = pytest 15 | commands = pytest --cov=readercli --cov-report=term-missing --cov-report=xml 16 | 17 | [testenv:lint] 18 | description = run linters 19 | skip_install = true 20 | deps = 21 | black 22 | commands = 23 | black . 24 | 25 | 26 | [testenv:type] 27 | description = run type checks 28 | deps = 29 | mypy 30 | types-requests 31 | types-python-dateutil 32 | types-beautifulsoup4 33 | commands = mypy readercli --------------------------------------------------------------------------------