├── .dockerignore ├── .github └── workflows │ ├── docker-publish.yml │ ├── dockerhub-publish.yml │ └── python-package.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── docs ├── Customizing.md ├── Example-Nov-1-2020.pdf ├── StyleGallery.md ├── exampleWeather.png ├── example_library_usage.py ├── goose.svg └── reference │ ├── docs │ └── example_library_usage.py.md │ ├── goosepaper │ ├── __main__.py.md │ ├── auth.py.md │ ├── goosepaper.md │ ├── goosepaper.py.md │ ├── multiparser.py.md │ ├── story.py.md │ ├── styles.py.md │ ├── upload.py.md │ └── util.py.md │ ├── setup.py.md │ └── storyprovider │ ├── reddit.py.md │ ├── rss.py.md │ ├── storyprovider.md │ ├── storyprovider.py.md │ ├── twitter.py.md │ ├── weather.py.md │ └── wikipedia.py.md ├── example-config.json ├── goosepaper ├── __init__.py ├── __main__.py ├── auth.py ├── goosepaper.json ├── goosepaper.py ├── multiparser.py ├── story.py ├── storyprovider │ ├── __init__.py │ ├── mastodon.py │ ├── reddit.py │ ├── rss.py │ ├── storyprovider.py │ ├── test_mastodon.py │ ├── weather.py │ └── wikipedia.py ├── styles.py ├── test_goosepaper.py ├── test_utils.py ├── upload.py └── util.py ├── manifest.in ├── requirements.txt ├── setup.py └── styles ├── Academy └── stylesheet.css ├── Autumn ├── stylesheet.css └── stylesheets.txt └── FifthAvenue ├── stylesheet.css └── stylesheets.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .git 3 | .DS_Store 4 | docs 5 | Dockerfile 6 | *.pdf 7 | *.json -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | # 2 | name: Create and publish a Docker image 3 | 4 | # Configures this workflow to run every time a change is pushed to a version tag 5 | on: 6 | push: 7 | branches: ['master'] 8 | tags: [ 'v*.*.*' ] 9 | 10 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 16 | jobs: 17 | build-and-push-image: 18 | runs-on: ubuntu-latest 19 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 20 | permissions: 21 | contents: read 22 | packages: write 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 27 | - name: Log in to the Container registry 28 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 34 | - name: Extract metadata (tags, labels) for Docker 35 | id: meta 36 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 37 | with: 38 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 40 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 41 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 42 | - name: Build and push Docker image 43 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 44 | with: 45 | context: . 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Hub 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: ['master'] 11 | tags: [ 'v*.*.*' ] 12 | 13 | env: 14 | # Use docker.io for Docker Hub if empty 15 | REGISTRY: docker.io 16 | # github.repository as / 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | 20 | jobs: 21 | build: 22 | 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: read 26 | packages: write 27 | # This is used to complete the identity challenge 28 | # with sigstore/fulcio when running outside of PRs. 29 | id-token: write 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | 35 | # Install the cosign tool except on PR 36 | # https://github.com/sigstore/cosign-installer 37 | - name: Install cosign 38 | if: github.event_name != 'pull_request' 39 | uses: sigstore/cosign-installer@1e95c1de343b5b0c23352d6417ee3e48d5bcd422 40 | with: 41 | cosign-release: 'v1.4.0' 42 | 43 | 44 | # Workaround: https://github.com/docker/build-push-action/issues/461 45 | - name: Setup Docker buildx 46 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 47 | 48 | # Login against a Docker registry except on PR 49 | # https://github.com/docker/login-action 50 | - name: Log into registry ${{ env.REGISTRY }} 51 | if: github.event_name != 'pull_request' 52 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 53 | with: 54 | registry: ${{ env.REGISTRY }} 55 | username: ${{ github.actor }} 56 | password: ${{ secrets.DHPW }} 57 | 58 | # Extract metadata (tags, labels) for Docker 59 | # https://github.com/docker/metadata-action 60 | - name: Extract Docker metadata 61 | id: meta 62 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 63 | with: 64 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 65 | 66 | # Build and push Docker image with Buildx (don't push on PR) 67 | # https://github.com/docker/build-push-action 68 | - name: Build and push Docker image 69 | id: build-and-push 70 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 71 | with: 72 | context: . 73 | push: ${{ github.event_name != 'pull_request' }} 74 | tags: ${{ steps.meta.outputs.tags }} 75 | labels: ${{ steps.meta.outputs.labels }} 76 | 77 | # Sign the resulting Docker image digest except on PRs. 78 | # This will only write to the public Rekor transparency log when the Docker 79 | # repository is public to avoid leaking data. If you would like to publish 80 | # transparency data even for private images, pass --force to cosign below. 81 | # https://github.com/sigstore/cosign 82 | - name: Sign the published Docker image 83 | if: ${{ github.event_name != 'pull_request' }} 84 | env: 85 | COSIGN_EXPERIMENTAL: "true" 86 | # This step uses the identity token to provision an ephemeral certificate 87 | # against the sigstore community Fulcio instance. 88 | run: cosign sign ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} 89 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [3.7, 3.8, 3.9, '3.10', '3.11', '3.12'] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest pytest-cov 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --max-complexity=30 --max-line-length=127 --statistics --ignore E501,W503 37 | - name: Test with pytest 38 | run: | 39 | pytest --cov=./ --cov-report=xml 40 | - name: Codecov 41 | uses: codecov/codecov-action@v1.0.13 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ignore.* 2 | # Created by https://www.gitignore.io/api/code,python,macos 3 | # Edit at https://www.gitignore.io/?templates=code,python,macos 4 | 5 | ### Code ### 6 | .vscode/* 7 | !.vscode/settings.json 8 | !.vscode/tasks.json 9 | !.vscode/launch.json 10 | !.vscode/extensions.json 11 | 12 | ### macOS ### 13 | # General 14 | .DS_Store 15 | .AppleDouble 16 | .LSOverride 17 | 18 | # Icon must end with two \r 19 | Icon 20 | 21 | # Thumbnails 22 | ._* 23 | 24 | # Files that might appear in the root of a volume 25 | .DocumentRevisions-V100 26 | .fseventsd 27 | .Spotlight-V100 28 | .TemporaryItems 29 | .Trashes 30 | .VolumeIcon.icns 31 | .com.apple.timemachine.donotpresent 32 | 33 | # Directories potentially created on remote AFP share 34 | .AppleDB 35 | .AppleDesktop 36 | Network Trash Folder 37 | Temporary Items 38 | .apdisk 39 | 40 | ### Python ### 41 | # Byte-compiled / optimized / DLL files 42 | __pycache__/ 43 | *.py[cod] 44 | *$py.class 45 | 46 | # C extensions 47 | *.so 48 | 49 | # Distribution / packaging 50 | .Python 51 | build/ 52 | develop-eggs/ 53 | dist/ 54 | downloads/ 55 | eggs/ 56 | .eggs/ 57 | lib/ 58 | lib64/ 59 | parts/ 60 | sdist/ 61 | var/ 62 | wheels/ 63 | pip-wheel-metadata/ 64 | share/python-wheels/ 65 | *.egg-info/ 66 | .installed.cfg 67 | *.egg 68 | MANIFEST 69 | 70 | # PyInstaller 71 | # Usually these files are written by a python script from a template 72 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 73 | *.manifest 74 | *.spec 75 | 76 | # Installer logs 77 | pip-log.txt 78 | pip-delete-this-directory.txt 79 | 80 | # Unit test / coverage reports 81 | htmlcov/ 82 | .tox/ 83 | .nox/ 84 | .coverage 85 | .coverage.* 86 | .cache 87 | nosetests.xml 88 | coverage.xml 89 | *.cover 90 | .hypothesis/ 91 | .pytest_cache/ 92 | 93 | # Translations 94 | *.mo 95 | *.pot 96 | 97 | # Scrapy stuff: 98 | .scrapy 99 | 100 | # Sphinx documentation 101 | docs/_build/ 102 | 103 | # PyBuilder 104 | target/ 105 | 106 | # pyenv 107 | .python-version 108 | 109 | # pipenv 110 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 111 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 112 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 113 | # install all needed dependencies. 114 | #Pipfile.lock 115 | 116 | # celery beat schedule file 117 | celerybeat-schedule 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # Mr Developer 130 | .mr.developer.cfg 131 | .project 132 | .pydevproject 133 | 134 | # mkdocs documentation 135 | /site 136 | 137 | # mypy 138 | .mypy_cache/ 139 | .dmypy.json 140 | dmypy.json 141 | 142 | # Pyre type checker 143 | .pyre/ 144 | 145 | # End of https://www.gitignore.io/api/code,python,macos 146 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ### **v0.7.1** (February 17, 2024) 4 | 5 | - Improvements 6 | - Moved to a much smaller, alpine-based Dockerfile. Thanks @lsmoura! 7 | 8 | 9 | ### **0.7.0** (August 7 2023) 10 | 11 | - Fixes 12 | - Fixed a bug where the weather provider would not correctly render celsius unit marker 13 | - Fixed the RSS story provider, 14 | - Improvements 15 | - added a Mastodon story provider (#82) 16 | - Removed the Twitter story provider, which no longer works due to Twitter's API changes 17 | 18 | ### **0.6.0** (June 5 2022) 19 | 20 | - Improvements 21 | - Enable writing a BytesIO object instead of creating a file. Good for cases where you're running goosepaper on unprivileged systems. 22 | - Fixes 23 | - Fixed the old broken weather provider by switching to Open-Meteo. 24 | - No longer builds images on pull-request branches. 25 | 26 | ### **0.5.1** 27 | 28 | - Improvements 29 | - Adds a new `CustomTextStoryProvider` to let you add your own text stories. 30 | - Housekeeping 31 | - Adds automated builds for docker hub and ghcr.io. 32 | 33 | ### **0.5.0** (January 14 2022) 34 | 35 | - Improvements 36 | - RSS stories now "fall back" gracefully on just rendering the title, if the full body cannot be rendered. This is in contrast with the old behavior, in which the story would not be rendered at all. 37 | - RSS, Reddit, and Twitter story providers now support a `since_days_ago` argument in their `config` dictionaries that enables you to specify how many days ago to start the search for stories. Older stories will not be included. 38 | - Add support for multiple styles, using the `"styles" config option. Options are `"Academy"`, `"FifthAvenue"`, and `"Autumn"`. Previous style (before v0.4.0) was `Autumn`. 39 | - Housekeeping 40 | - Moved the story providers into their own submodule: Note that this may break backward compatibility if you import these directly. 41 | 42 | ### **0.4.0** (January 13 2022) 43 | 44 | > Multiple fixes and improvements 45 | 46 | - Fixes 47 | - Changed some document name comparisons to case insensitive (prevent document overwrites, esp. for Windows users) 48 | - Switched upload to require named arguments rather than positional 49 | - Fixes the `limit` arg in the RSS provider, which was being ignored 50 | - Improvements 51 | - Improve typing support 52 | - Added more error handling for file and syntax handling 53 | - Change to using the `VissibleName` attribute in all cases rather than filename 54 | - Added code for upcoming additional sanity checks 55 | - Added more information on how to customize your goospaper in the docs, @kwillno (#54) 56 | - Adds the option to provide a global config (thanks @sedennial! #48) 57 | - Lots of new options to customize the upload and generation process (thanks @sedennial! #48) 58 | - Housekeeping 59 | - Fixes a bunch of flake8 errors and warnings to keep things tidy 60 | 61 | ### **0.3.1** (April 29 2021) 62 | 63 | > This version adds a test suite and improvements to the upload and config mechanisms, as well as several more performance and feature improvements. 64 | 65 | - Improvements 66 | - Add test suite 67 | - Improvements to upload mechanism 68 | - Add possibility to set title and subtile in config file - contribution by @traysh 69 | - Parallelize fetching RSS stories - contribution by @traysh 70 | - Add flag to allow replacing documents in remarkable cloud - contribution by @traysh 71 | - Add class to resolve options both from command line and config file - contribution by @traysh 72 | - Allow uploading to a folder in remarkable cloud 73 | 74 | ### **0.3.0** (November 27 2020) 75 | 76 | > Some major codebase reorganization, thanks to @sterlingbaldwin. Thank you!! 77 | 78 | - Bugfixes 79 | - Fixed Twitter story provider; we're back in business! 80 | - Improvements 81 | - RSS stories are now downloaded in full (when available) — thanks again @sterlingbaldwin! 82 | - Specify your weather preferences in C/F units 83 | - Added a Docker image! Generate your goosepapers in a box! 84 | 85 | ### **0.2.0** (November 27 2020) 86 | 87 | > This release converts Goosepaper to a Python module. You can now call it from the command-line with `goosepaper`. See `goosepaper --help` for more details. 88 | 89 | - Improvements 90 | - Use Goosepaper as a library, or use it as a command-line tool. Both work now! (#11) 91 | - Read a config file from disk in order to build a list of story providers, rather than having to hard-code it. (#11) 92 | 93 | ### **0.1.0** (November 8 2020) 94 | 95 | > This is the last release of Goosepaper before it was converted into a Python module. If you are still using Goosepaper as a script, this is the version for you. Please update as soon as possible! 96 | 97 | - Improvements 98 | - Weather 99 | - Optionally record the weather temperature in Celsius. Thanks @kwillno! (#10) 100 | - RSS 101 | - Don't break on RSS feeds that lack images. Thanks again, @kwillno! (#10) 102 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Getting Involved 2 | 3 | > [!IMPORTANT] 4 | > The current show-stopper for this work is getting a tool like `rmapy` to work again, after some changes to how reMarkable does cloud authentication. If you would like to collaborate on a solution to this problem, please check out https://github.com/j6k4m8/rmapy 5 | 6 | I'd love to see what you do with the codebase, and I want to see your configs too! :) Please feel free to submit pull-requests or feature requests, but keep in mind that many (!!) people now rely upon this codebase, so breaking changes (or complex installs) might take longer to get merged. 7 | 8 | ## Help Wanted! 9 | 10 | If you're looking for a place to start getting your hands dirty, check out some potential options below. [These `good-first-issue`s are also a great place to start!](https://github.com/j6k4m8/goosepaper/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 11 | 12 | ### New Story Providers 13 | 14 | Writing new Story Providers is a great place to start! Is there some news source you wish you could get in your Goosepaper? 15 | 16 | * Maybe one for GitHub notifications? 17 | * Maybe today's birthdays, pulled from Facebook? 18 | * Maybe your upcoming calendar events, via Google Calendar or another service? 19 | * HackerNews top stories? 20 | 21 | ### A "front-page" layout 22 | 23 | I'd love to have a big ol' headline area for the most important story of the day. 24 | 25 | ### New styles! 26 | 27 | Right now there's only one stylesheet. But perhaps you don't like my font choices?? 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 2 | 3 | LABEL maintainer="Jordan Matelsky " 4 | LABEL authors="Sergio Moura " 5 | 6 | RUN apk --update --no-cache add cairo libffi libjpeg libstdc++ libxml2 libxslt pango \ 7 | py3-aiohttp py3-cffi py3-feedparser py3-gobject3 py3-html5lib py3-lxml py3-multidict \ 8 | py3-numpy py3-requests py3-yarl ttf-dejavu 9 | 10 | WORKDIR /app 11 | COPY requirements.txt . 12 | RUN apk add --no-cache --virtual .build-deps build-base git libxml2-dev libxslt-dev libffi-dev libjpeg-turbo-dev py3-pip py3-wheel python3-dev && \ 13 | pip3 install -r ./requirements.txt && \ 14 | apk del .build-deps && \ 15 | rm -Rf /root/.cache 16 | COPY . . 17 | RUN apk add --no-cache --virtual .install-deps py3-pip && \ 18 | pip3 install -e . && \ 19 | apk del .install-deps && \ 20 | rm -Rf /root/.cache 21 | 22 | ENTRYPOINT ["goosepaper"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Jordan Matelsky 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
a daily newsfeed delivered to your remarkable tablet
3 | 4 |

5 | 6 | 7 | 8 |

9 |

10 | 11 | PyPI 12 |

13 |

14 | Docker Hub Automated Build 15 | GitHub Container Registry Automated build 16 |

17 |

18 | GitHub Workflow Status (with branch) 19 | Codecov 20 |

21 | 22 | ## what's up 23 | 24 | goosepaper is a utility that delivers a daily newspaper to your remarkable tablet. that's cute! 25 | 26 | you can include RSS feeds, Mastodon feeds, news articles, wikipedia articles-of-the-day, weather, and more. I read it when I wake up so that I can feel anxious without having to get my phone. 27 | 28 | ## survey 29 | 30 | **[New!]** In response to feedback, I'm collecting anonymous survey responses. Do you want a goosepaper delivered but without requiring any code? Please [let me know your thoughts!](https://forms.gle/t3PUp2TxDQnzzs8x9) 31 | 32 | ## get started with docker 33 | 34 | By far the easiest way to get started with Goosepaper is to use Docker. 35 | 36 | ### step 0: write your config file 37 | 38 | Write a config file to tell Goosepaper what news you want to read. An example is provided in `example-config.json`. 39 | 40 | ### step 1: generate your paper 41 | 42 | From the directory that has the config file in it, run the following: 43 | 44 | ```shell 45 | docker run -it --rm -v $(pwd):/goosepaper/mount j6k4m8/goosepaper goosepaper -c mount/example-config.json -o mount/Goosepaper.pdf 46 | ``` 47 | 48 | (where `example-config.json` is the name of the config file to use). 49 | 50 | ### step 2: you are done! 51 | 52 | If you want to both generate the PDF as well as upload it to your reMarkable tablet, you can pass the `--upload` flag to the docker command above. You must additionally mount your `~/.rmapy` file: 53 | 54 | ```shell 55 | docker run -it --rm \ 56 | -v $(pwd):/goosepaper/mount \ 57 | -v $HOME/.rmapy:/root/.rmapy \ 58 | j6k4m8/goosepaper \ 59 | goosepaper -c mount/example-config.json -o mount/Goosepaper.pdf --upload 60 | ``` 61 | 62 | Otherwise, you can now email this PDF to your tablet, perhaps using [ReMailable](https://github.com/j6k4m8/remailable). 63 | 64 | ## get started without docker: installation 65 | 66 | ### dependencies: 67 | 68 | this tool uses `weasyprint` to generate PDFs. You can install all of the python libraries you need with `pip3 install -r ./requirements.txt` from this repo, but you may need these prerequisites before getting started. 69 | 70 | more details [here](https://weasyprint.readthedocs.io/en/latest/install.html). 71 | 72 | #### mac: 73 | 74 | ```shell 75 | brew install cairo pango gdk-pixbuf libffi 76 | ``` 77 | 78 | #### ubuntu-flavored: 79 | 80 | ```shell 81 | sudo apt-get install build-essential python3-dev python3-pip python3-setuptools python3-wheel python3-cffi libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info 82 | ``` 83 | 84 | #### windows: 85 | 86 | [Follow these instructions carefully](https://weasyprint.readthedocs.io/en/latest/install.html#windows). 87 | 88 | ## and then: 89 | 90 | From inside the goosepaper repo, 91 | 92 | ```shell 93 | pip3 install -e . 94 | ``` 95 | 96 | ## get started 97 | 98 | You can customize your goosepaper by editing `config.json`. (More instructions, and customization tools, all coming soon!) 99 | 100 | ```shell 101 | goosepaper --config myconfig.json --output mypaper.pdf 102 | ``` 103 | 104 | If you don't pass an output flag, one will be generated based upon the time of generation. 105 | 106 | The output can also be specified by a config file. These are in order of precedence, low to highest: 107 | 108 | 1. Home directory global configs 109 | 2. Local directory from which goosepaper is called 110 | 3. Specified on the command line. 111 | 112 | An example config file is included here: [example-config.json](example-config.json). 113 | 114 | --- 115 | 116 | Check out [this example PDF](https://github.com/j6k4m8/goosepaper/blob/master/docs/Example-Nov-1-2020.pdf), generated on Nov 1 2020. 117 | 118 | ## existing story providers ([want to write your own?](https://github.com/j6k4m8/goosepaper/blob/master/CONTRIBUTING.md)) 119 | 120 | - [Wikipedia Top News / Current Events](https://github.com/j6k4m8/goosepaper/blob/master/goosepaper/storyprovider/wikipedia.py) 121 | - [Mastodon Toots](https://github.com/j6k4m8/goosepaper/blob/master/goosepaper/storyprovider/mastodon.py) 122 | - [Weather](https://github.com/j6k4m8/goosepaper/blob/master/goosepaper/storyprovider/weather.py). These stories appear in the "ear" of the front page, just like a regular ol' newspaper 123 | - [RSS Feeds](https://github.com/j6k4m8/goosepaper/blob/master/goosepaper/storyprovider/rss.py) 124 | - [Reddit Subreddits](https://github.com/j6k4m8/goosepaper/blob/master/goosepaper/storyprovider/reddit.py) 125 | 126 | # More Questions, Infrequently Asked 127 | 128 | ### yes but pardon me — i haven't a remarkable tablet 129 | 130 | Do you have another kind of tablet? You may generate a print-ready PDF which you can use on another kind of robot as well! Just remove the last line of `main.py`. 131 | 132 | ### very nice! may i have it in comic sans? 133 | 134 | yes! you may do anything that you find to be fun and welcoming :) 135 | 136 | Check out the `styles.Styles` enum in the goosepaper library. You will find there what you seek. 137 | 138 | ### do all dogs' names start with the letter "B"? 139 | 140 | I do not think so, but it is a good question! 141 | 142 | ### may i use this to browse twitter? 143 | 144 | ~~yes you may! you can add a list of usernames to the feed generator and it will make a print-ready version of twitter. this is helpful for when you are on twitter on your laptop but wish you had Other Twitter as well, in print form.~~ 145 | 146 | no! twitter has changed and now no one can play nicely with them. sorry! it is sad! 147 | 148 | # You May Also Like... 149 | 150 | - [remailable](https://github.com/j6k4m8/remailable): Email PDF documents to your reMarkable tablet 151 | - [rmapy fork](https://github.com/j6k4m8/rmapy): My fork of rmapy, with added features and bugfixes 152 | -------------------------------------------------------------------------------- /docs/Customizing.md: -------------------------------------------------------------------------------- 1 | # Customizing Your Feed 2 | 3 | ## Example config 4 | 5 | You can choose what content is added to your daily goosepaper by writing your own config-file. 6 | As an example we give the config delivered as an example `example-config.json`: 7 | 8 | ```json 9 | { 10 | "font_size": 12, 11 | "stories": [ 12 | { 13 | "provider": "weather", 14 | "config": { 15 | "lat": 59.3293, 16 | "lon": 18.0686, 17 | "F": true 18 | } 19 | }, 20 | { 21 | "provider": "wikipedia_current_events", 22 | "config": {} 23 | }, 24 | { 25 | "provider": "rss", 26 | "config": { 27 | "rss_path": "https://feeds.npr.org/1001/rss.xml", 28 | "limit": 5 29 | } 30 | }, 31 | { 32 | "provider": "reddit", 33 | "config": { "subreddit": "news" } 34 | }, 35 | { 36 | "provider": "reddit", 37 | "config": { "subreddit": "todayilearned" } 38 | } 39 | ] 40 | } 41 | ``` 42 | 43 | ## Look & Feel 44 | 45 | ### Titles and font size 46 | 47 | In the first part of the config you can set global parameters for your goosepaper. These do not need to be set as they have default parameters. 48 | 49 | ### Goosepaper Title 50 | 51 | The title is at the top of the first page if your paper. The default value is "Daily Goosepaper" but you can change it like this: 52 | 53 | ```json 54 | "title" : "Jordan's Daily Goosepaper" 55 | ``` 56 | 57 | ### Subtitle 58 | 59 | The subtitle is at the second line at the top of the first page after yout title. 60 | 61 | ```json 62 | "subtitle" : "" 63 | ``` 64 | 65 | ### Font Size 66 | 67 | The fontsize determines the fontsize for all text in the goosepaper. Other text will be scaled accordingly, so a large body font will generally correspond (ideally, if the style is well-built) with larger headliner font sizes as well. The default is 12. 68 | 69 | ```json 70 | "font_size" : 14 71 | ``` 72 | 73 | (This only matters if your output is set as a `.pdf`) 74 | 75 | ### Styles 76 | 77 | There are a few prepackaged stylesheets that can be applied to your goosepaper. The default is `"FifthAvenue"`. You can change this to any of the following: 78 | 79 | - Academy 80 | - FifthAvenue 81 | - Autumn 82 | 83 | For more information on the styles and to see a gallery of the different stylesheets on the same goosepaper content, see the [Style Gallery](StyleGallery.md) page. 84 | 85 | ## Stories and StoryProviders 86 | 87 | Stories in a Goosepaper are created by a StoryProvider. You can think of a StoryProvider as a "source." So you might have wikipedia stories (`WikipediaCurrentEventsStoryProvider`), some blog posts (`RSSFeedStoryProvider`), etc. 88 | 89 | This section aims to be a comprehensive list of all storyproviders and how to configure them. 90 | (This was the case at time of writing.) 91 | 92 | In addition to the storyproviders listed here, there is also a separate repository, [auxilliary-goose](https://github.com/j6k4m8/auxiliary-goose/), where you can find additional storyproviders. For info on how to customize these check out the documentation in said repository. 93 | 94 | Stories and storyproviders are given in the config-file using the `"stories"`-key in the following way: 95 | (remember correct comma-separation in this file). 96 | 97 | ```json 98 | "stories" : [ 99 | { 100 | "provider" : "Storyprovider1", 101 | "config" : { 102 | "PARAMETER" : "VALUE", 103 | "PARAMETER" : "VALUE" 104 | } 105 | }, 106 | { 107 | "provider" : "Storyprovider2", 108 | "config" : { 109 | "PARAMETER" : "VALUE", 110 | "PARAMETER" : "VALUE" 111 | } 112 | }, 113 | ] 114 | ``` 115 | 116 | Right now, these are the storyproviders built into this repository: 117 | 118 | - [CustomText](#CustomText) 119 | - [Reddit](#Reddit) 120 | - [RSS](#RSS) 121 | - [Weather](#Weather) 122 | - [Wikipedia Current Events](#Wikipedia) 123 | 124 | ### CustomTextStoryProvider 125 | 126 | ```json 127 | "provider": "text" 128 | ``` 129 | 130 | This storyprovider fills paragraphs with your own custom text, or with Lorem Ipsum text if you don't provide anything. 131 | 132 | #### Paramaeters: 133 | 134 | | Parameter | Type | Default | Description | 135 | | ---------- | ---- | ------- | -------------------------------------------------------------- | 136 | | `headline` | str | None | The text to use. If not provided, the default is Lorem Ipsum. | 137 | | `text` | str | None | The text to use. If not provided, the default is Lorem Ipsum. | 138 | | `limit` | int | 5 | The number of paragraphs to generate, if text is not provided. | 139 | 140 | #### Example: 141 | 142 | ```json 143 | { 144 | "provider": "text", 145 | "config": { 146 | "headline": "This is a headline", 147 | "text": "This is some text" 148 | } 149 | } 150 | ``` 151 | 152 | ### Reddit 153 | 154 | ```json 155 | "provider" : "reddit" 156 | ``` 157 | 158 | This storyprovider gives headlines from a selected subreddit given in config file. The story gives the title, the username of the poster, and some text. 159 | 160 | #### Parameters: 161 | 162 | | Parameter | Type | Default | Description | 163 | | ---------------- | ---- | ------- | --------------------------------------- | 164 | | `subreddit` | str | None | The subreddit to use. | 165 | | `limit` | int | 20 | The number of stories to get. | 166 | | `since_days_ago` | int | None | If provided, filter stories by recency. | 167 | 168 | ### RSS 169 | 170 | ```json 171 | "provider" : "rss" 172 | ``` 173 | 174 | Returns results from a given RSS feed. Feed URL must be specified in the config file. 175 | 176 | The parameter `rss_path` has to be given a value in configfile. 177 | Default limiting value is `5`. 178 | 179 | #### Parameters: 180 | 181 | | Parameter | Type | Default | Description | 182 | | ---------------- | ---- | ------- | --------------------------------------- | 183 | | `rss_path` | str | None | The RSS feed to use. | 184 | | `limit` | int | 5 | The number of stories to get. | 185 | | `since_days_ago` | int | None | If provided, filter stories by recency. | 186 | 187 | ### Mastodon 188 | 189 | ```json 190 | "provider" : "mastodon" 191 | ``` 192 | 193 | Returns toots from given users. 194 | 195 | #### Parameters: 196 | 197 | | Parameter | Type | Default | Description | 198 | | ---------------- | ---- | ------- | ----------------------------------------------------- | 199 | | `username` | str | None | Mastodon username to use. | 200 | | `limit` | int | 8 | The number of stories to get. | 201 | | `since_days_ago` | int | None | If provided, filter stories by recency. | 202 | | `server` | str | None | The server to use (e.g., "https://neuromatch.social") | 203 | 204 | ### Weather 205 | 206 | ```json 207 | "provider" : "weather" 208 | ``` 209 | 210 | Get the weather forecast for the day. This story provider is placed in the "ear" of the Goosepaper front page, as you'd expect on a real newspaper. 211 | 212 | The weatherdata for this storyprovider is collected from [www.metaweather.com](https://www.metaweather.com/). 213 | 214 | #### Parameters: 215 | 216 | | Parameter | Type | Default | Description | 217 | | --------- | ----- | ------- | --------------------------------------------------- | 218 | | `lat` | float | None | The latitude of the location to get weather for. | 219 | | `lon` | float | None | The longitude of the location to get weather for. | 220 | | `F` | bool | True | If set to True, the forecast will be in Fahrenheit. | 221 | 222 | ### Wikipedia Current Events 223 | 224 | ```json 225 | "provider" : "wikipedia_current_events" 226 | ``` 227 | 228 | Returns current events section from Wikipedia. 229 | 230 | There are no configurable parameters for this story provider. 231 | -------------------------------------------------------------------------------- /docs/Example-Nov-1-2020.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/Example-Nov-1-2020.pdf -------------------------------------------------------------------------------- /docs/StyleGallery.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | 3 | 4 | | Autumn | FifthAvenue | Academy | 5 | |:------:|:-----------:|:-----:| 6 | | image | image | image | 7 | 8 | To specify a style, add `"style": "Academy"` to your `goosepaper.json` config file. -------------------------------------------------------------------------------- /docs/exampleWeather.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/exampleWeather.png -------------------------------------------------------------------------------- /docs/example_library_usage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | from goosepaper.goosepaper import Goosepaper 5 | from goosepaper.storyprovider.reddit import RedditHeadlineStoryProvider 6 | from goosepaper.storyprovider.rss import RSSFeedStoryProvider 7 | from goosepaper.storyprovider.weather import OpenMeteoWeatherStoryProvider 8 | from goosepaper.storyprovider.wikipedia import WikipediaCurrentEventsStoryProvider 9 | from goosepaper.upload import upload 10 | 11 | 12 | FNAME = datetime.now().strftime("%Y-%m-%d") + ".pdf" 13 | logging.info("Honk! I will save your temporary PDF to {FNAME}.") 14 | 15 | 16 | logging.info("Generating paper...") 17 | Goosepaper( 18 | [ 19 | WikipediaCurrentEventsStoryProvider(), 20 | OpenMeteoWeatherStoryProvider(lat=42.3601, lon=-71.0589, F=True), 21 | RSSFeedStoryProvider("https://www.npr.org/feed/", limit=5), 22 | RSSFeedStoryProvider("https://www.statnews.com/feed/", limit=2), 23 | RedditHeadlineStoryProvider("news"), 24 | RedditHeadlineStoryProvider("todayilearned"), 25 | ] 26 | ).to_pdf(FNAME) 27 | logging.info("Saved to PDF, now transferring...") 28 | 29 | 30 | upload(FNAME) 31 | logging.info("HONK! I'm done :)") 32 | -------------------------------------------------------------------------------- /docs/goose.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 19 | 25 | 63 | 64 | 65 | 66 | 67 | Goosepaper 68 | 69 | 70 | -------------------------------------------------------------------------------- /docs/reference/docs/example_library_usage.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/docs/example_library_usage.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/__main__.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/__main__.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/auth.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/auth.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/goosepaper.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/goosepaper.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/goosepaper.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/goosepaper.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/multiparser.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `__init__(self)` 2 | 3 | 4 | Creates a new MultiParser, which abstracts acessing command line arguments and config file entries. 5 | 6 | 7 | 8 | ## *Function* `argumentOrConfig(self, key, default=None, dependency=None)` 9 | 10 | 11 | Returns a command line argument or an entry from the config file 12 | 13 | ### Arguments 14 | > - **key** (`None`: `None`): the command line option name (as in --key) or config entry 15 | > - **default** (`str`: `None`): the default value, returned if the key was not 16 | set both as a command line argument and a config entry 17 | > - **dependency** (`str`: `None`): the name of a dependency command line 18 | argument or config entry that must be present for this call to be valid 19 | 20 | ### Returns 21 | If a command line option with 'key' name was set, returns it. Else, 22 | if a config entry named 'key' was set, returns it. If none of the previous was returned, returns the default value specified by the 'default' argument. 23 | 24 | -------------------------------------------------------------------------------- /docs/reference/goosepaper/story.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/story.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/styles.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/styles.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/upload.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/goosepaper/upload.py.md -------------------------------------------------------------------------------- /docs/reference/goosepaper/util.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `htmlize(text: Union[str, List[str]]) -> str` 2 | 3 | 4 | Generate HTML text from a text string, correctly formatting paragraphs etc. 5 | -------------------------------------------------------------------------------- /docs/reference/setup.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/setup.py.md -------------------------------------------------------------------------------- /docs/reference/storyprovider/reddit.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/storyprovider/reddit.py.md -------------------------------------------------------------------------------- /docs/reference/storyprovider/rss.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/storyprovider/rss.py.md -------------------------------------------------------------------------------- /docs/reference/storyprovider/storyprovider.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/storyprovider/storyprovider.md -------------------------------------------------------------------------------- /docs/reference/storyprovider/storyprovider.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `StoryProvider(abc.ABC)` 2 | 3 | 4 | An abstract class for a class that provides stories to be rendered. 5 | 6 | 7 | ## *Function* `get_stories(self, limit: int = 5) -> List["Story"]` 8 | 9 | 10 | Get a list of stories from this Provider. 11 | -------------------------------------------------------------------------------- /docs/reference/storyprovider/twitter.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `get_stories(self, limit: int = 10, **kwargs) -> List[Story]` 2 | 3 | 4 | Get a list of stories. 5 | 6 | Here, the headline is the @username, and the body text is the tweet. 7 | 8 | 9 | ## *Function* `get_stories(self, limit: int = 42, **kwargs) -> List[Story]` 10 | 11 | 12 | Get a list of tweets where each tweet is a story. 13 | 14 | ### Arguments 15 | > - **limit** (`int`: `15`): The maximum number of tweets to fetch 16 | 17 | ### Returns 18 | > - **List[Story]** (`None`: `None`): A list of tweets 19 | 20 | -------------------------------------------------------------------------------- /docs/reference/storyprovider/weather.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j6k4m8/goosepaper/525f3107f43da45f9f85ad70f7b900485df1a180/docs/reference/storyprovider/weather.py.md -------------------------------------------------------------------------------- /docs/reference/storyprovider/wikipedia.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `WikipediaCurrentEventsStoryProvider(StoryProvider)` 2 | 3 | 4 | A story provider that reads from today's current events on Wikipedia. 5 | 6 | 7 | ## *Function* `get_stories(self, limit: int = 10, **kwargs) -> List[Story]` 8 | 9 | 10 | Get a list of current stories from Wikipedia. 11 | -------------------------------------------------------------------------------- /example-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "font_size": 12, 3 | "stories": [ 4 | { 5 | "provider": "weather", 6 | "config": { 7 | "lat": 36.5, 8 | "lon": -75.1, 9 | "F": true 10 | } 11 | }, 12 | { 13 | "provider": "mastodon", 14 | "config": { 15 | "since_days_ago": 0.5, 16 | "username": "jordan", 17 | "server": "https://neuromatch.social", 18 | "limit_per": 8 19 | } 20 | }, 21 | { 22 | "provider": "wikipedia_current_events", 23 | "config": {} 24 | }, 25 | { 26 | "provider": "rss", 27 | "config": { 28 | "rss_path": "https://feeds.npr.org/1001/rss.xml", 29 | "limit": 5 30 | } 31 | }, 32 | { 33 | "provider": "reddit", 34 | "config": { 35 | "subreddit": "news" 36 | } 37 | }, 38 | { 39 | "provider": "reddit", 40 | "config": { 41 | "subreddit": "todayilearned" 42 | } 43 | }, 44 | { 45 | "provider": "rss", 46 | "config": { 47 | "rss_path": "https://krebsonsecurity.com/feed/", 48 | "limit": 1, 49 | "skip": true 50 | } 51 | } 52 | ] 53 | } -------------------------------------------------------------------------------- /goosepaper/__init__.py: -------------------------------------------------------------------------------- 1 | from .goosepaper import Goosepaper # noqa 2 | 3 | __version__ = "0.7.0" 4 | -------------------------------------------------------------------------------- /goosepaper/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | 4 | from goosepaper.goosepaper import Goosepaper 5 | from goosepaper.util import construct_story_providers_from_config_dict 6 | from goosepaper.upload import upload 7 | from goosepaper.multiparser import MultiParser 8 | 9 | 10 | def main(): 11 | multiparser = MultiParser() 12 | config = multiparser.config 13 | 14 | nostory = multiparser.argumentOrConfig("nostory") 15 | 16 | filename = multiparser.argumentOrConfig( 17 | "output", 18 | default=f"Goosepaper-{datetime.datetime.now().strftime('%Y-%B-%d-%H-%M')}.pdf", 19 | ) 20 | 21 | if not nostory: # global nostory flag 22 | story_providers = construct_story_providers_from_config_dict(config) 23 | font_size = multiparser.argumentOrConfig("font_size", 14) 24 | style = multiparser.argumentOrConfig("style", "FifthAvenue") 25 | 26 | title = config["title"] if "title" in config else None 27 | subtitle = config["subtitle"] if "subtitle" in config else None 28 | 29 | paper = Goosepaper( 30 | story_providers=story_providers, title=title, subtitle=subtitle 31 | ) 32 | 33 | if filename.endswith(".html"): 34 | with open(filename, "w") as fh: 35 | fh.write(paper.to_html()) 36 | elif filename.endswith(".pdf"): 37 | paper.to_pdf(filename, font_size=font_size, style=style) 38 | elif filename.endswith(".epub"): 39 | paper.to_epub(filename, font_size=font_size, style=style) 40 | else: 41 | print(f"Unknown file extension '{filename.split('.')[-1]}'.") 42 | exit(1) 43 | 44 | if multiparser.argumentOrConfig("upload"): 45 | if multiparser.argumentOrConfig("noupload"): 46 | print( 47 | "Honk! The 'upload' directive was found, but '--noupload' was also specified on the command line. Your goosepaper {0} was generated but I'm not uploading it.".format( 48 | filename 49 | ) 50 | ) 51 | else: 52 | upload(filepath=filename, multiparser=multiparser) 53 | 54 | return 0 55 | 56 | 57 | if __name__ == "__main__": 58 | sys.exit(main()) 59 | -------------------------------------------------------------------------------- /goosepaper/auth.py: -------------------------------------------------------------------------------- 1 | from rmapy.api import Client 2 | from rmapy.exceptions import AuthError 3 | 4 | CODE_URL = "https://my.remarkable.com/connect/remarkable" 5 | 6 | 7 | def auth_client(): 8 | client = Client() 9 | 10 | try: 11 | client.renew_token() 12 | except AuthError: 13 | print( 14 | "Looks like this is the first time you've uploaded. You need to " 15 | f"register the device. Input a code from {CODE_URL}" 16 | ) 17 | code = input() 18 | print("registering") 19 | client.register_device(code) 20 | if not client.renew_token(): 21 | print("Honk! Registration renewal failed.") 22 | return False 23 | else: 24 | print("registration successful") 25 | 26 | return client 27 | -------------------------------------------------------------------------------- /goosepaper/goosepaper.json: -------------------------------------------------------------------------------- 1 | { 2 | "font_size": "14", 3 | "replace": false, 4 | "nocase": false, 5 | "strictlysane": false, 6 | "upload": false, 7 | "output": null, 8 | "folder": null, 9 | "nostory": false, 10 | "cleanup": false 11 | } 12 | -------------------------------------------------------------------------------- /goosepaper/goosepaper.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from typing import List, Optional, Type, Union 3 | import datetime 4 | import io 5 | import tempfile 6 | from uuid import uuid4 7 | 8 | from goosepaper.story import Story 9 | 10 | from .styles import Style 11 | from .util import PlacementPreference 12 | from .storyprovider.storyprovider import StoryProvider 13 | 14 | 15 | def _get_style(style): 16 | if isinstance(style, str): 17 | style_obj = Style(style) 18 | else: 19 | try: 20 | style_obj = style() 21 | except Exception as e: 22 | raise ValueError(f"Invalid style {style}") from e 23 | return style_obj 24 | 25 | 26 | class Goosepaper: 27 | """ 28 | A high-level class that manages the creation and styling of a goosepaper 29 | periodical delivery. 30 | 31 | """ 32 | 33 | def __init__( 34 | self, 35 | story_providers: List[StoryProvider], 36 | title: str = None, 37 | subtitle: str = None, 38 | ): 39 | """ 40 | Create a new Goosepaper. 41 | 42 | Arguments: 43 | story_providers: A list of StoryProvider objects to render 44 | title: The title of the goosepaper 45 | subtitle: The subtitle of the goosepaper 46 | 47 | """ 48 | self.story_providers = story_providers 49 | self.title = title if title else "Daily Goosepaper" 50 | self.subtitle = subtitle + "\n" if subtitle else "" 51 | self.subtitle += datetime.datetime.today().strftime("%B %d, %Y %H:%M") 52 | 53 | def get_stories(self, deduplicate: bool = False) -> List[Story]: 54 | """ 55 | Retrieve the complete list of stories to render in this Goosepaper. 56 | 57 | Arguments: 58 | deduplicate: Whether to remove duplicate stories. Default: False 59 | 60 | Returns: 61 | List[Story] 62 | 63 | """ 64 | stories: List[Story] = [] 65 | for prov in self.story_providers: 66 | new_stories = prov.get_stories() 67 | for a in new_stories: 68 | if deduplicate: 69 | for b in stories: 70 | if a.headline == b.headline and a.date == b.date: 71 | break 72 | else: 73 | stories.append(a) 74 | else: 75 | stories.append(a) 76 | return stories 77 | 78 | def to_html(self) -> str: 79 | """ 80 | Produce an HTML version of the Goosepaper. 81 | 82 | Arguments: 83 | None 84 | 85 | Returns: 86 | str: An HTML version of the paper 87 | 88 | """ 89 | stories = self.get_stories() 90 | 91 | # Get ears: 92 | ears = [ 93 | s 94 | for s in stories 95 | # TODO: Which to prioritize? 96 | if s.placement_preference == PlacementPreference.EAR 97 | ] 98 | right_ear = "" 99 | left_ear = "" 100 | if len(ears) > 0: 101 | right_ear = ears[0].to_html() 102 | if len(ears) > 1: 103 | left_ear = ears[1].to_html() 104 | 105 | main_stories = [ 106 | s.to_html() 107 | for s in stories 108 | if s.placement_preference 109 | not in [PlacementPreference.EAR, PlacementPreference.SIDEBAR] 110 | ] 111 | 112 | sidebar_stories = [ 113 | s.to_html() 114 | for s in stories 115 | if s.placement_preference == PlacementPreference.SIDEBAR 116 | ] 117 | 118 | return f""" 119 | 120 | 121 | 125 | 126 | 127 | 128 |
129 |
{left_ear}
130 |

{self.title}

{self.subtitle}

131 |
{right_ear}
132 |
133 |
134 |
135 | {"
".join(main_stories)} 136 |
137 | 140 |
141 | 142 | 143 | """ 144 | 145 | def to_pdf( 146 | self, 147 | filename: Union[str, io.BytesIO], 148 | style: Union[str] = "", 149 | font_size: int = 14, 150 | ) -> Optional[str]: 151 | """ 152 | Renders the current Goosepaper to a PDF file on disk. 153 | 154 | Arguments: 155 | filename: The filename to save the PDF to. If this is an io.BytesIO 156 | object, the PDF will be written to the object instead and this 157 | function will return None. 158 | style: The style to use for the paper. Default: FifthAvenueStyle 159 | font_size: The font size to use for the paper. Default: 14 160 | 161 | Returns: 162 | str: The filename of the PDF file. If `filename` is an IO object, 163 | then this will return None. 164 | 165 | """ 166 | from weasyprint import HTML, CSS 167 | from weasyprint.text.fonts import FontConfiguration 168 | 169 | font_config = FontConfiguration() 170 | style_obj = _get_style(style) 171 | html = self.to_html() 172 | h = HTML(string=html) 173 | base_url = str(pathlib.Path.cwd()) 174 | c = CSS( 175 | string=style_obj.get_css(font_size), 176 | font_config=font_config, 177 | base_url=base_url, 178 | ) 179 | # Check if the file is a filepath (str): 180 | if isinstance(filename, str): 181 | h.write_pdf( 182 | filename, 183 | stylesheets=[c, *style_obj.get_stylesheets()], 184 | font_config=font_config, 185 | ) 186 | return filename 187 | elif isinstance(filename, io.BytesIO): 188 | # Create a tempfile to save the PDF to: 189 | tf = tempfile.NamedTemporaryFile(suffix=".pdf") 190 | h.write_pdf( 191 | tf, 192 | stylesheets=[c, *style_obj.get_stylesheets()], 193 | ) 194 | tf.seek(0) 195 | filename.write(tf.read()) 196 | return None 197 | else: 198 | raise ValueError(f"Invalid filename {filename}") 199 | 200 | def to_epub( 201 | self, 202 | filename: Union[str, io.BytesIO], 203 | style: Union[str, Type[Style]] = "", 204 | font_size: int = 14, 205 | ) -> Optional[str]: 206 | """ 207 | Render the current Goosepaper to an epub file on disk. 208 | 209 | Arguments: 210 | filename: The filename to save the epub to. If `filename` is an 211 | IO object, then this will return None and the epub will be 212 | written to that object. 213 | style: The style to use for the paper. Default: FifthAvenueStyle 214 | font_size: The font size to use for the paper. Default: 14 215 | 216 | """ 217 | from ebooklib import epub 218 | 219 | style_obj = _get_style(style) 220 | 221 | stories = [] 222 | for prov in self.story_providers: 223 | new_stories = prov.get_stories() 224 | for a in new_stories: 225 | if not a.headline: 226 | stories.append(a) 227 | continue 228 | for b in stories: 229 | if a.headline == b.headline: 230 | break 231 | else: 232 | stories.append(a) 233 | 234 | book = epub.EpubBook() 235 | title = f"{self.title} - {self.subtitle}" 236 | book.set_title(title) 237 | book.set_language("en") 238 | 239 | css = epub.EpubItem( 240 | uid="style_default", 241 | file_name="style/default.css", 242 | media_type="text/css", 243 | content=style_obj.get_css(font_size), 244 | ) 245 | book.add_item(css) 246 | 247 | chapters = [] 248 | links = [] 249 | no_headlines = [] 250 | for story in stories: 251 | if not story.headline: 252 | no_headlines.append(story) 253 | stories = [x for x in stories if x.headline] 254 | for story in stories: 255 | file = f"{uuid4().hex}.xhtml" 256 | title = story.headline 257 | chapter = epub.EpubHtml(title=title, file_name=file, lang="en") 258 | links.append(file) 259 | chapter.content = story.to_html() 260 | book.add_item(chapter) 261 | chapters.append(chapter) 262 | 263 | if no_headlines: 264 | file = f"{uuid4().hex}.xhtml" 265 | chapter = epub.EpubHtml( 266 | title="From Reddit", 267 | file_name=file, 268 | lang="en", 269 | ) 270 | links.append(file) 271 | chapter.content = "
".join([s.to_html() for s in no_headlines]) 272 | book.add_item(chapter) 273 | chapters.append(chapter) 274 | 275 | book.toc = chapters 276 | book.add_item(epub.EpubNcx()) 277 | book.add_item(epub.EpubNav()) 278 | book.spine = ["nav"] + chapters 279 | 280 | if isinstance(filename, str): 281 | epub.write_epub(filename, book) 282 | return filename 283 | elif isinstance(filename, io.BytesIO): 284 | # Create a tempfile buffer: 285 | tf = tempfile.NamedTemporaryFile(suffix=".epub") 286 | epub.write_epub(tf, book) 287 | tf.seek(0) 288 | filename.write(tf.read()) 289 | return None 290 | -------------------------------------------------------------------------------- /goosepaper/multiparser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pathlib 3 | 4 | from goosepaper.util import load_config_file 5 | 6 | 7 | class NewLineFormatter(argparse.HelpFormatter): 8 | def _split_lines(self, text, width): 9 | if text.startswith("||"): 10 | return text[2:].splitlines() 11 | return argparse.HelpFormatter._split_lines(self, text, width) 12 | 13 | 14 | class MultiParser: 15 | def __init__(self): 16 | """ 17 | Creates a new MultiParser, which abstracts acessing command line 18 | arguments and config file entries. 19 | 20 | """ 21 | 22 | self.parser = argparse.ArgumentParser( 23 | prog="goosepaper", 24 | description="Goosepaper generates and delivers a daily newspaper in PDF format.", 25 | formatter_class=NewLineFormatter, 26 | ) 27 | 28 | self.parser.add_argument( 29 | "-c", 30 | "--config", 31 | required=False, 32 | default="", 33 | help="The json file to use to generate this paper.", 34 | ) 35 | self.parser.add_argument( 36 | "-o", 37 | "--output", 38 | required=False, 39 | help="The output file path at which to save the paper", 40 | ) 41 | self.parser.add_argument( 42 | "-f", 43 | "--folder", 44 | required=False, 45 | help="Folder to which the document will be uploaded in your remarkable.", 46 | ) 47 | self.parser.add_argument( 48 | "-u", 49 | "--upload", 50 | action="store_true", 51 | required=False, 52 | default=None, 53 | help="Whether to upload the file to your remarkable using rmapy.", 54 | ) 55 | self.parser.add_argument( 56 | "--noupload", 57 | action="store_true", 58 | required=False, 59 | default=None, 60 | help="Overrides any other 'upload: true' arguments from config files or command line. Useful for testing configs or story generation without having to edit config files.", 61 | ) 62 | self.parser.add_argument( 63 | "--showconfig", 64 | action="store_true", 65 | required=False, 66 | default=None, 67 | help="Print out all config files and command line options in order loaded and the final config to help finding conflicting options. Needed since there are now four possible ways to pass options.", 68 | ) 69 | self.parser.add_argument( 70 | "-n", 71 | "--nostory", 72 | required=False, 73 | default=False, 74 | action="store_true", 75 | help='||Skip story creation. Combined with "--upload" can be used to\nupload a preexisting output file.\n\n** NOTE ** If used without "--upload" goosepaper will run but\nperform no action.', 76 | ) 77 | self.parser.add_argument( 78 | "--replace", 79 | action="store_true", 80 | required=False, 81 | default=None, 82 | help="||Will replace a document with same name in your remarkable.\nDefault behaviour is case sensitive (ala *nix/Mac).\n\ne.g. 'A Flock of RSS Feeds.epub' and 'a flock of rss feeds.epub'\nare seen as TWO different files. Can be altered with '--nocase'\nor '--strictlysane' switches.", 83 | ) 84 | self.parser.add_argument( 85 | "--noreplace", 86 | action="store_true", 87 | required=False, 88 | default=None, 89 | help="Only valid when specified on command line (ignored if present in any config file). Supersedes any config file setting for 'replace: true', thus ensuring that the file will NEVER be overwritten. Will also supersede command line '--replace' if both are specified regardless of order.", 90 | ) 91 | self.parser.add_argument( 92 | "--cleanup", 93 | required=False, 94 | default=None, 95 | action="store_true", 96 | help="Delete the output file after upload.", 97 | ) 98 | self.args = self.parser.parse_args() 99 | 100 | # These are in order of precedence, low to highest 101 | # 1. Home directory global configs 102 | # 2. Local directory from which goosepaper is called 103 | # 3. Specified on the command line. 104 | 105 | defaultconfigs = list( 106 | set( 107 | [ 108 | str(pathlib.Path("~").expanduser()) + "/.goosepaper.json", 109 | "goosepaper.json", 110 | self.args.config, 111 | ] 112 | ) 113 | ) 114 | self.config = {} 115 | outputcount = 0 116 | debug_configs = True if self.args.showconfig else None 117 | 118 | # Debug code for troubleshooting config file and cli override issues. 119 | if debug_configs: 120 | import pprint 121 | 122 | pp = pprint.PrettyPrinter(indent=3) 123 | print( 124 | "\n".join( 125 | [ 126 | "Command line arguments received:", 127 | "(including default values)", 128 | "--------------------------------", 129 | ] 130 | ) 131 | ) 132 | pp.pprint(self.args) 133 | 134 | # If passed a config file on the command line, assume it's important 135 | # so fail if not readable. 136 | 137 | if self.args.config: 138 | try: 139 | load_config_file(self.args.config) 140 | except FileNotFoundError: 141 | print( 142 | f"Couldn't find config file ({self.args.config}) " 143 | "specified on the command line. Aborting." 144 | ) 145 | exit(1) 146 | 147 | for defconfigfile in defaultconfigs: 148 | try: 149 | tempconfig = load_config_file(defconfigfile) 150 | if "output" in tempconfig and "output" in self.config: 151 | outputcount = outputcount + 1 152 | if "stories" in tempconfig and "stories" in self.config: 153 | for story in self.config["stories"]: 154 | tempconfig["stories"].append(story) 155 | 156 | self.config.update(tempconfig) 157 | if debug_configs: 158 | print( 159 | f"\nConfig options found in {defconfigfile}:" 160 | "\n---------------------\n" 161 | ) 162 | pp.pprint(load_config_file(defconfigfile)) 163 | except FileNotFoundError: 164 | pass 165 | 166 | if debug_configs: 167 | print("\nFinal config values are:\n------------------") 168 | pp.pprint(self.config) 169 | print("") 170 | 171 | if "noreplace" in self.config: 172 | del self.config["noreplace"] 173 | 174 | def argumentOrConfig(self, key, default=None, dependency=None): 175 | """ 176 | Returns a command line argument or an entry from the config file 177 | 178 | Arguments: 179 | key: the command line option name (as in --key) or config entry 180 | default (str: None): the default value, returned if the key was not 181 | set both as a command line argument and a config entry 182 | dependency (str: None): the name of a dependency command line 183 | argument or config entry that must be present for this call to 184 | be valid 185 | 186 | Returns: 187 | If a command line option with 'key' name was set, returns it. Else, 188 | if a config entry named 'key' was set, returns it. If none of 189 | the previous was returned, returns the default value specified 190 | by the 'default' argument. 191 | 192 | """ 193 | 194 | d = vars(self.args) 195 | if key in d and d[key] is not None: 196 | if dependency and dependency not in d: 197 | self.parser.error(f"--{key} requires --{dependency}.") 198 | value = d[key] 199 | elif key in self.config: 200 | value = self.config[key] or default 201 | else: 202 | value = default 203 | 204 | return value 205 | -------------------------------------------------------------------------------- /goosepaper/story.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Optional, Union 3 | 4 | from .util import PlacementPreference, htmlize, StoryPriority 5 | 6 | 7 | class Story: 8 | def __init__( 9 | self, 10 | headline: Optional[str], 11 | body_html: str = None, 12 | body_text: Union[str, List[str]] = None, 13 | byline: str = None, 14 | date: datetime.datetime = None, 15 | priority: StoryPriority = StoryPriority.DEFAULT, 16 | placement_preference: PlacementPreference = PlacementPreference.NONE, 17 | ) -> None: 18 | """ 19 | Create a new Story with headline and body text. 20 | """ 21 | self.headline = headline 22 | self.priority = priority 23 | self.byline = byline 24 | self.date = date 25 | if body_html is not None: 26 | self.body_html = body_html 27 | elif body_text is not None: 28 | self.body_html = htmlize(body_text) 29 | else: 30 | raise ValueError( 31 | "You must provide at least one of body_html or body_text " 32 | "to the Story constructor" 33 | ) 34 | self.placement_preference = placement_preference 35 | 36 | def to_html(self) -> str: 37 | byline_p = f"" if self.byline else "" 38 | priority_class = { 39 | StoryPriority.DEFAULT: "", 40 | StoryPriority.LOW: "priority-low", 41 | StoryPriority.BANNER: "priority-banner", 42 | }[self.priority] 43 | headline = ( 44 | f"

{self.headline}

" 45 | if self.headline 46 | else "" 47 | ) 48 | return f""" 49 |
50 | {headline} 51 | {byline_p} 52 | {self.body_html} 53 |
54 | """ 55 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/__init__.py: -------------------------------------------------------------------------------- 1 | from .storyprovider import StoryProvider # noqa 2 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/mastodon.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import feedparser 3 | from typing import List 4 | 5 | from .storyprovider import StoryProvider 6 | from ..story import Story 7 | 8 | 9 | class MastodonStoryProvider(StoryProvider): 10 | def __init__( 11 | self, 12 | server: str, 13 | username: str, 14 | limit: int = 5, 15 | since_days_ago: int = None, 16 | ) -> None: 17 | self.limit = limit 18 | self.username = username 19 | self.feed_url = server.rstrip("/") + "/@" + username.lstrip("@") + ".rss" 20 | self._since = ( 21 | datetime.datetime.now() - datetime.timedelta(days=since_days_ago) 22 | if since_days_ago 23 | else None 24 | ) 25 | 26 | def get_stories(self, limit: int = 5, **kwargs) -> List[Story]: 27 | feed = feedparser.parse(self.feed_url) 28 | limit = min(limit, self.limit, len(feed.entries)) 29 | if limit == 0: 30 | print(f"Sad honk :/ No entries found for feed {self.feed_url}...") 31 | 32 | stories = [] 33 | for entry in feed.entries: 34 | date = datetime.datetime(*entry.published_parsed[:6]) 35 | if self._since is not None and date < self._since: 36 | continue 37 | 38 | # Just return the headline content: 39 | story = Story( 40 | "@" 41 | + self.username.lstrip("@") 42 | + " at " 43 | + date.strftime("%Y-%m-%d %H:%M"), 44 | body_html=entry["summary"], 45 | byline=self.username, 46 | date=date, 47 | ) 48 | 49 | stories.append(story) 50 | if len(stories) >= limit: 51 | break 52 | 53 | return list(filter(None, stories)) 54 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/reddit.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import datetime 3 | import feedparser 4 | 5 | from ..util import PlacementPreference 6 | from .storyprovider import StoryProvider 7 | from ..story import Story 8 | 9 | 10 | class RedditHeadlineStoryProvider(StoryProvider): 11 | def __init__(self, subreddit: str, limit: int = 20, since_days_ago: int = None): 12 | self.limit = limit 13 | self._since = ( 14 | datetime.datetime.now() - datetime.timedelta(days=since_days_ago) 15 | if since_days_ago 16 | else None 17 | ) 18 | subreddit.lstrip("/") 19 | subreddit = subreddit[2:] if subreddit.startswith("r/") else subreddit 20 | self.subreddit = subreddit 21 | 22 | def get_stories(self, limit: int = 20, **kwargs) -> List[Story]: 23 | feed = feedparser.parse(f"https://www.reddit.com/r/{self.subreddit}.rss") 24 | limit = min(self.limit, len(feed.entries), limit) 25 | stories = [] 26 | for entry in feed.entries: 27 | try: 28 | author = entry.author 29 | except AttributeError: 30 | author = "A Reddit user" 31 | 32 | date = datetime.datetime(*entry.updated_parsed[:6]) 33 | if self._since is not None and date < self._since: 34 | continue 35 | 36 | stories.append( 37 | Story( 38 | headline="", 39 | body_text=str(entry.title), 40 | byline=f"{author} in r/{self.subreddit}", 41 | date=date, 42 | placement_preference=PlacementPreference.SIDEBAR, 43 | ) 44 | ) 45 | if len(stories) >= limit: 46 | break 47 | 48 | return stories 49 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/rss.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import requests 3 | import feedparser 4 | import urllib.parse 5 | from typing import List 6 | from readability import Document 7 | 8 | from .storyprovider import StoryProvider 9 | from ..story import Story 10 | 11 | 12 | class RSSFeedStoryProvider(StoryProvider): 13 | def __init__( 14 | self, 15 | rss_path: str, 16 | limit: int = 5, 17 | since_days_ago: int = None, 18 | ) -> None: 19 | self.limit = limit 20 | self.feed_url = rss_path 21 | self._since = ( 22 | datetime.datetime.now() - datetime.timedelta(days=since_days_ago) 23 | if since_days_ago 24 | else None 25 | ) 26 | 27 | def get_stories(self, limit: int = 5, **kwargs) -> List[Story]: 28 | feed = feedparser.parse(self.feed_url) 29 | limit = min(limit, self.limit, len(feed.entries)) 30 | if limit == 0: 31 | print(f"Sad honk :/ No entries found for feed {self.feed_url}...") 32 | 33 | stories = [] 34 | for entry in feed.entries: 35 | date = datetime.datetime(*entry.updated_parsed[:6]) 36 | if self._since is not None and date < self._since: 37 | continue 38 | 39 | req = requests.get(entry["link"], headers={'User-Agent': 'goosepaper/0.7.1'}) 40 | # Source is the URL root: 41 | source = urllib.parse.urlparse(entry["link"]).netloc 42 | if not req.ok: 43 | # Just return the headline content: 44 | story = Story( 45 | entry["title"], 46 | body_html=entry["summary"], 47 | byline=source, 48 | date=date, 49 | ) 50 | else: 51 | doc = Document(req.content) 52 | story = Story( 53 | doc.title(), 54 | body_html=doc.summary(), 55 | byline=source, 56 | date=date, 57 | ) 58 | 59 | stories.append(story) 60 | if len(stories) >= limit: 61 | break 62 | 63 | return list(filter(None, stories)) 64 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/storyprovider.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import List 3 | from ..story import Story 4 | 5 | 6 | class StoryProvider(abc.ABC): 7 | """ 8 | An abstract class for a class that provides stories to be rendered. 9 | """ 10 | 11 | def get_stories(self, limit: int = 5) -> List["Story"]: 12 | """ 13 | Get a list of stories from this Provider. 14 | """ 15 | ... 16 | 17 | 18 | class CustomTextStoryProvider(StoryProvider): 19 | def __init__(self, limit: int = 5, headline: str = None, text: str = None): 20 | self.text = text or [ 21 | "Lorem ipsum! dolor sit amet, consectetur adipiscing elit. Duis eget velit sem. In elementum eget lorem non luctus. Vivamus tempus justo in pulvinar ultrices. Aliquam ac maximus leo. Quisque ipsum sapien, vestibulum viverra tempus ac, vestibulum quis justo. Nullam ut purus varius, bibendum metus ac, viverra enim. Phasellus sodales ullamcorper sapien pretium tristique. Duis dapibus felis quis tincidunt ultrices. Etiam purus sapien, tincidunt ac turpis vel, eleifend placerat enim. In sed mauris justo. Suspendisse ac tincidunt nunc. Nullam luctus porta pretium. Donec porttitor, nulla ut finibus pretium, augue turpis posuere ante, ac congue nunc nulla eu nisl. Phasellus imperdiet vel augue id gravida.", 22 | "Morbi mattis egestas quam, in tempus elit efficitur sagittis. Sed in maximus lorem. Aliquam erat volutpat. Phasellus mattis varius velit, vitae varius justo. Sed imperdiet eget dolor non consequat. Cras non felis neque. Nam eget arcu sapien. Morbi ultrices tristique cursus. Sed tempor ex lorem, vel ultrices sem placerat non. Nullam tortor arcu, imperdiet id lobortis a, commodo nec mi. Duis rhoncus in est sit amet tristique. Mauris condimentum nisl a erat tristique, id dictum risus euismod. Phasellus at sapien ante. Morbi facilisis tortor id leo porta, condimentum mollis dolor suscipit.", 23 | "Phasellus ut nibh vitae turpis congue venenatis. Morbi mollis justo dolor, ac finibus erat suscipit vitae. Donec libero erat, luctus quis sapien vel, sagittis dapibus est. Ut non quam et nisl hendrerit sodales. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sodales ut augue a lacinia. Phasellus mattis sapien eget nibh auctor porttitor. Sed feugiat consectetur risus, a tempus ipsum scelerisque eu. Maecenas suscipit erat quis neque vulputate, ornare vehicula tellus lobortis. Duis tempor elit scelerisque ex tincidunt imperdiet. Curabitur dictum condimentum turpis, vitae ultrices ante sodales a. Praesent eu erat nec odio placerat placerat. Sed et dolor augue.", 24 | "Curabitur consectetur, nisi eget consequat ultrices, erat ante tincidunt ipsum, eget varius mauris turpis ac enim. Vivamus rutrum condimentum metus ut egestas. Nulla consectetur tincidunt laoreet. Vivamus tortor sem, imperdiet sodales facilisis quis, elementum nec erat. Curabitur imperdiet, nulla vel mattis gravida, risus eros sollicitudin magna, nec feugiat mauris mauris eu lorem. In hac habitasse platea dictumst. Sed tincidunt facilisis sem, non commodo metus volutpat nec. Fusce nulla mauris, vulputate sit amet magna id, blandit ornare leo. Nam vel faucibus ipsum, ac congue dolor.", 25 | "Vivamus pretium purus vel libero finibus blandit. Donec vitae nisl sollicitudin, consectetur nunc ac, volutpat libero. Maecenas ac leo ut velit viverra aliquet non id turpis. Morbi ut euismod erat. Vestibulum congue sed erat nec dapibus. Donec semper consectetur vestibulum. Praesent egestas dolor a ante sodales maximus. Suspendisse a odio vitae odio sagittis sollicitudin in quis massa. Praesent at convallis nulla. Mauris a nisl tincidunt, iaculis lacus eget, lobortis sapien. Nullam condimentum neque quis nisi consequat, eget accumsan tellus fermentum. Quisque dictum, nunc et pretium accumsan, lacus eros pharetra odio, ac euismod orci lorem sed turpis. ", 26 | ] 27 | self.limit = limit 28 | self.headline = headline or "Lorem Ipsum Dolor Sit Amet" 29 | 30 | def get_stories(self, limit: int = 5, **kwargs) -> List[Story]: 31 | return [ 32 | Story(headline=self.headline, body_text=self.text) 33 | for _ in range(min(self.limit, limit)) 34 | ] 35 | 36 | 37 | LoremStoryProvider = CustomTextStoryProvider 38 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/test_mastodon.py: -------------------------------------------------------------------------------- 1 | from .mastodon import MastodonStoryProvider 2 | 3 | 4 | def test_can_get_mastodon_stories(): 5 | LIMIT = 5 6 | stories = MastodonStoryProvider( 7 | "https://neuromatch.social", 8 | "jordan", 9 | limit=LIMIT, 10 | ).get_stories() 11 | assert len(stories) == LIMIT 12 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/weather.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import List 3 | 4 | from ..util import PlacementPreference 5 | from .storyprovider import StoryProvider 6 | from ..story import Story 7 | 8 | 9 | class MetaWeatherStoryProvider(StoryProvider): 10 | def __init__(self, woe: str = "2358820", F: bool = True): 11 | self.woe = woe 12 | self.F = F 13 | 14 | def CtoF(self, temp: float) -> float: 15 | return (temp * 9 / 5) + 32 16 | 17 | def get_stories(self, limit: int = 1, **kwargs) -> List[Story]: 18 | weatherReq = requests.get( 19 | f"https://www.metaweather.com/api/location/{self.woe}/" 20 | ).json() 21 | weather = weatherReq["consolidated_weather"][0] 22 | weatherTitle = weatherReq["title"] 23 | if self.F: 24 | headline = f"{int(self.CtoF(weather['the_temp']))}ºF with {weather['weather_state_name']} in {weatherTitle}" 25 | body_html = f""" 26 | 29 | {int(self.CtoF(weather['min_temp']))} – {int(self.CtoF(weather['max_temp']))}ºF, Winds {weather['wind_direction_compass']} 30 | """ 31 | else: 32 | headline = f"{weather['the_temp']:.1f}ºC with {weather['weather_state_name']} in {weatherTitle}" 33 | body_html = f""" 34 | 37 | {weather['min_temp']:.1f} – {weather['max_temp']:.1f}ºC, Winds {weather['wind_direction_compass']} 38 | """ 39 | return [ 40 | Story( 41 | headline=headline, 42 | body_html=body_html, 43 | placement_preference=PlacementPreference.EAR, 44 | ) 45 | ] 46 | 47 | 48 | _WEATHER_CODES = { 49 | 0: "Clear sky", 50 | 1: "Mainly clear", 51 | 2: "Partly cloudy", 52 | 3: "Overcast", 53 | 45: "Fog", 54 | 48: "Depositing rime fog", 55 | 51: "Light drizzle", 56 | 53: "Moderate drizzle", 57 | 55: "Heavy drizzle", 58 | 56: "Light freezing drizzle", 59 | 57: "Heavy freezing drizzle", 60 | 61: "Slight rain", 61 | 63: "Moderate rain", 62 | 65: "Heavy rain", 63 | 66: "Light freezing rain", 64 | 67: "Heavy freezing rain", 65 | 71: "Light snow", 66 | 73: "Moderate snow", 67 | 75: "Heavy snow", 68 | 77: "Snow grains", 69 | 80: "Light rain showers", 70 | 81: "Moderate rain showers", 71 | 82: "Heavy rain showers", 72 | 85: "Light snow showers", 73 | 86: "Heavy snow showers", 74 | 95: "Thunderstorms", 75 | 96: "Thunderstorms with hail", 76 | 99: "Thunderstorms with heavy hail", 77 | } 78 | 79 | 80 | class OpenMeteoWeatherStoryProvider(StoryProvider): 81 | def __init__( 82 | self, 83 | lat: float, 84 | lon: float, 85 | F: bool = True, 86 | timezone: str = "America%2FNew_York", 87 | **kwargs, 88 | ): 89 | self.lat = lat 90 | self.lon = lon 91 | self.F = F 92 | self.timezone = timezone.replace("/", "%2F") 93 | if "woe" in kwargs: 94 | raise ValueError( 95 | "OpenMeteoWeatherStoryProvider does not support WOEIDs. Please pass a lat and lon instead." 96 | ) 97 | 98 | def _weather_code_to_string(self, code: int): 99 | return ( 100 | _WEATHER_CODES[code] 101 | if code in _WEATHER_CODES 102 | else "Unknown weather code [{}]".format(code) 103 | ) 104 | 105 | def _build_url(self): 106 | return ( 107 | f"https://api.open-meteo.com/v1/forecast?latitude={self.lat}" 108 | f"&longitude={self.lon}&daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_sum" 109 | f"&temperature_unit={'fahrenheit' if self.F else 'celsius'}&timezone={self.timezone}" 110 | ) 111 | 112 | def get_stories(self, limit: int = 1, **kwargs) -> List[Story]: 113 | res = requests.get(self._build_url()).json() 114 | daily = res["daily"] 115 | todays_high = daily["temperature_2m_max"][0] 116 | todays_low = daily["temperature_2m_min"][0] 117 | # todays_precip = daily["precipitation_sum"][0] 118 | weathercode_string = self._weather_code_to_string(daily["weathercode"][0]) 119 | unit = "F" if self.F else "C" 120 | headline = f"{todays_high:.1f}º{unit}/{todays_low:.1f}º{unit}" 121 | return [ 122 | Story( 123 | headline=headline, 124 | body_text=f"{weathercode_string}", 125 | placement_preference=PlacementPreference.EAR, 126 | ) 127 | ] 128 | -------------------------------------------------------------------------------- /goosepaper/storyprovider/wikipedia.py: -------------------------------------------------------------------------------- 1 | import feedparser 2 | import bs4 3 | from typing import List 4 | 5 | from ..util import PlacementPreference 6 | from .storyprovider import StoryProvider 7 | from ..story import Story 8 | 9 | 10 | class WikipediaCurrentEventsStoryProvider(StoryProvider): 11 | """ 12 | A story provider that reads from today's current events on Wikipedia. 13 | """ 14 | 15 | def __init__(self): 16 | pass 17 | 18 | def get_stories(self, limit: int = 10, **kwargs) -> List[Story]: 19 | """ 20 | Get a list of current stories from Wikipedia. 21 | """ 22 | feed = feedparser.parse("https://www.to-rss.xyz/wikipedia/current_events/") 23 | # title = feed.entries[0].title 24 | title = "Today's Current Events" 25 | content = bs4.BeautifulSoup(feed.entries[0].summary, "lxml") 26 | for a in content.find_all("a"): 27 | while a.find("li"): 28 | a.find("li").replace_with_children() 29 | while a.find("ul"): 30 | a.find("ul").replace_with_children() 31 | a.replace_with_children() 32 | 33 | while content.find("dl"): 34 | content.find("dl").name = "h3" 35 | return [ 36 | Story( 37 | headline=title, 38 | body_html=str(content), 39 | byline="Wikipedia Current Events", 40 | placement_preference=PlacementPreference.BANNER, 41 | ) 42 | ] 43 | -------------------------------------------------------------------------------- /goosepaper/styles.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | def read_stylesheets(path: pathlib.Path) -> list: 5 | if path.is_file(): 6 | return path.read_text().strip("\n").split("\n") 7 | else: 8 | return [] 9 | 10 | 11 | def read_css(path: pathlib.Path): 12 | return path.read_text() 13 | 14 | 15 | class Style: 16 | def __init__(self, style=""): 17 | if style: 18 | try: 19 | self.read_style(style) 20 | except (FileNotFoundError, StopIteration): 21 | print(f"Oops! {style} style not found or broken. Use default style.") 22 | self.read_default_style() # if style not found 23 | return 24 | 25 | def get_stylesheets(self) -> list: 26 | return getattr(self, "_stylesheets", []) 27 | 28 | def get_css(self, font_size: int = None): 29 | font_size = str(font_size) 30 | if font_size: 31 | self._css += f""" 32 | .stories {{ 33 | font-size: {font_size}pt !important; 34 | }} 35 | article>.byline {{ 36 | font-size: {font_size}pt !important; 37 | }} 38 | """ 39 | return getattr(self, "_css", "") 40 | 41 | def read_style(self, style): 42 | path = pathlib.Path("./styles/") / style 43 | if path.is_dir(): 44 | if not hasattr(self, "_css"): 45 | self._stylesheets = read_stylesheets(path / "stylesheets.txt") 46 | self._css = read_css(next(path.glob("*.css"))) 47 | elif path.with_suffix(".css").is_file(): 48 | self._stylesheets = [] 49 | self._css = read_css(path.with_suffix(".css")) 50 | 51 | def read_default_style(self): # code copied from FifthAvenueStyle 52 | if not hasattr(self, "_css"): 53 | self._stylesheets = [ 54 | "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Source+Serif+Pro:ital,wght@0,400;0,700;1,400&display=swap" 55 | ] 56 | self._css = """ 57 | @page { 58 | margin-top: 0.5in; 59 | margin-right: 0.2in; 60 | margin-left: 0.65in; 61 | margin-bottom: 0.2in; 62 | } 63 | 64 | body { 65 | font-family: "Open Sans"; 66 | } 67 | 68 | .header { 69 | padding: 1em; 70 | height: 10em; 71 | } 72 | 73 | .header div { 74 | float: left; 75 | display: block; 76 | } 77 | 78 | .header .ear { 79 | float: right; 80 | } 81 | 82 | ul, li, ol { 83 | margin-left: 0; padding-left: 0.15em; 84 | } 85 | 86 | .stories { 87 | font-size: 14pt; 88 | } 89 | 90 | .ear article { 91 | border: 1px groove black; 92 | padding: 1em; 93 | margin: 1em; 94 | font-size: 11pt; 95 | } 96 | .ear article h1 { 97 | font-family: "Source Serif Pro"; 98 | font-size: 10pt; 99 | font-weight: normal; 100 | } 101 | 102 | article { 103 | text-align: justify; 104 | line-height: 1.3em; 105 | } 106 | 107 | .longform { 108 | page-break-after: always; 109 | } 110 | 111 | article>h1 { 112 | font-family: "Source Serif Pro"; 113 | font-weight: 400; 114 | font-size: 23pt; 115 | text-indent: 0; 116 | margin-bottom: 0.25em; 117 | line-height: 1.2em; 118 | text-align: left; 119 | } 120 | article>h1.priority-low { 121 | font-family: "Open Sans"; 122 | font-size: 18pt; 123 | font-weight: 400; 124 | text-indent: 0; 125 | border-bottom: 1px solid #dedede; 126 | margin-bottom: 0.15em; 127 | } 128 | 129 | article>.byline { 130 | font-family: "Open Sans"; 131 | font-size: 14pt; 132 | font-weight: 400; 133 | text-indent: 0; 134 | border-bottom: 1px solid #dedede; 135 | margin-top: 1.33em; 136 | margin-bottom: 1.33em; 137 | margin-left: 0; 138 | margin-right: 0; 139 | } 140 | 141 | article>h3 { 142 | font-family: "Open Sans"; 143 | font-weight: 400; 144 | font-size: 18pt; 145 | text-indent: 0; 146 | } 147 | 148 | section>h1, 149 | section>h2, 150 | section>h3, 151 | section>h4, 152 | section>h5 { 153 | border-left: 5px solid #dedede; 154 | padding-left: 1em; 155 | } 156 | 157 | figure { 158 | border: 1px solid black; 159 | text-indent: 0; 160 | width: auto; 161 | } 162 | 163 | .stories article.story img { 164 | width: 100%; 165 | } 166 | 167 | figure>span { 168 | font-size: 0; 169 | } 170 | 171 | .row { 172 | column-count: 2; 173 | }""" 174 | -------------------------------------------------------------------------------- /goosepaper/test_goosepaper.py: -------------------------------------------------------------------------------- 1 | from .goosepaper import Goosepaper 2 | 3 | from .storyprovider.storyprovider import LoremStoryProvider 4 | 5 | 6 | def test_can_create_goosepaper_with_no_providers(): 7 | g = Goosepaper([]) 8 | assert g.story_providers == [] 9 | 10 | 11 | def test_can_create_goosepaper_with_duplicate_provider(): 12 | g = Goosepaper([LoremStoryProvider(limit=3), LoremStoryProvider(limit=4)]) 13 | assert len(g.get_stories()) == 7 14 | 15 | 16 | def test_can_deduplicate_by_headline(): 17 | g = Goosepaper([LoremStoryProvider(limit=3), LoremStoryProvider(limit=4)]) 18 | assert len(g.get_stories(deduplicate=True)) == 1 19 | 20 | 21 | def test_can_create_html(): 22 | g = Goosepaper([LoremStoryProvider()]) 23 | assert "" in g.to_html() 24 | assert "Lorem ipsum" in g.to_html() 25 | -------------------------------------------------------------------------------- /goosepaper/test_utils.py: -------------------------------------------------------------------------------- 1 | from .util import ( 2 | htmlize, 3 | clean_html, 4 | clean_text, 5 | construct_story_providers_from_config_dict, 6 | ) 7 | 8 | 9 | def test_htmlize(): 10 | assert htmlize(["foo", "bar"]) == "

foo

bar

" 11 | 12 | 13 | def test_clean_html(): 14 | assert clean_html("fooâ€TMbar") == "foo'bar" 15 | 16 | 17 | def test_clean_text(): 18 | assert clean_text("fooâ€TMbar") == "foo'bar" 19 | 20 | 21 | def test_construct_story_providers_from_config_dict(): 22 | assert construct_story_providers_from_config_dict({}) == [] 23 | stories = construct_story_providers_from_config_dict( 24 | { 25 | "stories": [ 26 | { 27 | "provider": "mastodon", 28 | "config": { 29 | "server": "https://neuromatch.social", 30 | "username": "j6m8", 31 | "limit": 1, 32 | }, 33 | } 34 | ] 35 | } 36 | ) 37 | assert len(stories) == 1 38 | 39 | stories = construct_story_providers_from_config_dict( 40 | { 41 | "stories": [ 42 | { 43 | "provider": "mastodon", 44 | "config": { 45 | "server": "https://neuromatch.social", 46 | "username": "j6m8", 47 | "limit": 1, 48 | }, 49 | }, 50 | {"provider": "reddit", "config": {"subreddit": "worldnews"}}, 51 | ] 52 | } 53 | ) 54 | assert len(stories) == 2 55 | -------------------------------------------------------------------------------- /goosepaper/upload.py: -------------------------------------------------------------------------------- 1 | from goosepaper.multiparser import MultiParser 2 | from rmapy.document import ZipDocument 3 | from rmapy.api import Folder 4 | 5 | import os 6 | from pathlib import Path 7 | 8 | from .auth import auth_client 9 | 10 | 11 | def sanitycheck(folder: str, client): 12 | 13 | # First lets do a sanity check. Since RM cloud uses an object ID as a unique key 14 | # it is entirely possible to have duplicate VissibleName attributes for both folders and 15 | # documents, and you can have folders and documents that share the same name. 16 | 17 | # This is insanity and we have no programmatic way to resolve it so we're going to cheat. 18 | # Check for duplicate folder names as the root level and if found we simply force the 19 | # user to resolve it and check for duplicate document names. 20 | 21 | rootfolders = [ 22 | f 23 | for f in client.get_meta_items() 24 | if (f.Type == "CollectionType" and f.Parent == "") 25 | ] 26 | 27 | uniquefolders = set() 28 | [ 29 | uniquefolders.add(folder.VissibleName.lower()) or folder 30 | for folder in rootfolders 31 | if folder.VissibleName.lower() not in uniquefolders 32 | ] 33 | foldercountdif = abs(len(uniquefolders) - len(rootfolders)) 34 | 35 | folderduperr = "" 36 | 37 | if foldercountdif == 1: 38 | folderduperr = "I found a duplicate folder name in the root of your RM.\n" 39 | elif foldercountdif > 1: 40 | folderduperr = ( 41 | "You have multiple duplicate folder names in the root of your RM.\n" 42 | ) 43 | else: 44 | pass 45 | 46 | if foldercountdif: 47 | print("{0}\n\nYou must fix this first.".format(folderduperr)) 48 | return False 49 | else: 50 | return True 51 | 52 | 53 | def validateFolder(folder: str): 54 | if folder: 55 | if folder == "": 56 | print("Honk! Folder cannot be an empty string") 57 | return False 58 | elif "/" in folder: 59 | print("Honk! Please do not include '/' in the folder name") 60 | print(" Nested folders are not supported for now") 61 | return False 62 | 63 | return True 64 | 65 | 66 | def getallitems(client): 67 | 68 | # So somehow I corrupted or broke my cloud during testing and any object 69 | # which exists is getting returned twice in the object list from 70 | # get_meta_items. Deleting items and re-adding them hasn't fixed it even 71 | # using the official RM client. So this avoids that problem. I haven't found 72 | # a real fix yet for my cloud though. 73 | 74 | allitems = [item for item in client.get_meta_items() if (item.Parent != "trash")] 75 | 76 | items = [] 77 | for tempitem in allitems: 78 | if not any(item.ID == tempitem.ID for item in items): 79 | items.append(tempitem) 80 | 81 | return items 82 | 83 | 84 | def upload(filepath, multiparser=None): 85 | 86 | if not multiparser: 87 | multiparser = MultiParser() 88 | 89 | filepath = Path(filepath) 90 | replace = ( 91 | False 92 | if multiparser.argumentOrConfig("noreplace") 93 | else multiparser.argumentOrConfig("replace") 94 | ) 95 | folder = multiparser.argumentOrConfig("folder") 96 | cleanup = multiparser.argumentOrConfig("cleanup") 97 | strictlysane = multiparser.argumentOrConfig("strictlysane") 98 | nocase = multiparser.argumentOrConfig("nocase") 99 | 100 | if strictlysane: 101 | nocase = True 102 | 103 | if multiparser.argumentOrConfig("showconfig"): 104 | print("\nParameters passed to upload\n----------------\n") 105 | print( 106 | "Replace:\t{0}\nFolder:\t\t{1}\nCleanup:\t{2}" 107 | "\nStrictlysane:\t{3}\nNocase:\t\t{4}\nFilepath:\t{5}\n".format( 108 | replace, folder, cleanup, strictlysane, nocase, filepath 109 | ) 110 | ) 111 | 112 | client = auth_client() 113 | 114 | if not client: 115 | print("Honk Honk! Couldn't auth! Is your rmapy configured?") 116 | return False 117 | 118 | if not validateFolder(folder): 119 | return False 120 | 121 | # Added error handling to deal with possible race condition where the file 122 | # is mangled or not written out before the upload actually occurs such as 123 | # an AV false positive. 'pdf' is a simple throwaway file handle to make 124 | # sure that we retain control of the file while it's being imported. 125 | 126 | fpr = filepath.resolve() 127 | 128 | try: 129 | doc = ZipDocument(doc=str(fpr)) 130 | except IOError as err: 131 | raise IOError(f"Error locating or opening {filepath} during upload.") from err 132 | 133 | paperCandidates = [] 134 | paperFolder = None 135 | 136 | for item in getallitems(client): 137 | 138 | # is it the folder we are looking for? 139 | if ( 140 | folder 141 | and item.Type == "CollectionType" # is a folder 142 | and item.VissibleName.lower() 143 | == folder.lower() # has the name we're looking for 144 | and (item.Parent is None or item.Parent == "") 145 | ): # is not in another folder 146 | paperFolder = item 147 | 148 | # is it possibly the file we are looking for? 149 | elif item.Type == "DocumentType" and ( 150 | item.VissibleName.lower() == str(doc.metadata["VissibleName"]).lower() 151 | ): 152 | paperCandidates.append(item) 153 | 154 | # TODO: if the folder was found, check if a paper candidate is in it 155 | # for paper in paperCandidates: 156 | # parent = client.get_doc(paper.Parent) 157 | 158 | paper = None 159 | if len(paperCandidates) > 0: 160 | if folder: 161 | filtered = [ 162 | item for item in paperCandidates if item.Parent == paperFolder.ID 163 | ] 164 | else: 165 | filtered = list( 166 | filter( 167 | lambda item: item.Parent != "trash" 168 | and client.get_doc(item.Parent) is None, 169 | paperCandidates, 170 | ) 171 | ) 172 | 173 | if len(filtered) > 1 and replace: 174 | print( 175 | "multiple candidate papers with the same name " 176 | f"{filtered[0].VissibleName}, don't know which to delete" 177 | ) 178 | return False 179 | if len(filtered) == 1: # found the outdated paper 180 | paper = filtered[0] 181 | 182 | if paper is not None: 183 | if replace: 184 | result = client.delete(paper) 185 | else: 186 | print("Honk! The paper already exists!") 187 | return False 188 | 189 | if folder and not paperFolder: 190 | paperFolder = Folder(folder) 191 | if not client.create_folder(paperFolder): 192 | print("Honk! Failed to create the folder!") 193 | return False 194 | 195 | # workarround rmapy bug: client.upload(doc) would set a non-existing parent 196 | # ID to the document 197 | if not paperFolder: 198 | paperFolder = Folder() 199 | paperFolder.ID = "" 200 | if isinstance(paperFolder, Folder): 201 | result = client.upload(doc, paperFolder) 202 | if result: 203 | print("Honk! Upload successful!") 204 | if cleanup: 205 | try: 206 | os.remove(fpr) 207 | except Exception as err: 208 | raise IOError(f"Failed to remove file after upload: {fpr}") from err 209 | else: 210 | print("Honk! Error with upload!") 211 | return result 212 | else: 213 | print("Honk! Could not upload: Document already exists.") 214 | return False 215 | -------------------------------------------------------------------------------- /goosepaper/util.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | import json 4 | from typing import List, Union 5 | 6 | 7 | def htmlize(text: Union[str, List[str]]) -> str: 8 | """ 9 | Generate HTML text from a text string, correctly formatting paragraphs etc. 10 | """ 11 | # TODO: 12 | # * Escaping 13 | # * Paragraph delims 14 | # * Remove illegal elements 15 | if isinstance(text, list): 16 | return "".join([f"

{line}

" for line in text]) 17 | return f"

{text}

" 18 | 19 | 20 | def clean_html(html: str) -> str: 21 | html = html.replace("â€TM", "'") 22 | html = re.sub(r"http[s]?:\/\/[^\s\"']+", "", html) 23 | return html 24 | 25 | 26 | def clean_text(text: str) -> str: 27 | text = text.replace("â€TM", "'") 28 | text = re.sub(r"http[s]?:\/\/[^\s\"']+", "", text) 29 | return text 30 | 31 | 32 | class PlacementPreference(enum.Enum): 33 | NONE = 0 34 | FULLPAGE = 1 35 | SIDEBAR = 2 36 | EAR = 3 37 | FOLIO = 4 38 | BANNER = 5 39 | 40 | 41 | class StoryPriority(enum.Enum): 42 | DEFAULT = 0 43 | LOW = 1 44 | HEADLINE = 5 45 | BANNER = 9 46 | 47 | 48 | def load_config_file(filepath: str) -> dict: 49 | try: 50 | with open(filepath, "r") as fh: 51 | config_dict = json.load(fh) 52 | except ValueError as err: 53 | raise ValueError( 54 | "Honk Honk! Syntax Error in config file {0}".format(filepath) 55 | ) from err 56 | return config_dict 57 | 58 | 59 | def construct_story_providers_from_config_dict(config: dict): 60 | from goosepaper.storyprovider.rss import RSSFeedStoryProvider 61 | from goosepaper.storyprovider.reddit import RedditHeadlineStoryProvider 62 | from goosepaper.storyprovider.mastodon import MastodonStoryProvider 63 | from goosepaper.storyprovider.storyprovider import CustomTextStoryProvider 64 | from goosepaper.storyprovider.weather import OpenMeteoWeatherStoryProvider 65 | from goosepaper.storyprovider.wikipedia import WikipediaCurrentEventsStoryProvider 66 | 67 | StoryProviderConfigNames = { 68 | "lorem": CustomTextStoryProvider, 69 | "text": CustomTextStoryProvider, 70 | "reddit": RedditHeadlineStoryProvider, 71 | "weather": OpenMeteoWeatherStoryProvider, 72 | "mastodon": MastodonStoryProvider, 73 | "openmeteo_weather": OpenMeteoWeatherStoryProvider, 74 | "wikipedia_current_events": WikipediaCurrentEventsStoryProvider, 75 | "rss": RSSFeedStoryProvider, 76 | } 77 | 78 | if "stories" not in config: 79 | return [] 80 | 81 | stories = [] 82 | 83 | for provider_config in config["stories"]: 84 | provider_name = provider_config["provider"] 85 | if provider_name not in StoryProviderConfigNames: 86 | raise ValueError(f"Provider {provider_name} does not exist.") 87 | arguments = provider_config["config"] if "config" in provider_config else {} 88 | if arguments.get("skip"): 89 | continue 90 | else: 91 | stories.append(StoryProviderConfigNames[provider_name](**arguments)) 92 | return stories 93 | -------------------------------------------------------------------------------- /manifest.in: -------------------------------------------------------------------------------- 1 | recursive-include styles/ * -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # for general downloadery and story chomping: 2 | requests 3 | 4 | # for rss feeds and reddit: 5 | feedparser 6 | 7 | # for wikipedia stories: 8 | bs4 9 | lxml[html_clean] 10 | 11 | # for rendering: 12 | WeasyPrint 13 | 14 | # for epub creation 15 | ebooklib 16 | 17 | # for content parsing 18 | readability-lxml 19 | 20 | # for sending to remarkable: 21 | git+https://github.com/j6k4m8/rmapy 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import path 4 | from codecs import open as copen 5 | from setuptools import setup, find_packages 6 | 7 | __version__ = "0.7.1" 8 | 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | 12 | # Get the long description from the README file 13 | with copen(path.join(here, "README.md"), encoding="utf-8") as f: 14 | long_description = f.read() 15 | 16 | # get the dependencies and installs 17 | with copen(path.join(here, "requirements.txt"), encoding="utf-8") as f: 18 | all_reqs = f.read().split("\n") 19 | 20 | install_requires = [x.strip() for x in all_reqs if "git+" not in x] 21 | dependency_links = [ 22 | x.strip().replace("git+", "") for x in all_reqs if x.startswith("git+") 23 | ] 24 | 25 | setup( 26 | name="goosepaper", 27 | version=__version__, 28 | description="Generate and deliver a daily newspaper PDF", 29 | long_description=long_description, 30 | long_description_content_type="text/markdown", 31 | download_url="https://github.com/j6k4m8/goosepaper/tarball/" + __version__, 32 | license="Apache 2.0", 33 | classifiers=[ 34 | "Development Status :: 4 - Beta", 35 | "Intended Audience :: Developers", 36 | "Programming Language :: Python :: 3.7", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | "Programming Language :: Python :: 3.10", 40 | "Programming Language :: Python :: 3.11", 41 | "Programming Language :: Python :: 3.12", 42 | ], 43 | keywords=["remarkable", "tablet", "pdf", "news"], 44 | packages=find_packages(exclude=["docs", "tests*"]), 45 | include_package_data=True, 46 | entry_points={ 47 | "console_scripts": [ 48 | "goosepaper=goosepaper.__main__:main", 49 | "upload_to_remarkable=goosepaper.upload:upload", 50 | ] 51 | }, 52 | author="Jordan Matelsky", 53 | install_requires=install_requires, 54 | dependency_links=dependency_links, 55 | author_email="goosepaper@matelsky.com", 56 | ) 57 | -------------------------------------------------------------------------------- /styles/Academy/stylesheet.css: -------------------------------------------------------------------------------- 1 | @page { 2 | size: 702pt 936pt; 3 | margin-top: 30mm; 4 | margin-bottom: 20mm; 5 | margin-left: 20mm; 6 | margin-right: 10mm; 7 | } 8 | body { 9 | font-family: "Times New Roman"; 10 | } 11 | .header { 12 | padding: 1em; 13 | height: 10em; 14 | } 15 | .header div { 16 | float: left; 17 | display: block; 18 | } 19 | .header .ear { 20 | float: right; 21 | } 22 | .stories { 23 | font-size: 14pt; 24 | } 25 | .ear article { 26 | border: 3px groove black; 27 | padding: 1em; 28 | margin: 1em; 29 | font-size: 11pt; 30 | } 31 | .ear article h1 { 32 | font-family: "Times New Roman"; 33 | font-size: 10pt; 34 | font-weight: normal; 35 | } 36 | article { 37 | text-align: left; 38 | line-height: 1.4em; 39 | } 40 | article>h1 { 41 | font-family: "Times New Roman"; 42 | font-weight: 400; 43 | font-size: 23pt; 44 | text-indent: 0; 45 | margin-bottom: 0.25em; 46 | line-height: 1.2em; 47 | text-align: left; 48 | } 49 | article>h1.priority-low { 50 | font-family: "Times New Roman"; 51 | font-size: 18pt; 52 | font-weight: 400; 53 | text-indent: 0; 54 | margin-bottom: 0.15em; 55 | } 56 | article>.byline { 57 | font-family: "Times New Roman"; 58 | font-size: 14pt; 59 | font-weight: 400; 60 | text-indent: 0; 61 | margin-top: 1.33em; 62 | margin-bottom: 1.33em; 63 | margin-left: 0; 64 | margin-right: 0; 65 | } 66 | article>h3 { 67 | font-family: "Times New Roman"; 68 | font-weight: 400; 69 | font-size: 18pt; 70 | text-indent: 0; 71 | } 72 | section>h1, 73 | section>h2, 74 | section>h3, 75 | section>h4, 76 | section>h5 { 77 | border-left: 5px solid #dedede; 78 | padding-left: 1em; 79 | } 80 | figure { 81 | border: 1px solid black; 82 | text-indent: 0; 83 | width: auto; 84 | } 85 | .stories article.story img { 86 | width: 100%; 87 | } 88 | figure>span { 89 | font-size: 0; 90 | } 91 | -------------------------------------------------------------------------------- /styles/Autumn/stylesheet.css: -------------------------------------------------------------------------------- 1 | @page { 2 | margin-top: 0.5in; 3 | margin-right: 0.2in; 4 | margin-left: 0.65in; 5 | margin-bottom: 0.2in; 6 | } 7 | body { 8 | font-family: "Playfair Display"; 9 | } 10 | .header { 11 | padding: 1em; 12 | height: 10em; 13 | } 14 | .header div { 15 | float: left; 16 | display: block; 17 | } 18 | .header .ear { 19 | float: right; 20 | } 21 | ul, li, ol { 22 | margin-left: 0; padding-left: 0.15em; 23 | } 24 | .stories { 25 | font-size: 14pt; 26 | } 27 | .ear article { 28 | border: 1px groove black; 29 | padding: 1em; 30 | margin: 1em; 31 | font-size: 11pt; 32 | } 33 | .ear article h1 { 34 | font-family: "Playfair Display"; 35 | font-size: 10pt; 36 | font-weight: normal; 37 | } 38 | article { 39 | text-align: justify; 40 | line-height: 1.25em; 41 | } 42 | .longform { 43 | page-break-after: always; 44 | } 45 | article>h1 { 46 | font-family: "Oswald"; 47 | font-weight: 400; 48 | font-size: 23pt; 49 | text-indent: 0; 50 | margin-bottom: 0.25em; 51 | line-height: 1.2em; 52 | text-align: left; 53 | } 54 | article>h1.priority-low { 55 | font-family: "Oswald"; 56 | font-size: 18pt; 57 | font-weight: 400; 58 | text-indent: 0; 59 | border-bottom: 1px solid #dedede; 60 | margin-bottom: 0.15em; 61 | } 62 | article>.byline { 63 | font-family: "Oswald"; 64 | font-size: 14pt; 65 | font-weight: 400; 66 | text-indent: 0; 67 | border-bottom: 1px solid #dedede; 68 | margin-top: 1.33em; 69 | margin-bottom: 1.33em; 70 | margin-left: 0; 71 | margin-right: 0; 72 | } 73 | article>h3 { 74 | font-family: "Oswald"; 75 | font-weight: 400; 76 | font-size: 18pt; 77 | text-indent: 0; 78 | } 79 | section>h1, 80 | section>h2, 81 | section>h3, 82 | section>h4, 83 | section>h5 { 84 | border-left: 5px solid #dedede; 85 | padding-left: 1em; 86 | } 87 | figure { 88 | border: 1px solid black; 89 | text-indent: 0; 90 | width: auto; 91 | } 92 | .stories article.story img { 93 | width: 100%; 94 | } 95 | figure>span { 96 | font-size: 0; 97 | } 98 | .row { 99 | column-count: 2; 100 | } 101 | -------------------------------------------------------------------------------- /styles/Autumn/stylesheets.txt: -------------------------------------------------------------------------------- 1 | https://fonts.googleapis.com/css?family=Oswald 2 | https://fonts.googleapis.com/css?family=Playfair+Display 3 | -------------------------------------------------------------------------------- /styles/FifthAvenue/stylesheet.css: -------------------------------------------------------------------------------- 1 | @page { 2 | size: 702pt 936pt; 3 | margin-top: 30mm; 4 | margin-bottom: 20mm; 5 | margin-left: 20mm; 6 | margin-right: 10mm; 7 | } 8 | body { 9 | font-family: "Open Sans"; 10 | } 11 | .header { 12 | padding: -6em; 13 | height: 6em; 14 | } 15 | .header div { 16 | float: left; 17 | display: block; 18 | } 19 | .header .ear { 20 | float: right; 21 | } 22 | ul, li, ol { 23 | margin-left: 0; padding-left: 0.15em; 24 | } 25 | .stories { 26 | font-size: 14pt; 27 | } 28 | .ear article { 29 | border: 1px groove black; 30 | padding: 1em; 31 | margin: 1em; 32 | font-size: 11pt; 33 | } 34 | .ear article h1 { 35 | font-family: "Source Serif Pro"; 36 | font-size: 10pt; 37 | font-weight: normal; 38 | } 39 | article { 40 | text-align: justify; 41 | line-height: 1.3em; 42 | } 43 | .longform { 44 | page-break-after: always; 45 | } 46 | article>h1 { 47 | font-family: "Source Serif Pro"; 48 | font-weight: 400; 49 | font-size: 23pt; 50 | text-indent: 0; 51 | margin-bottom: 0.25em; 52 | line-height: 1.2em; 53 | text-align: left; 54 | } 55 | article>h1.priority-low { 56 | font-family: "Open Sans"; 57 | font-size: 18pt; 58 | font-weight: 400; 59 | text-indent: 0; 60 | border-bottom: 1px solid #dedede; 61 | margin-bottom: 0.15em; 62 | } 63 | article>.byline { 64 | font-family: "Open Sans"; 65 | font-size: 14pt; 66 | font-weight: 400; 67 | text-indent: 0; 68 | border-bottom: 1px solid #dedede; 69 | margin-top: 1.33em; 70 | margin-bottom: 1.33em; 71 | margin-left: 0; 72 | margin-right: 0; 73 | } 74 | article>h3 { 75 | font-family: "Open Sans"; 76 | font-weight: 400; 77 | font-size: 18pt; 78 | text-indent: 0; 79 | } 80 | section>h1, 81 | section>h2, 82 | section>h3, 83 | section>h4, 84 | section>h5 { 85 | border-left: 5px solid #dedede; 86 | padding-left: 1em; 87 | } 88 | figure { 89 | border: 1px solid black; 90 | text-indent: 0; 91 | width: auto; 92 | } 93 | .stories article.story img { 94 | width: 100%; 95 | } 96 | figure>span { 97 | font-size: 0; 98 | } 99 | .row { 100 | column-count: 2; 101 | column-gap: 2rem; 102 | } 103 | -------------------------------------------------------------------------------- /styles/FifthAvenue/stylesheets.txt: -------------------------------------------------------------------------------- 1 | https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Source+Serif+Pro:ital,wght@0,400;0,700;1,400&display=swap --------------------------------------------------------------------------------