├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yml ├── .gitignore ├── .pylintrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.html ├── README.md ├── docs ├── Screen-Shot-2013-03-18-at-7.06.38-PM.png ├── Screen-Shot-2013-03-18-at-7.07.33-PM.png ├── Screen-Shot-2013-03-18-at-7.14.02-PM.png ├── Screen-Shot-2013-03-18-at-7.14.49-PM.png ├── Screen-Shot-2013-03-18-at-7.33.22-PM.png └── index.html ├── githooks ├── README.md └── pre-commit ├── ir ├── __init__.py ├── _version.py ├── about.py ├── data │ └── colors.u8 ├── gui.py ├── importer │ ├── __init__.py │ ├── base_importer.py │ ├── concrete_importers.py │ ├── epub.py │ ├── exceptions.py │ ├── html_cleaner.py │ ├── importer.py │ ├── local_file.py │ ├── models.py │ ├── pocket.py │ └── web.py ├── lib │ ├── __init__.py │ ├── cgi.py │ └── feedparser.py ├── main.py ├── manifest.json ├── schedule.py ├── settings.py ├── text.py ├── util.py ├── view.py └── web │ ├── model.css │ ├── scroll.js │ ├── table_of_contents.js │ └── text.js ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── screenshots ├── extraction-and-highlighting.png ├── highlighting-tab.png └── quick-keys-tab.png └── tests ├── __init__.py ├── conftest.py ├── test_html_cleaner.py ├── test_scheduler.py └── test_settings.py /.envrc: -------------------------------------------------------------------------------- 1 | # Setting for direnv 2 | export VIRTUAL_ENV=".venv" 3 | layout python 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Specs (please complete the following information):** 24 | - OS: [e.g. Windows, iOS, Ubuntu] 25 | - Anki Version [e.g. v2.0.52, v2.1.5] 26 | - Incremental Reading Version [e.g. v0.5.3-beta] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main # Or your main branch name (e.g., master) 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Install poetry 20 | run: pipx install poetry 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.13' 26 | cache: 'poetry' 27 | 28 | - name: Install Dependencies 29 | run: | 30 | make install-deps 31 | 32 | - name: Run make test 33 | run: | 34 | make test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Backup files 2 | *~ 3 | 4 | # Local Python version 5 | .python-version 6 | 7 | # Python bytecode cache 8 | __pycache__ 9 | 10 | # MyPy type checking cache 11 | .mypy_cache/ 12 | 13 | # Code coverage reports 14 | .coverage 15 | 16 | # JetBrains IDE settings 17 | .idea 18 | 19 | # Visual Studio Code settings 20 | .vscode 21 | 22 | # virtual environment 23 | .venv/* 24 | 25 | # Release artefacts 26 | release 27 | incremental-reading-v*.zip 28 | 29 | # Anki addon metadata 30 | meta.json -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable= 3 | C0103, # invalid-name 4 | C0114, # missing-module-docstring 5 | C0115, # missing-class-docstring 6 | C0116, # missing-function-docstring 7 | R0903, # too-few-public-methods 8 | W0107, # unnecessary-pass 9 | W0212, # protected-access, because we hack Anki quite a bit 10 | E0611, # no-name-in-module because of aqt.qt 11 | C0415, # import-outside-toplevel, because of tests 12 | 13 | 14 | ignore=lib 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - '3.6' 5 | 6 | install: 7 | - pip install nose 8 | 9 | script: nosetests ./tests 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [4.13.0] - 2025-04-23 9 | 10 | ### Added 11 | - Table of contents (#67) 12 | 13 | ### Changed 14 | - Remove nav tags during import (#65) 15 | 16 | 17 | ## [4.12.1] - 2025-01-13 18 | 19 | ### Fixed 20 | - Fix LinkedIn import issue where some images don't have a src attribute (#56). 21 | - Major refactoring of the import process (#43). 22 | - (Try to) fix an issue with epub import on Windows where tempfile returns a relative temp path (#37). 23 | 24 | ## [4.12.0] - 2024-12-30 25 | 26 | ### Fixed 27 | - Don't switch to deck browser after import (#38). 28 | - Properly handle importing a page when title is missing (#34). 29 | - Missing About menu on OSX (#47). 30 | 31 | ### Changed 32 | - Various improvements to development process (#43). 33 | 34 | ### Removed 35 | - Remove `maxWidth` configuration as this should be part of the card's CSS. New 36 | users will have the correct CSS, but existing users might need to manually 37 | update their Card template's CSS to 38 | https://github.com/tvhong/incremental-reading/blob/main/ir/web/model.css 39 | 40 | 41 | ## [4.11.9] - 2023-03-05 42 | 43 | ### Added 44 | - Add ability to import Epub files (lujun9972@). 45 | 46 | ### Fixed 47 | - Fix compatibility with 23.10 (lujun9972@, khonkhortisan@, tvhong@). 48 | 49 | ## [4.11.8] 50 | 51 | ### Fixed 52 | - Use resolved title from Pocket when import from Pocket (contribution by lujun9972@). 53 | 54 | ## [4.11.7] 55 | 56 | ### Fixed 57 | Fix bug for first time user creating IR cards. 58 | 59 | ## [4.11.6] 60 | 61 | ### Fixed 62 | - Fix bug in enabling priority queue. 63 | 64 | ## [4.11.5] 65 | 66 | ### Fixed 67 | - Fix images and named anchors importing. 68 | 69 | ## [4.11.4] 70 | 71 | ### Fixed 72 | - Fix HTTPS imports in MacOS. 73 | 74 | ## [4.11.3] 75 | 76 | ### Removed 77 | - Remove deprecated imports from Anki 2.1.54. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | The project is structured as follow: 3 | * `ir/` contains the source code (Python + javascript) 4 | * `tests/` contains the test code 5 | * `docs/` contains documentation 6 | 7 | # Dev setup 8 | We use `poetry` to manage project dependency. 9 | 10 | First, install poetry following the instructions from https://python-poetry.org/docs/#installation . 11 | Then, install poetry-bumpversion plugin 12 | ``` 13 | poetry self add poetry-bumpversion 14 | ``` 15 | 16 | Then, install dependencies: 17 | ```shell 18 | make install-deps 19 | ``` 20 | 21 | Poetry will automatically create a virtual environment and install the dependencies. 22 | To use the virtual environment, see https://python-poetry.org/docs/basic-usage/#using-your-virtual-environment 23 | 24 | # Manual Test 25 | 26 | ## Add local repo as add-on 27 | To iterate quickly on your changes, you should add the local repo to Anki as an add-on. 28 | The manual testing cycle then becomes: make code changes, restart Anki, and test your change. 29 | 30 | Steps: 31 | 1. Find where Anki stores its add-on: Open Anki > Tools > Add-ons > View Files. 32 | 2. Then create a symlink from Anki's add-on directory to your "ir" directory. 33 | For example: 34 | * My Anki add-on directory is `$HOME/.local/share/Anki2/addons21`. 35 | * My local incremental reading workspace is `$HOME/workplace/incremental-reading`. 36 | * Then to add my local workspace as an Anki add-on, I'd run 37 | ```shell 38 | ln -s $HOME/workplace/incremental-reading/ir $HOME/.local/share/Anki2/addons21/ir 39 | ``` 40 | 3. Restart Anki. 41 | 42 | ## Run Anki 43 | 44 | 1. Create a "Test" profile to test your changes, for your own safety. 45 | 2. Then run Anki from terminal. This will show stdout, which is useful for debugging. 46 | ```shell 47 | # On Ubuntu 48 | /usr/local/bin/anki -p Test 49 | 50 | # On Mac 51 | /Applications/Anki.app/Contents/MacOS/anki -p Test 52 | ``` 53 | 54 | # Unit test 55 | 56 | ```shell 57 | make test 58 | ``` 59 | 60 | # Publishing 61 | 62 | 1. Update the version 63 | ``` 64 | poetry version patch|minor|major 65 | ``` 66 | 2. Build the `release/incremental-reading-v{version}.zip` file: 67 | ```shell 68 | make release 69 | ``` 70 | 3. Test the zip file in Anki 71 | * Disable current IR add-on: Open Anki > Tools > Add-ons > Select current IR add-on > Toggle Enabled. 72 | * Add the zipped add-on: In Add-ons page > Install from file... > Pick the zip file from earlier. 73 | * Restart Anki to test. 74 | * After finish testing, disable the local add-on and re-enable the public add-on. 75 | 4. Upload to https://ankiweb.net/shared/addons/ . 76 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright 2013 Tiago Barroso 4 | Copyright 2013 Frank Kmiec 5 | Copyright 2013-2016 Aleksej 6 | Copyright 2017 Christian Weiß 7 | Copyright 2018 Timothée Chauvin 8 | Copyright 2017-2022 Joseph Lorimer <joseph@lorimer.me> 9 | Copyright 2022-2024 Vy Hong <contact@vyhong.me> 10 | 11 | Permission to use, copy, modify, and/or distribute this software for any 12 | purpose with or without fee is hereby granted, provided that the above 13 | copyright notice and this permission notice appear in all copies. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 16 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 17 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 18 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 19 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 20 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 21 | PERFORMANCE OF THIS SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 2 | # 3 | # Permission to use, copy, modify, and distribute this software for any purpose 4 | # with or without fee is hereby granted, provided that the above copyright 5 | # notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | # PERFORMANCE OF THIS SOFTWARE. 14 | 15 | export PYTHONPATH=. 16 | VERSION=$(shell poetry version -s) 17 | PROJECT_SHORT=ir 18 | PROJECT_LONG=incremental-reading 19 | 20 | RELEASE_DIR=$(CURDIR)/release 21 | RELEASE_FILE=$(RELEASE_DIR)/$(PROJECT_LONG)-v$(VERSION).zip 22 | 23 | .PHONY: install-deps lint format test clean release 24 | 25 | all: install-deps test 26 | 27 | install-deps: 28 | @echo "Installing dependencies..." 29 | poetry install --sync --no-root --with dev 30 | 31 | lint: 32 | @echo "Linting code..." 33 | poetry run pylint "$(PROJECT_SHORT)" tests 34 | 35 | format: 36 | @echo "Formatting code..." 37 | poetry run black "$(PROJECT_SHORT)" tests 38 | poetry run isort "$(PROJECT_SHORT)" tests 39 | 40 | test: 41 | @echo "Running tests..." 42 | poetry run pytest -W ignore::DeprecationWarning tests -v 43 | 44 | check: lint test 45 | 46 | clean: 47 | @echo "Cleaning up..." 48 | rm -rf "$(RELEASE_DIR)" 49 | find . -name '*.pyc' -type f -delete 50 | find . -name '*~' -type f -delete 51 | find . -name .mypy_cache -type d -exec rm -rf {} + 52 | find . -name .ropeproject -type d -exec rm -rf {} + 53 | find . -name __pycache__ -type d -exec rm -rf {} + 54 | 55 | release: test clean 56 | @echo "Creating release file: $(RELEASE_FILE)" 57 | mkdir -p "$(RELEASE_DIR)" 58 | cd "$(PROJECT_SHORT)" && zip -r "$(RELEASE_FILE)" * 59 | zip $(RELEASE_FILE) LICENSE.md -------------------------------------------------------------------------------- /README.html: -------------------------------------------------------------------------------- 1 | <strong><i>2021 Project Status: Active development will resume on Saturday the 27th of February.</i></strong> 2 | 3 | <a href="https://travis-ci.org/luoliyan/incremental-reading"><img src="https://travis-ci.org/luoliyan/incremental-reading.svg?branch=master" alt="Build Status" /></a> 4 | 5 | 6 | <b>Note:</b> Version 4 of the add-on is only available for Anki 2.1+. Some features will be missing from the earlier versions. 7 | 8 | <b><i>Introduction</i></b> 9 | 10 | This is a rewrite of the <a href="https://github.com/aleksejrs/anki-2.0-vsa-and-ire">Incremental Reading add-on</a>, which aims to provide features that support incremental reading in Anki. The idea of working with long-form content within a spaced-repetition program appears to have originated with SuperMemo, which offers an elaborate implementation of the technique (see their <a href="https://www.supermemo.com/help/read.htm">help article</a> for more information). This add-on for Anki is comparatively bare-bones, providing a minimal set of tools for iterating over long texts and creating new flashcards from existing ones. For an overview of these features, see below. 11 | 12 | <ul><li>Version 4: <a href="https://github.com/luoliyan/incremental-reading">GitHub</a>, <a href="https://github.com/luoliyan/incremental-reading/issues">issue tracker</a>, <a href="https://anki.tenderapp.com/discussions/add-ons/9054-incremental-reading-add-on-discussion-support">discussion board</a></li><li>Version 3: <a href="https://github.com/luoliyan/incremental-reading/tree/legacy">GitHub</a>, <a href="https://anki.tenderapp.com/discussions/add-ons/9054-incremental-reading-add-on-discussion-support">discussion board</a></li><li>Version 2: <a href="https://ankiweb.net/shared/info/355348508">AnkiWeb</a>, <a href="https://github.com/aleksejrs/anki-2.0-vsa-and-ire">GitHub</a>, <a href="https://luoliyan.github.io/incremental-reading">manual</a></li></ul> 13 | <b><i>Main Features</i></b> 14 | 15 | <ul><li>Import content from web feeds (RSS/Atom), webpages, or Pocket (<b>v4 only</b>)</li><li>Extract selected text into a new card by pressing <code><b>x</b></code></li><li>Highlight selected text by pressing <code><b>h</b></code></li><li>Remove selected text by pressing <code><b>z</b></code></li><li>Undo changes to the text by pressing <code><b>u</b></code></li><li>Apply rich text formatting while reading</li><li>Create custom shortcuts to quickly add cards</li><li>Maintain scroll position and zoom on a per-card basis</li><li>Rearrange cards in the built-in organiser</li><li>Control the scheduling of incremental reading cards</li><li>Limit the width of cards (useful on large screens) (<b>v4 only</b>)</li></ul> 16 | <b>New to Version 4</b> 17 | 18 | <ul><li>Compatible with Anki 2.1</li><li>Import single webpages (<code><b>Alt</b></code>+<code><b>3</b></code>)</li><li>Import web feeds (<code><b>Alt</b></code>+<code><b>4</b></code>)</li><li>Import Pocket articles (<code><b>Alt</b></code>+<code><b>5</b></code>)</li><li>Apply bold, italics, underline or strikethrough (<code><b>Ctrl</b></code>+<code><b>B</b></code>, <code><b>I</b></code>, <code><b>U</b></code>, or <code><b>S</b></code>)</li><li>Toggle formatting on and off (<code><b>Ctrl</b></code>+<code><b>Shift</b></code>+<code><b>O</b></code>)</li><li>Choose maximum width of cards (see options: <code><b>Alt</b></code>+<code><b>1</b></code>)</li><li>Control initial scheduling of extracts (see options: <code><b>Alt</b></code>+<code><b>1</b></code>)</li></ul> 19 | <b>New to Version 3</b> 20 | 21 | <ul><li>Remove unwanted text with a single key-press (<code><b>z</b></code>)</li><li>Multi-level undo, for reverting text changes (<code><b>u</b></code>)</li><li>New options to control how text is extracted:<ul><li>Open the full note editor for each extraction (slow), or simply a title entry box (fast)</li><li>Extract selected text as HTML (retain color and formatting) or plain text (remove all formatting)</li><li>Choose a destination deck for extracts</li></ul></li><li>New options for several aspects of zoom and scroll functionality:<ul><li><i>Zoom Step</i> (the amount that magnification changes when zooming in or out)</li><li><i>General Zoom</i> (the zoom level for the deck browser and overview screens)</li><li><i>Line Step</i> (the amount the page moves up or down when the Up or Down direction keys are used)</li><li><i>Page Step</i> (same as above, but with the <code><b>Page Up</b></code> and <code><b>Page Down</b></code> keys)</li></ul></li><li>Highlighting:<ul><li>Both the background color and text color used for highlighting can be customized</li><li>A drop-down list of available colors is provided</li><li>A preview is now displayed when selecting highlight colors</li><li>The colors applied to text extracted with <code><b>x</b></code> can now be set independently</li></ul></li><li>Quick Keys<ul><li>A list of all existing Quick Keys is now shown, to allow easy modification</li><li>Unwanted Quick Keys can be easily deleted</li><li>A plain text extraction option has also been added</li></ul></li><li>All options have been consolidated into a single tabbed dialog</li></ul> 22 | <b><i>Screenshots</i></b> 23 | 24 | <b>Note:</b> These are fairly outdated. 25 | 26 | <img src="https://raw.githubusercontent.com/luoliyan/incremental-reading/master/screenshots/extraction-and-highlighting.png" alt="Screenshot #1" /><img src="https://raw.githubusercontent.com/luoliyan/incremental-reading/master/screenshots/highlighting-tab.png" alt="Screenshot #2" /><img src="https://raw.githubusercontent.com/luoliyan/incremental-reading/master/screenshots/quick-keys-tab.png" alt="Screenshot #3" /> 27 | 28 | <b><i>Installation</i></b> 29 | 30 | You will first need to have Anki installed. Download the relevant installer <a href="http://ankisrs.net">here</a>. 31 | 32 | To install through Anki, navigate to Tools → Add-ons → Get Add-ons..., and enter the code <code>935264945</code>. To install manually, download the GitHub repository (<a href="https://github.com/luoliyan/incremental-reading-for-anki/archive/master.zip">here</a>) and place the <code>ir</code> folder into your add-ons folder. 33 | 34 | <b><i>Usage</i></b> 35 | 36 | Experimentation should lead to a pretty quick understanding of how the add-on works. If in doubt, start with the following: 37 | 38 | <ol><li>Create a new IR note with an article you want to study (the easiest way to do this is to import a webpage, by pressing <code><b>Alt</b></code>+<code><b>3</b></code> while on the deck overview screen)</li><li>Set up a shortcut for creating regular Anki cards from IR cards (press <code><b>Alt</b></code>+<code><b>1</b></code>, or go to the menu, then go to the Quick Keys tab)</li><li>Review the IR card that was created, and extract any text you find interesting (by selecting the text and pressing <code><b>x</b></code>)</li><li>Choose <i>Soon</i> or <i>Later</i> when you want to move to the next card (which will be a portion of text you extracted)</li><li>Whenever you want to create a regular Anki note, simply select the desired text and use the shortcut you created earlier</li></ol> 39 | Outdated instructions can be found <a href="https://luoliyan.github.io/incremental-reading">here</a>. They were written for v2, but the basic behaviour of the add-on is still similar. 40 | 41 | <b><i>Support</i></b> 42 | 43 | If any issues are encountered, please post details to the <a href="https://anki.tenderapp.com/discussions/add-ons">Anki add-ons forum</a>. It’s best if you post in the existing thread (<a href="https://anki.tenderapp.com/discussions/add-ons/9054-incremental-reading-add-on-discussion-support">here</a>) so I receive an email notification. Otherwise, <a href="https://github.com/luoliyan/incremental-reading-for-anki/issues">note an issue</a> or make a pull request on GitHub. 44 | 45 | Please include the following information in your post: 46 | 47 | <ul><li>The version of Anki you are using (e.g., v2.1.0-beta5; can be found in Help → About...)</li><li>The version of IR you are using (this can be found in Read → About...)</li><li>The operating system you are using</li><li>Details of the problem</li><li>Steps needed to reproduce the problem</li></ul> 48 | <b><i>License</i></b> 49 | 50 | Multiple people have contributed to this add-on, and it’s somewhat unclear who to credit for which changes and which licenses to apply. 51 | 52 | Tiago Barroso appears to have initiated the project, and he has <a href="https://groups.google.com/d/msg/anki-addons/xibqDVFqQwQ/-qpxKvxurPMJ">stated</a> that he releases all of his add-ons under the ISC license. Frank Kmiec later vastly expanded the add-on, but it’s unclear which license his changes were released under. Presuming he didn’t specify one, the <a href="https://ankiweb.net/account/terms">AnkiWeb terms and conditions</a> suggest they were automatically released under the AGPL v3. Aleksej’s changes to Frank’s version are <a href="https://github.com/aleksejrs/anki-2.0-vsa-and-ire">multi-licensed under the GPL and ISC licenses</a>. 53 | 54 | For the sake of simplicity, my changes are also released under the ISC license. For each author, I have placed a copyright lines where appropriate, with what I believe are correct dates. If I have made a mistake in this respect, please let me know. 55 | 56 | Frank Raiser released an Anki 1 add-on under a similar name, but it doesn’t appear to share any code with the current project and functions quite differently. For more information, see <a href="http://frankraiser.de/drupal/AnkiIR">Anki Incremental Reading</a>. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **_2021 Project Status: Active development will resume on Saturday the 27th of February._** 2 | 3 | # Incremental Reading for Anki 4 | 5 | [](https://travis-ci.org/luoliyan/incremental-reading) 6 | 7 | **Note:** Version 4 of the add-on is only available for Anki 2.1+. Some features will be missing from the earlier versions. 8 | 9 | ## Introduction 10 | 11 | This is a rewrite of the [Incremental Reading add-on](https://github.com/aleksejrs/anki-2.0-vsa-and-ire), which aims to provide features that support incremental reading in Anki. The idea of working with long-form content within a spaced-repetition program appears to have originated with SuperMemo, which offers an elaborate implementation of the technique (see their [help article](https://www.supermemo.com/help/read.htm) for more information). This add-on for Anki is comparatively bare-bones, providing a minimal set of tools for iterating over long texts and creating new flashcards from existing ones. For an overview of these features, see below. 12 | 13 | - Version 4: [GitHub](https://github.com/luoliyan/incremental-reading), [issue tracker](https://github.com/luoliyan/incremental-reading/issues), [discussion board](https://anki.tenderapp.com/discussions/add-ons/9054-incremental-reading-add-on-discussion-support) 14 | - Version 3: [GitHub](https://github.com/luoliyan/incremental-reading/tree/legacy), [discussion board](https://anki.tenderapp.com/discussions/add-ons/9054-incremental-reading-add-on-discussion-support) 15 | - Version 2: [AnkiWeb](https://ankiweb.net/shared/info/355348508), [GitHub](https://github.com/aleksejrs/anki-2.0-vsa-and-ire), [manual](https://luoliyan.github.io/incremental-reading) 16 | 17 | ## Main Features 18 | 19 | - Import content from web feeds (RSS/Atom), webpages, or Pocket (**v4 only**) 20 | - Extract selected text into a new card by pressing <kbd>x</kbd> 21 | - Highlight selected text by pressing <kbd>h</kbd> 22 | - Remove selected text by pressing <kbd>z</kbd> 23 | - Undo changes to the text by pressing <kbd>u</kbd> 24 | - Apply rich text formatting while reading 25 | - Create custom shortcuts to quickly add cards 26 | - Maintain scroll position and zoom on a per-card basis 27 | - Rearrange cards in the built-in organiser 28 | - Control the scheduling of incremental reading cards 29 | - Limit the width of cards (useful on large screens) (**v4 only**) 30 | 31 | ### New to Version 4 32 | 33 | - Compatible with Anki 2.1 34 | - Import single webpages (<kbd>Alt</kbd>+<kbd>3</kbd>) 35 | - Import web feeds (<kbd>Alt</kbd>+<kbd>4</kbd>) 36 | - Import Pocket articles (<kbd>Alt</kbd>+<kbd>5</kbd>) 37 | - Apply bold, italics, underline or strikethrough (<kbd>Ctrl</kbd>+<kbd>B</kbd>, <kbd>I</kbd>, <kbd>U</kbd>, or <kbd>S</kbd>) 38 | - Toggle formatting on and off (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>O</kbd>) 39 | - Choose maximum width of cards (see options: <kbd>Alt</kbd>+<kbd>1</kbd>) 40 | - Control initial scheduling of extracts (see options: <kbd>Alt</kbd>+<kbd>1</kbd>) 41 | 42 | ### New to Version 3 43 | 44 | - Remove unwanted text with a single key-press (<kbd>z</kbd>) 45 | - Multi-level undo, for reverting text changes (<kbd>u</kbd>) 46 | - New options to control how text is extracted: 47 | - Open the full note editor for each extraction (slow), or simply a title entry box (fast) 48 | - Extract selected text as HTML (retain color and formatting) or plain text (remove all formatting) 49 | - Choose a destination deck for extracts 50 | - New options for several aspects of zoom and scroll functionality: 51 | - _Zoom Step_ (the amount that magnification changes when zooming in or out) 52 | - _General Zoom_ (the zoom level for the deck browser and overview screens) 53 | - _Line Step_ (the amount the page moves up or down when the Up or Down direction keys are used) 54 | - _Page Step_ (same as above, but with the <kbd>Page Up</kbd> and <kbd>Page Down</kbd> keys) 55 | - Highlighting: 56 | - Both the background color and text color used for highlighting can be customized 57 | - A drop-down list of available colors is provided 58 | - A preview is now displayed when selecting highlight colors 59 | - The colors applied to text extracted with <kbd>x</kbd> can now be set independently 60 | - Quick Keys 61 | - A list of all existing Quick Keys is now shown, to allow easy modification 62 | - Unwanted Quick Keys can be easily deleted 63 | - A plain text extraction option has also been added 64 | - All options have been consolidated into a single tabbed dialog 65 | 66 | ## Screenshots 67 | 68 | **Note:** These are fairly outdated. 69 | 70 |  71 |  72 |  73 | 74 | ## Installation 75 | 76 | You will first need to have Anki installed. Download the relevant installer [here](http://ankisrs.net). 77 | 78 | To install through Anki, navigate to Tools → Add-ons → Get Add-ons..., and enter the code `935264945`. To install manually, download the GitHub repository ([here](https://github.com/luoliyan/incremental-reading-for-anki/archive/master.zip)) and place the `ir` folder into your add-ons folder. 79 | 80 | ## Usage 81 | 82 | Experimentation should lead to a pretty quick understanding of how the add-on works. If in doubt, start with the following: 83 | 84 | 1. Create a new IR note with an article you want to study (the easiest way to do this is to import a webpage, by pressing <kbd>Alt</kbd>+<kbd>3</kbd> while on the deck overview screen) 85 | 2. Set up a shortcut for creating regular Anki cards from IR cards (press <kbd>Alt</kbd>+<kbd>1</kbd>, or go to the menu, then go to the Quick Keys tab) 86 | 3. Review the IR card that was created, and extract any text you find interesting (by selecting the text and pressing <kbd>x</kbd>) 87 | 4. Choose _Soon_ or _Later_ when you want to move to the next card (which will be a portion of text you extracted) 88 | 5. Whenever you want to create a regular Anki note, simply select the desired text and use the shortcut you created earlier 89 | 90 | Outdated instructions can be found [here](https://luoliyan.github.io/incremental-reading). They were written for v2, but the basic behaviour of the add-on is still similar. 91 | 92 | ## Support 93 | 94 | If any issues are encountered, please post details to the [Anki add-ons forum](https://anki.tenderapp.com/discussions/add-ons). It’s best if you post in the existing thread ([here](https://anki.tenderapp.com/discussions/add-ons/9054-incremental-reading-add-on-discussion-support)) so I receive an email notification. Otherwise, [note an issue](https://github.com/luoliyan/incremental-reading-for-anki/issues) or make a pull request on GitHub. 95 | 96 | Please include the following information in your post: 97 | 98 | - The version of Anki you are using (e.g., v2.1.0-beta5; can be found in Help → About...) 99 | - The version of IR you are using (this can be found in Read → About...) 100 | - The operating system you are using 101 | - Details of the problem 102 | - Steps needed to reproduce the problem 103 | 104 | ## License 105 | 106 | Multiple people have contributed to this add-on, and it’s somewhat unclear who to credit for which changes and which licenses to apply. 107 | 108 | Tiago Barroso appears to have initiated the project, and he has [stated](https://groups.google.com/d/msg/anki-addons/xibqDVFqQwQ/-qpxKvxurPMJ) that he releases all of his add-ons under the ISC license. Frank Kmiec later vastly expanded the add-on, but it’s unclear which license his changes were released under. Presuming he didn’t specify one, the [AnkiWeb terms and conditions](https://ankiweb.net/account/terms) suggest they were automatically released under the AGPL v3. Aleksej’s changes to Frank’s version are [multi-licensed under the GPL and ISC licenses](https://github.com/aleksejrs/anki-2.0-vsa-and-ire). 109 | 110 | For the sake of simplicity, my changes are also released under the ISC license. For each author, I have placed a copyright lines where appropriate, with what I believe are correct dates. If I have made a mistake in this respect, please let me know. 111 | 112 | Frank Raiser released an Anki 1 add-on under a similar name, but it doesn’t appear to share any code with the current project and functions quite differently. For more information, see [Anki Incremental Reading](http://frankraiser.de/drupal/AnkiIR). 113 | -------------------------------------------------------------------------------- /docs/Screen-Shot-2013-03-18-at-7.06.38-PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/docs/Screen-Shot-2013-03-18-at-7.06.38-PM.png -------------------------------------------------------------------------------- /docs/Screen-Shot-2013-03-18-at-7.07.33-PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/docs/Screen-Shot-2013-03-18-at-7.07.33-PM.png -------------------------------------------------------------------------------- /docs/Screen-Shot-2013-03-18-at-7.14.02-PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/docs/Screen-Shot-2013-03-18-at-7.14.02-PM.png -------------------------------------------------------------------------------- /docs/Screen-Shot-2013-03-18-at-7.14.49-PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/docs/Screen-Shot-2013-03-18-at-7.14.49-PM.png -------------------------------------------------------------------------------- /docs/Screen-Shot-2013-03-18-at-7.33.22-PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/docs/Screen-Shot-2013-03-18-at-7.33.22-PM.png -------------------------------------------------------------------------------- /githooks/README.md: -------------------------------------------------------------------------------- 1 | To reuse these githooks, run the following command after cloning the repository: 2 | ``` 3 | git config core.hooksPath githooks 4 | ``` -------------------------------------------------------------------------------- /githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get list of Python files that are staged for commit 4 | files=$(git diff --cached --name-only --diff-filter=d | grep '\.py#39; | grep -v '^ir/lib/') 5 | 6 | if [ -z "$files" ]; then 7 | # No Python files to format 8 | exit 0 9 | fi 10 | 11 | echo "Running code formatting on staged files..." 12 | 13 | # Create a temporary file to store the file list 14 | temp_file=$(mktemp) 15 | echo "$files" > "$temp_file" 16 | 17 | # Format only the staged files 18 | poetry run black --quiet $(cat "$temp_file") 19 | poetry run isort --quiet $(cat "$temp_file") 20 | 21 | # Clean up 22 | rm "$temp_file" 23 | 24 | # Add the formatted files back to staging 25 | if ! git diff --quiet; then 26 | echo "Code formatting created changes. Adding them to the commit..." 27 | git add $files 28 | fi 29 | 30 | exit 0 31 | -------------------------------------------------------------------------------- /ir/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 2 | # 3 | # Permission to use, copy, modify, and distribute this software for any purpose 4 | # with or without fee is hereby granted, provided that the above copyright 5 | # notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | # PERFORMANCE OF THIS SOFTWARE. 14 | 15 | import os 16 | 17 | if not os.environ.get("IR_TESTING"): 18 | from aqt import mw 19 | 20 | from .main import ReadingManager 21 | 22 | mw.readingManager = ReadingManager() 23 | -------------------------------------------------------------------------------- /ir/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "4.13.0" 2 | -------------------------------------------------------------------------------- /ir/about.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 2 | # 3 | # Permission to use, copy, modify, and distribute this software for any purpose 4 | # with or without fee is hereby granted, provided that the above copyright 5 | # notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | # PERFORMANCE OF THIS SOFTWARE. 14 | 15 | from aqt import mw 16 | from aqt.qt import QDialog, QDialogButtonBox, QLabel, QVBoxLayout 17 | 18 | from ._version import __version__ 19 | 20 | IR_GITHUB_URL = "https://github.com/tvhong/incremental-reading" 21 | 22 | 23 | def showAbout(): 24 | dialog = QDialog(mw) 25 | 26 | label = QLabel() 27 | label.setStyleSheet("QLabel { font-size: 14px; }") 28 | contributors = [ 29 | "Joseph Lorimer <joseph@lorimer.me> Timothée Chauvin", 30 | "Christian Weiß", 31 | "Aleksej", 32 | "Frank Kmiec", 33 | "Tiago Barroso", 34 | ] 35 | text = f""" 36 | <div style="font-weight: bold">Incremental Reading v{__version__}</div> 37 | <div>Vy Hong <contact@vyhong.me></div> 38 | <div>Contributors: {", ".join(contributors)}</div> 39 | <div>Website: <a href="{IR_GITHUB_URL}">{IR_GITHUB_URL}</a></div> 40 | """ 41 | label.setText(text) 42 | 43 | buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) 44 | buttonBox.accepted.connect(dialog.accept) 45 | 46 | layout = QVBoxLayout() 47 | layout.addWidget(label) 48 | layout.addWidget(buttonBox) 49 | 50 | dialog.setLayout(layout) 51 | dialog.setWindowTitle("About") 52 | dialog.exec() 53 | -------------------------------------------------------------------------------- /ir/data/colors.u8: -------------------------------------------------------------------------------- 1 | AliceBlue 2 | AntiqueWhite 3 | Aqua 4 | Aquamarine 5 | Azure 6 | Beige 7 | Bisque 8 | Black 9 | BlanchedAlmond 10 | Blue 11 | BlueViolet 12 | Brown 13 | BurlyWood 14 | CadetBlue 15 | Chartreuse 16 | Chocolate 17 | Coral 18 | CornflowerBlue 19 | Cornsilk 20 | Crimson 21 | Cyan 22 | DarkBlue 23 | DarkCyan 24 | DarkGoldenRod 25 | DarkGray 26 | DarkGrey 27 | DarkGreen 28 | DarkKhaki 29 | DarkMagenta 30 | DarkOliveGreen 31 | DarkOrange 32 | DarkOrchid 33 | DarkRed 34 | DarkSalmon 35 | DarkSeaGreen 36 | DarkSlateBlue 37 | DarkSlateGray 38 | DarkSlateGrey 39 | DarkTurquoise 40 | DarkViolet 41 | DeepPink 42 | DeepSkyBlue 43 | DimGray 44 | DimGrey 45 | DodgerBlue 46 | FireBrick 47 | FloralWhite 48 | ForestGreen 49 | Fuchsia 50 | Gainsboro 51 | GhostWhite 52 | Gold 53 | GoldenRod 54 | Gray 55 | Grey 56 | Green 57 | GreenYellow 58 | HoneyDew 59 | HotPink 60 | IndianRed 61 | Indigo 62 | Ivory 63 | Khaki 64 | Lavender 65 | LavenderBlush 66 | LawnGreen 67 | LemonChiffon 68 | LightBlue 69 | LightCoral 70 | LightCyan 71 | LightGoldenRodYellow 72 | LightGray 73 | LightGrey 74 | LightGreen 75 | LightPink 76 | LightSalmon 77 | LightSeaGreen 78 | LightSkyBlue 79 | LightSlateGray 80 | LightSlateGrey 81 | LightSteelBlue 82 | LightYellow 83 | Lime 84 | LimeGreen 85 | Linen 86 | Magenta 87 | Maroon 88 | MediumAquaMarine 89 | MediumBlue 90 | MediumOrchid 91 | MediumPurple 92 | MediumSeaGreen 93 | MediumSlateBlue 94 | MediumSpringGreen 95 | MediumTurquoise 96 | MediumVioletRed 97 | MidnightBlue 98 | MintCream 99 | MistyRose 100 | Moccasin 101 | NavajoWhite 102 | Navy 103 | OldLace 104 | Olive 105 | OliveDrab 106 | Orange 107 | OrangeRed 108 | Orchid 109 | PaleGoldenRod 110 | PaleGreen 111 | PaleTurquoise 112 | PaleVioletRed 113 | PapayaWhip 114 | PeachPuff 115 | Peru 116 | Pink 117 | Plum 118 | PowderBlue 119 | Purple 120 | RebeccaPurple 121 | Red 122 | RosyBrown 123 | RoyalBlue 124 | SaddleBrown 125 | Salmon 126 | SandyBrown 127 | SeaGreen 128 | SeaShell 129 | Sienna 130 | Silver 131 | SkyBlue 132 | SlateBlue 133 | SlateGray 134 | SlateGrey 135 | Snow 136 | SpringGreen 137 | SteelBlue 138 | Tan 139 | Teal 140 | Thistle 141 | Tomato 142 | Turquoise 143 | Violet 144 | Wheat 145 | White 146 | WhiteSmoke 147 | Yellow 148 | YellowGreen -------------------------------------------------------------------------------- /ir/importer/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Timothée Chauvin 2 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 3 | # 4 | # Permission to use, copy, modify, and distribute this software for any purpose 5 | # with or without fee is hereby granted, provided that the above copyright 6 | # notice and this permission notice appear in all copies. 7 | # 8 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | # PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /ir/importer/base_importer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import date 3 | from typing import List, Optional 4 | 5 | from anki.notes import Note 6 | from aqt import mw 7 | from aqt.utils import chooseList, showCritical, showWarning, tooltip 8 | 9 | from ..settings import SettingsManager 10 | from ..util import Article, setField 11 | from .exceptions import ErrorLevel, ImporterError 12 | from .models import NoteModel 13 | 14 | 15 | class BaseImporter(ABC): 16 | def __init__(self, settings: SettingsManager): 17 | self.settings = settings 18 | 19 | def importContent(self) -> None: 20 | """Template method that defines the import algorithm""" 21 | try: 22 | articles = self._getArticles() 23 | selected = self._selectArticles(articles) 24 | if not selected: 25 | return 26 | 27 | priority = self._getPriority() if self.settings["prioEnabled"] else None 28 | mw.progress.start( 29 | label=self._getProgressLabel(), max=len(selected), immediate=True 30 | ) 31 | 32 | deckName = None 33 | for i, article in enumerate(selected, start=1): 34 | noteModel = self._processArticle(article, priority) 35 | deckName = self._createNote(noteModel) 36 | self._postProcessArticle(article) 37 | mw.progress.update(value=i) 38 | 39 | mw.progress.finish() 40 | 41 | tooltip(f"Added {len(selected)} item(s) to deck: {deckName}") 42 | 43 | return 44 | 45 | except ImporterError as e: 46 | self._handleError(e) 47 | return 48 | 49 | @abstractmethod 50 | def _getArticles(self) -> List[Article]: 51 | """Get the articles to be imported""" 52 | pass 53 | 54 | @abstractmethod 55 | def _selectArticles(self, articles: List[Article]) -> List[Article]: 56 | """Select which articles to import. Can be overridden by subclasses.""" 57 | pass 58 | 59 | @abstractmethod 60 | def _processArticle(self, article: Article, priority: Optional[str]) -> NoteModel: 61 | """Process a single article""" 62 | pass 63 | 64 | def _postProcessArticle(self, article: Article) -> None: 65 | """Post-process a single article""" 66 | pass 67 | 68 | @abstractmethod 69 | def _getProgressLabel(self) -> str: 70 | """Get the progress label for the import operation""" 71 | pass 72 | 73 | def _getPriority(self) -> str: 74 | prompt = "Select priority for import" 75 | return self.settings["priorities"][ 76 | chooseList(prompt, self.settings["priorities"]) 77 | ] 78 | 79 | def _createNote(self, noteModel: NoteModel) -> str: 80 | """Create a note from a NoteModel""" 81 | if self.settings["importDeck"]: 82 | deck = mw.col.decks.by_name(self.settings["importDeck"]) 83 | if not deck: 84 | showWarning( 85 | f'Destination deck "{deck}" no longer exists. Please update your settings.' 86 | ) 87 | return "" 88 | deckId = deck["id"] 89 | else: 90 | deckId = mw.col.conf["curDeck"] 91 | 92 | model = mw.col.models.by_name(self.settings["modelName"]) 93 | note = Note(mw.col, model) 94 | setField(note, self.settings["titleField"], noteModel.title) 95 | setField(note, self.settings["textField"], noteModel.content) 96 | 97 | source = self.settings["sourceFormat"].format( 98 | date=date.today(), url=f'<a href="{noteModel.url}">{noteModel.url}</a>' 99 | ) 100 | setField(note, self.settings["sourceField"], source) 101 | if noteModel.priority: 102 | setField(note, self.settings["prioField"], noteModel.priority) 103 | 104 | note.note_type()["did"] = deckId 105 | mw.col.addNote(note) 106 | 107 | return mw.col.decks.get(deckId)["name"] 108 | 109 | def _handleError(self, error: ImporterError) -> None: 110 | """Handle import errors""" 111 | if error.errorLevel == ErrorLevel.CRITICAL: 112 | showCritical(error.message) 113 | elif error.errorLevel == ErrorLevel.WARNING: 114 | showWarning(error.message) 115 | -------------------------------------------------------------------------------- /ir/importer/concrete_importers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from urllib.parse import urlsplit 3 | 4 | from aqt.utils import getFile, getText 5 | 6 | from ..lib.feedparser import parse 7 | from ..settings import SettingsManager 8 | from ..util import Article, selectArticles 9 | from .base_importer import BaseImporter 10 | from .epub import getEpubToc 11 | from .exceptions import ErrorLevel, ImporterError 12 | from .local_file import LocalFile 13 | from .models import NoteModel 14 | from .pocket import Pocket 15 | from .web import Web 16 | 17 | 18 | class WebpageImporter(BaseImporter): 19 | def __init__(self, settings: SettingsManager, web: Web) -> None: 20 | super().__init__(settings) 21 | self.web = web 22 | 23 | def _getArticles(self) -> List[Article]: 24 | url, accepted = getText("Enter URL:", title="Import Webpage") 25 | if not url or not accepted: 26 | return [] 27 | 28 | if not urlsplit(url).scheme: 29 | url = "http://" + url 30 | 31 | return [Article(title=url, data=url)] 32 | 33 | def _selectArticles(self, articles: List[Article]) -> List[Article]: 34 | return articles 35 | 36 | def _processArticle(self, article: Article, priority: Optional[str]) -> NoteModel: 37 | webpage = self.web.download(article.data) 38 | return NoteModel(webpage.title, webpage.body, webpage.url, priority) 39 | 40 | def _getProgressLabel(self) -> str: 41 | return "Importing webpage..." 42 | 43 | 44 | class FeedImporter(BaseImporter): 45 | def __init__(self, settings: SettingsManager, web: Web) -> None: 46 | super().__init__(settings) 47 | self.web = web 48 | self.log = settings["feedLog"] 49 | 50 | def _getArticles(self) -> List[Article]: 51 | feedUrl, accepted = getText("Enter RSS URL:", title="Import Feed") 52 | if not feedUrl or not accepted: 53 | return [] 54 | 55 | if not urlsplit(feedUrl).scheme: 56 | feedUrl = "http://" + feedUrl 57 | 58 | try: 59 | feed = parse( 60 | feedUrl, 61 | agent=self.settings["userAgent"], 62 | etag=self.log[feedUrl]["etag"], 63 | modified=self.log[feedUrl]["modified"], 64 | ) 65 | except KeyError: 66 | self.log[feedUrl] = {"downloaded": []} 67 | feed = parse(feedUrl, agent=self.settings["userAgent"]) 68 | 69 | if feed["status"] not in [200, 301, 302]: 70 | raise ImporterError( 71 | ErrorLevel.WARNING, 72 | f"The remote server returned an unexpected status: {feed['status']}", 73 | ) 74 | 75 | articles = [ 76 | Article( 77 | title=e["title"], 78 | data={"feedUrl": feedUrl, "feed": feed, "url": e["link"]}, 79 | ) 80 | for e in feed["entries"] 81 | if e["link"] not in self.log[feedUrl]["downloaded"] 82 | ] 83 | 84 | if not articles: 85 | raise ImporterError( 86 | ErrorLevel.WARNING, "There are no new entries in the feed." 87 | ) 88 | 89 | return articles 90 | 91 | def _selectArticles(self, articles: List[Article]) -> List[Article]: 92 | return selectArticles(articles) 93 | 94 | def _processArticle(self, article: Article, priority: Optional[str]) -> NoteModel: 95 | url = article.data["url"] 96 | webpage = self.web.download(url) 97 | return NoteModel(webpage.title, webpage.body, webpage.url, priority) 98 | 99 | def _postProcessArticle(self, article: Article) -> None: 100 | feedUrl = article.data["feedUrl"] 101 | 102 | url = article.data["url"] 103 | self.log[feedUrl]["downloaded"].append(url) 104 | 105 | feed = article.data["feed"] 106 | self.log[feedUrl]["etag"] = feed.etag if hasattr(feed, "etag") else "" 107 | self.log[feedUrl]["modified"] = ( 108 | feed.modified if hasattr(feed, "modified") else "" 109 | ) 110 | 111 | def _getProgressLabel(self) -> str: 112 | return "Importing feed..." 113 | 114 | 115 | class EpubImporter(BaseImporter): 116 | def __init__(self, settings: SettingsManager, localFile: LocalFile) -> None: 117 | super().__init__(settings) 118 | self.localFile = localFile 119 | 120 | def _getArticles(self) -> List[Article]: 121 | epubFilePath = getFile(None, "Enter epub file path", None, filter="*.epub") 122 | 123 | if not epubFilePath: 124 | return [] 125 | 126 | articles = getEpubToc(epubFilePath) 127 | if not articles: 128 | raise ImporterError( 129 | ErrorLevel.WARNING, f"No articles found in {epubFilePath}." 130 | ) 131 | 132 | return articles 133 | 134 | def _selectArticles(self, articles: List[Article]) -> List[Article]: 135 | return selectArticles(articles) 136 | 137 | def _processArticle(self, article: Article, priority: Optional[str]) -> NoteModel: 138 | url = article.data["url"] 139 | parsedFile = self.localFile.process(url) 140 | return NoteModel(article.title, parsedFile.body, url, priority) 141 | 142 | def _getProgressLabel(self) -> str: 143 | return "Importing epub..." 144 | 145 | 146 | class PocketImporter(BaseImporter): 147 | def __init__(self, settings: SettingsManager, pocket: Pocket, web: Web) -> None: 148 | super().__init__(settings) 149 | self.pocket = pocket 150 | self.web = web 151 | 152 | def _getArticles(self) -> List[Article]: 153 | articles = self.pocket.getArticles() 154 | if not articles: 155 | raise ImporterError( 156 | ErrorLevel.WARNING, "There are no new articles in Pocket." 157 | ) 158 | 159 | return articles 160 | 161 | def _selectArticles(self, articles: List[Article]) -> List[Article]: 162 | return selectArticles(articles) 163 | 164 | def _processArticle(self, article: Article, priority: Optional[str]) -> NoteModel: 165 | url = article.data["given_url"] 166 | webpage = self.web.download(url) 167 | return NoteModel( 168 | article.data.get("resolved_title") or webpage.title, 169 | webpage.body, 170 | webpage.url, 171 | priority, 172 | ) 173 | 174 | def _postProcessArticle(self, article: Article) -> None: 175 | if self.settings["pocketArchive"]: 176 | self.pocket.archive(article) 177 | 178 | def _getProgressLabel(self) -> str: 179 | return "Importing Pocket articles..." 180 | -------------------------------------------------------------------------------- /ir/importer/epub.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 DarkSun <lujun9972@gmail.com> 2 | # 3 | # Permission to use, copy, modify, and distribute this software for any purpose 4 | # with or without fee is hereby granted, provided that the above copyright 5 | # notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | # PERFORMANCE OF THIS SOFTWARE. 14 | 15 | import os 16 | import tempfile 17 | import xml.etree.ElementTree as ET 18 | import zipfile 19 | from pathlib import Path 20 | from typing import List 21 | from urllib.parse import urlsplit, urlunsplit 22 | 23 | from ..util import Article 24 | 25 | 26 | def nov_container_content_filename(filename: str) -> str: 27 | """Return the content filename for CONTENT.""" 28 | query = "{*}rootfiles/{*}rootfile[@media-type='application/oebps-package+xml']" 29 | doc = ET.parse(filename) 30 | root = doc.getroot() 31 | node = root.find(query) 32 | if node is not None: 33 | return node.get("full-path") 34 | return "OEBPS/Content.opf" 35 | 36 | 37 | def nov_content_version(root) -> float: 38 | """Return the EPUB version for ROOT.""" 39 | version = root.get("version") if root is not None else None 40 | if not version: 41 | raise ValueError("Version not specified") 42 | return float(version) 43 | 44 | 45 | def nov_content_manifest(directory, root): 46 | """Extract an alist of manifest files for CONTENT in DIRECTORY. 47 | Each alist item consists of the identifier and full path.""" 48 | query = "{*}manifest/{*}item" 49 | nodes = root.findall(query) 50 | return {node.get("id"): os.path.join(directory, node.get("href")) for node in nodes} 51 | 52 | 53 | def nov_content_epub2_toc_file(root, manifest): 54 | """Return toc file for EPUB 2.""" 55 | node = root.find("{*}spine[@toc]") 56 | if node is None: 57 | raise ValueError("EPUB 2 NCX ID not found") 58 | 59 | toc_id = node.get("toc") 60 | if toc_id is None: 61 | raise ValueError("EPUB 2 NCX ID not found") 62 | 63 | toc_file = manifest.get(toc_id) 64 | 65 | if toc_file is None: 66 | raise ValueError("EPUB 2 NCX file not found") 67 | 68 | return toc_file 69 | 70 | 71 | def nov_content_epub3_toc_file(root, manifest): 72 | """Return toc file for EPUB 3.""" 73 | node = root.find("{*}manifest/{*}item[@properties='nav']") 74 | if node is None: 75 | raise ValueError("EPUB 3 <nav> ID not found") 76 | 77 | toc_id = node.get("id") 78 | if toc_id is None: 79 | raise ValueError("EPUB 3 <nav> ID not found") 80 | 81 | toc_file = manifest.get(toc_id) 82 | 83 | if toc_file is None: 84 | raise ValueError("EPUB 3 <nav> file not found") 85 | 86 | return toc_file 87 | 88 | 89 | def nov_content_epub2_files(root, manifest, files): 90 | """Return updated files list for EPUB 2.""" 91 | node = root.find("{*}spine[@toc]") 92 | if node is None: 93 | raise ValueError("EPUB 2 NCX ID not found") 94 | 95 | toc_id = node.get("toc") 96 | if toc_id is None: 97 | raise ValueError("EPUB 2 NCX ID not found") 98 | 99 | toc_file = manifest.get(toc_id) 100 | 101 | if toc_file is None: 102 | raise ValueError("EPUB 2 NCX file not found") 103 | 104 | files[toc_id] = toc_file 105 | return files 106 | 107 | 108 | def nov_content_epub3_files(root, manifest, files): 109 | """Return updated files list for EPUB 3.""" 110 | node = root.find("{*}manifest/{*}item[@properties~=nav]") 111 | if node is None: 112 | raise ValueError("EPUB 3 <nav> ID not found") 113 | 114 | toc_id = node.get("id") 115 | if toc_id is None: 116 | raise ValueError("EPUB 3 <nav> ID not found") 117 | 118 | toc_file = manifest.get(toc_id) 119 | 120 | if toc_file is None: 121 | raise ValueError("EPUB 3 <nav> file not found") 122 | 123 | files[toc_id] = toc_file 124 | return files 125 | 126 | 127 | def nov_content_toc_file(content_dir, root): 128 | "Return toc file from content ROOT" 129 | manifest = nov_content_manifest(content_dir, root) 130 | version = nov_content_version(root) 131 | if version < 3.0: 132 | toc_filename = nov_content_epub2_toc_file(root, manifest) 133 | else: 134 | toc_filename = nov_content_epub3_toc_file(root, manifest) 135 | return version, toc_filename 136 | 137 | 138 | def nov_toc_epub2_files(content_dir, root) -> List[Article]: 139 | query = "{*}navMap//{*}navPoint" 140 | nav_points = root.findall(query) 141 | files = [] 142 | for point in nav_points: 143 | text_node = point.find("{*}navLabel/{*}text") 144 | content_node = point.find("{*}content") 145 | title = text_node.text 146 | href = os.path.join(content_dir, content_node.get("src")) 147 | scheme, netloc, path, *_ = urlsplit(href) 148 | path = urlunsplit((scheme, netloc, path, "", "")) 149 | data = {"url": path} 150 | files.append(Article(title, data)) 151 | return files 152 | 153 | 154 | def nov_toc_epub3_files(toc_file: str, root) -> List[Article]: 155 | toc_dir = os.path.dirname(toc_file) 156 | query = ".//{*}nav//{*}ol/{*}li" 157 | nav_points = root.findall(query) 158 | files = [] 159 | for point in nav_points: 160 | node = point.find("{*}a") 161 | title = node.text 162 | href = node.get("href") 163 | if href.startswith("#"): 164 | path = toc_file 165 | else: 166 | path = os.path.join(toc_dir, href) 167 | _scheme, _netloc, path, *_ = urlsplit(path) 168 | data = {"url": path} 169 | files.append(Article(title, data)) 170 | return files 171 | 172 | 173 | def _get_extract_dir(filename: str) -> str: 174 | "get extract directory by epub filename." 175 | 176 | tempdir = tempfile.gettempdir() 177 | 178 | # Use absolute path as Windows sometimes returns a relative path without the drive letter 179 | tempdir = Path(tempdir).absolute().as_posix() 180 | basename = os.path.basename(filename) 181 | nonextension = os.path.splitext(basename)[0] 182 | return os.path.join(tempdir, nonextension) 183 | 184 | 185 | def _unzip_epub(file_path: str) -> str: 186 | extract_dir = _get_extract_dir(file_path) 187 | with zipfile.ZipFile(file_path, "r") as zip_ref: 188 | zip_ref.extractall(extract_dir) 189 | return extract_dir 190 | 191 | 192 | def getEpubToc(epub_file_path: str) -> List[Article]: 193 | extract_dir = _unzip_epub(epub_file_path) 194 | container_filename = os.path.join(extract_dir, "META-INF", "container.xml") 195 | content_filename = nov_container_content_filename(container_filename) 196 | content_filename = os.path.join(extract_dir, content_filename) 197 | content_dir = os.path.dirname(content_filename) 198 | content_doc = ET.parse(content_filename) 199 | content_root = content_doc.getroot() 200 | version, toc_filename = nov_content_toc_file(content_dir, content_root) 201 | toc_file = os.path.join(content_dir, toc_filename) 202 | toc_doc = ET.parse(toc_file) 203 | toc_root = toc_doc.getroot() 204 | if version < 3.0: 205 | return nov_toc_epub2_files(content_dir, toc_root) 206 | 207 | return nov_toc_epub3_files(toc_file, toc_root) 208 | -------------------------------------------------------------------------------- /ir/importer/exceptions.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ErrorLevel(Enum): 5 | WARNING = 1 6 | CRITICAL = 2 7 | 8 | 9 | class ImporterError(Exception): 10 | def __init__(self, level: ErrorLevel, message: str) -> None: 11 | super().__init__(message) 12 | 13 | self.errorLevel = level 14 | self.message = message 15 | -------------------------------------------------------------------------------- /ir/importer/html_cleaner.py: -------------------------------------------------------------------------------- 1 | from typing import Set, Union 2 | from urllib.parse import urljoin, urlsplit 3 | from urllib.request import url2pathname 4 | 5 | from aqt import mw 6 | from bs4 import BeautifulSoup, Comment, Tag 7 | 8 | 9 | class HtmlCleaner: 10 | _IGNORED_TAGS: Set[str] = {"iframe", "script", "nav"} 11 | 12 | def clean( 13 | self, html: Union[bytes, str], url: str, local: bool = False 14 | ) -> BeautifulSoup: 15 | webpage = BeautifulSoup(html, "html.parser") 16 | 17 | for tagName in self._IGNORED_TAGS: 18 | for tag in webpage.find_all(tagName): 19 | tag.decompose() 20 | 21 | for c in webpage.find_all(text=lambda s: isinstance(s, Comment)): 22 | c.extract() 23 | 24 | for a in webpage.find_all("a"): 25 | self._processATag(url, a) 26 | 27 | for img in webpage.find_all("img"): 28 | self._processImgTag(url, img, local) 29 | 30 | for link in webpage.find_all("link"): 31 | self._processLinkTag(url, link, local) 32 | 33 | return webpage 34 | 35 | def _processATag(self, url: str, a: Tag) -> None: 36 | if a.get("href"): 37 | if a["href"].startswith("#"): 38 | # Need to override onclick for named anchor to work 39 | # See https://forums.ankiweb.net/t/links-to-named-anchors-malfunction/5157 40 | if not a.get("onclick"): 41 | named_anchor = a["href"][1:] # Remove first hash 42 | a["href"] = "javascript:;" 43 | a["onclick"] = f"document.location.hash='{named_anchor}';" 44 | else: 45 | a["href"] = urljoin(url, a["href"]) 46 | 47 | def _processImgTag(self, url: str, img: Tag, local: bool = False) -> None: 48 | """ 49 | Copy image from local storage to Anki media folder and replace src with local path 50 | """ 51 | if not img.get("src"): 52 | return 53 | 54 | img["src"] = urljoin(url, img["src"]) 55 | if local and urlsplit(img["src"]).scheme == "file": 56 | filepath = url2pathname(urlsplit(img["src"]).path) 57 | # TODO: remove mw reference 58 | mediafilepath = mw.col.media.add_file(filepath) 59 | img["src"] = mediafilepath 60 | 61 | # Some webpages send broken base64-encoded URI in srcset attribute. 62 | # Remove them for now. 63 | del img["srcset"] 64 | 65 | def _processLinkTag(self, url: str, link: Tag, local: bool = False) -> None: 66 | if link.get("href"): 67 | link["href"] = urljoin(url, link.get("href", "")) 68 | if local and urlsplit(link["href"]).scheme == "file": 69 | filepath = url2pathname(urlsplit(link["href"]).path) 70 | mediafilepath = mw.col.media.add_file(filepath) 71 | print(filepath, "===>", mediafilepath) 72 | link["href"] = mediafilepath 73 | -------------------------------------------------------------------------------- /ir/importer/importer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Timothée Chauvin 2 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 3 | # 4 | # Permission to use, copy, modify, and distribute this software for any purpose 5 | # with or without fee is hereby granted, provided that the above copyright 6 | # notice and this permission notice appear in all copies. 7 | # 8 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | # PERFORMANCE OF THIS SOFTWARE. 15 | 16 | from typing import Optional 17 | 18 | from .local_file import LocalFile 19 | from .web import Web 20 | 21 | try: 22 | from PyQt6.QtCore import Qt 23 | except ModuleNotFoundError: 24 | from PyQt5.QtCore import Qt 25 | 26 | from ..settings import SettingsManager 27 | from .concrete_importers import ( 28 | EpubImporter, 29 | FeedImporter, 30 | PocketImporter, 31 | WebpageImporter, 32 | ) 33 | from .pocket import Pocket 34 | 35 | 36 | class Importer: 37 | _pocket: Optional[Pocket] = None 38 | _web: Optional[Web] = None 39 | _localFile: Optional[LocalFile] = None 40 | _settings: Optional[SettingsManager] = None 41 | 42 | _webImporter: Optional[WebpageImporter] = None 43 | _feedImporter: Optional[FeedImporter] = None 44 | _epubImporter: Optional[EpubImporter] = None 45 | _pocketImporter: Optional[PocketImporter] = None 46 | 47 | def changeProfile(self, settings: SettingsManager): 48 | self._settings = settings 49 | 50 | self._web = Web(self._settings) 51 | self._localFile = LocalFile() 52 | self._pocket = Pocket() 53 | 54 | self._webImporter = WebpageImporter(self._settings, self._web) 55 | self._feedImporter = FeedImporter(self._settings, self._web) 56 | self._epubImporter = EpubImporter(self._settings, self._localFile) 57 | self._pocketImporter = PocketImporter(self._settings, self._pocket, self._web) 58 | 59 | def importWebpage(self): 60 | self._webImporter.importContent() 61 | 62 | def importFeed(self): 63 | self._feedImporter.importContent() 64 | 65 | def importPocket(self): 66 | self._pocketImporter.importContent() 67 | 68 | def importEpub(self): 69 | self._epubImporter.importContent() 70 | -------------------------------------------------------------------------------- /ir/importer/local_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlunsplit 3 | 4 | from attr import dataclass 5 | from bs4 import BeautifulSoup 6 | 7 | from .exceptions import ErrorLevel, ImporterError 8 | from .html_cleaner import HtmlCleaner 9 | 10 | 11 | @dataclass 12 | class ParsedFile: 13 | body: str 14 | 15 | 16 | class LocalFile: 17 | def __init__(self) -> None: 18 | self._htmlCleaner = HtmlCleaner() 19 | 20 | def process(self, filepath: str) -> ParsedFile: 21 | if not filepath: 22 | raise ValueError("Filepath is empty") 23 | 24 | filepath = Path(filepath).as_posix() # Convert Windows Path to Linux 25 | 26 | html = self._fetchLocalPage(filepath) 27 | 28 | url = urlunsplit(("file", "", filepath, None, None)) 29 | page = self._htmlCleaner.clean(html, url, True) 30 | 31 | return self._constructResponse(page) 32 | 33 | def _fetchLocalPage(self, filepath: str) -> str: 34 | try: 35 | with open(filepath, "r", encoding="utf-8") as f: 36 | return f.read() 37 | except FileNotFoundError as error: 38 | raise ImporterError( 39 | ErrorLevel.CRITICAL, f"File [{filepath}] Not exists." 40 | ) from error 41 | 42 | def _constructResponse(self, localPage: BeautifulSoup) -> ParsedFile: 43 | body = "\n".join(map(str, localPage.find("body").children)) 44 | return ParsedFile(body) 45 | -------------------------------------------------------------------------------- /ir/importer/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | 5 | @dataclass 6 | class NoteModel: 7 | title: str 8 | content: str 9 | url: str 10 | priority: Optional[str] 11 | -------------------------------------------------------------------------------- /ir/importer/pocket.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 2 | # 3 | # Permission to use, copy, modify, and distribute this software for any purpose 4 | # with or without fee is hereby granted, provided that the above copyright 5 | # notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | # PERFORMANCE OF THIS SOFTWARE. 14 | 15 | from json.decoder import JSONDecodeError 16 | from typing import Dict, List, Optional 17 | from urllib.parse import urlencode 18 | 19 | from anki.utils import is_mac, is_win 20 | from aqt.utils import askUser, openLink, showCritical, showInfo 21 | from requests import post 22 | 23 | from ..util import Article 24 | 25 | 26 | class Pocket: 27 | _accessToken: Optional[str] = None 28 | _redirectURI: str = "https://github.com/luoliyan/incremental-reading" 29 | _headers: Dict[str, str] = {"X-Accept": "application/json"} 30 | 31 | if is_win: 32 | consumerKey: str = "71462-da4f02100e7e381cbc4a86df" 33 | elif is_mac: 34 | consumerKey: str = "71462-ed224e5a561a545814023bf9" 35 | else: 36 | consumerKey: str = "71462-05fb63bf0314903c7e73c52f" 37 | 38 | def getArticles(self) -> List[Article]: 39 | """Get unread articles from Pocket""" 40 | if not self._accessToken: 41 | self._accessToken = self._authenticate() 42 | 43 | if not self._accessToken: 44 | showCritical("Authentication failed.") 45 | return [] 46 | 47 | response = post( 48 | "https://getpocket.com/v3/get", 49 | json={ 50 | "consumer_key": self.consumerKey, 51 | "access_token": self._accessToken, 52 | "contentType": "article", 53 | "count": 30, 54 | "detailType": "complete", 55 | "sort": "newest", 56 | }, 57 | headers=self._headers, 58 | timeout=5, 59 | ) 60 | 61 | if response.json()["list"]: 62 | return [ 63 | Article(title=a["resolved_title"], data=a) 64 | for a in response.json()["list"].values() 65 | ] 66 | 67 | showInfo("You have no unread articles remaining.") 68 | return [] 69 | 70 | def _authenticate(self): 71 | response = post( 72 | "https://getpocket.com/v3/oauth/request", 73 | json={ 74 | "consumer_key": self.consumerKey, 75 | "redirect_uri": self._redirectURI, 76 | }, 77 | headers=self._headers, 78 | timeout=5, 79 | ) 80 | 81 | requestToken = response.json()["code"] 82 | 83 | authUrl = "https://getpocket.com/auth/authorize?" 84 | authParams = { 85 | "request_token": requestToken, 86 | "redirect_uri": self._redirectURI, 87 | } 88 | 89 | openLink(authUrl + urlencode(authParams)) 90 | if not askUser("I have authenticated with Pocket."): 91 | return None 92 | 93 | response = post( 94 | "https://getpocket.com/v3/oauth/authorize", 95 | json={"consumer_key": self.consumerKey, "code": requestToken}, 96 | headers=self._headers, 97 | timeout=5, 98 | ) 99 | 100 | try: 101 | return response.json()["access_token"] 102 | except JSONDecodeError: 103 | return None 104 | 105 | def archive(self, article: Article) -> None: 106 | post( 107 | "https://getpocket.com/v3/send", 108 | json={ 109 | "consumer_key": self.consumerKey, 110 | "access_token": self._accessToken, 111 | "actions": [{"action": "archive", "item_id": article.data["item_id"]}], 112 | }, 113 | timeout=5, 114 | ) 115 | -------------------------------------------------------------------------------- /ir/importer/web.py: -------------------------------------------------------------------------------- 1 | from urllib.error import HTTPError 2 | from urllib.parse import urlsplit 3 | 4 | from attr import dataclass 5 | from bs4 import BeautifulSoup 6 | from requests import get 7 | 8 | from ..settings import SettingsManager 9 | from .exceptions import ErrorLevel, ImporterError 10 | from .html_cleaner import HtmlCleaner 11 | 12 | 13 | @dataclass 14 | class Webpage: 15 | url: str 16 | title: str 17 | body: str 18 | 19 | 20 | class Web: 21 | def __init__(self, settings: SettingsManager) -> None: 22 | self._settings = settings 23 | self._htmlCleaner = HtmlCleaner() 24 | 25 | def download(self, url: str) -> Webpage: 26 | html = self._fetchWebpage(url) 27 | page = self._htmlCleaner.clean(html, url) 28 | return self._constructResponse(url, page) 29 | 30 | def _fetchWebpage(self, url: str) -> bytes: 31 | if urlsplit(url).scheme not in ["http", "https"]: 32 | raise ImporterError( 33 | ErrorLevel.CRITICAL, "Only HTTP requests are supported." 34 | ) 35 | 36 | try: 37 | html = get( 38 | url, headers={"User-Agent": self._settings["userAgent"]}, timeout=5 39 | ).content 40 | except HTTPError as error: 41 | raise ImporterError( 42 | ErrorLevel.WARNING, 43 | "The remote server has returned an error:" 44 | f"HTTP Error {error.code} ({error.reason})", 45 | ) from error 46 | except ConnectionError as error: 47 | raise ImporterError( 48 | ErrorLevel.WARNING, "There was a problem connecting to the website." 49 | ) from error 50 | 51 | return html 52 | 53 | def _constructResponse(self, url: str, webpage: BeautifulSoup) -> Webpage: 54 | try: 55 | body = "\n".join(map(str, webpage.find("body").children)) 56 | title = webpage.title.string if webpage.title else url 57 | except AttributeError as error: 58 | raise ImporterError( 59 | ErrorLevel.WARNING, f"The webpage at {url} is not valid." 60 | ) from error 61 | 62 | return Webpage(url, title, body) 63 | -------------------------------------------------------------------------------- /ir/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/ir/lib/__init__.py -------------------------------------------------------------------------------- /ir/lib/cgi.py: -------------------------------------------------------------------------------- 1 | #! /usr/local/bin/python 2 | 3 | # NOTE: the above "/usr/local/bin/python" is NOT a mistake. It is 4 | # intentionally NOT "/usr/bin/env python". On many systems 5 | # (e.g. Solaris), /usr/local/bin is not in $PATH as passed to CGI 6 | # scripts, and /usr/local/bin is the default directory where Python is 7 | # installed, so /usr/bin/env would be unable to find python. Granted, 8 | # binary installations by Linux vendors often install Python in 9 | # /usr/bin. So let those vendors patch cgi.py to match their choice 10 | # of installation. 11 | 12 | """Support module for CGI (Common Gateway Interface) scripts. 13 | 14 | This module defines a number of utilities for use by CGI scripts 15 | written in Python. 16 | """ 17 | 18 | # History 19 | # ------- 20 | # 21 | # Michael McLay started this module. Steve Majewski changed the 22 | # interface to SvFormContentDict and FormContentDict. The multipart 23 | # parsing was inspired by code submitted by Andreas Paepcke. Guido van 24 | # Rossum rewrote, reformatted and documented the module and is currently 25 | # responsible for its maintenance. 26 | # 27 | 28 | __version__ = "2.6" 29 | 30 | 31 | # Imports 32 | # ======= 33 | 34 | import html 35 | import locale 36 | import os 37 | import sys 38 | import tempfile 39 | import urllib.parse 40 | from collections.abc import Mapping 41 | from email.message import Message 42 | from email.parser import FeedParser 43 | from io import BytesIO, StringIO, TextIOWrapper 44 | from warnings import warn 45 | 46 | __all__ = [ 47 | "MiniFieldStorage", 48 | "FieldStorage", 49 | "parse", 50 | "parse_qs", 51 | "parse_qsl", 52 | "parse_multipart", 53 | "parse_header", 54 | "test", 55 | "print_exception", 56 | "print_environ", 57 | "print_form", 58 | "print_directory", 59 | "print_arguments", 60 | "print_environ_usage", 61 | "escape", 62 | ] 63 | 64 | # Logging support 65 | # =============== 66 | 67 | logfile = "" # Filename to log to, if not empty 68 | logfp = None # File object to log to, if not None 69 | 70 | 71 | def initlog(*allargs): 72 | """Write a log message, if there is a log file. 73 | 74 | Even though this function is called initlog(), you should always 75 | use log(); log is a variable that is set either to initlog 76 | (initially), to dolog (once the log file has been opened), or to 77 | nolog (when logging is disabled). 78 | 79 | The first argument is a format string; the remaining arguments (if 80 | any) are arguments to the % operator, so e.g. 81 | log("%s: %s", "a", "b") 82 | will write "a: b" to the log file, followed by a newline. 83 | 84 | If the global logfp is not None, it should be a file object to 85 | which log data is written. 86 | 87 | If the global logfp is None, the global logfile may be a string 88 | giving a filename to open, in append mode. This file should be 89 | world writable!!! If the file can't be opened, logging is 90 | silently disabled (since there is no safe place where we could 91 | send an error message). 92 | 93 | """ 94 | global log, logfile, logfp 95 | if logfile and not logfp: 96 | try: 97 | logfp = open(logfile, "a") 98 | except OSError: 99 | pass 100 | if not logfp: 101 | log = nolog 102 | else: 103 | log = dolog 104 | log(*allargs) 105 | 106 | 107 | def dolog(fmt, *args): 108 | """Write a log message to the log file. See initlog() for docs.""" 109 | logfp.write(fmt % args + "\n") 110 | 111 | 112 | def nolog(*allargs): 113 | """Dummy function, assigned to log when logging is disabled.""" 114 | pass 115 | 116 | 117 | def closelog(): 118 | """Close the log file.""" 119 | global log, logfile, logfp 120 | logfile = "" 121 | if logfp: 122 | logfp.close() 123 | logfp = None 124 | log = initlog 125 | 126 | 127 | log = initlog # The current logging function 128 | 129 | 130 | # Parsing functions 131 | # ================= 132 | 133 | # Maximum input we will accept when REQUEST_METHOD is POST 134 | # 0 ==> unlimited input 135 | maxlen = 0 136 | 137 | 138 | def parse(fp=None, environ=os.environ, keep_blank_values=0, strict_parsing=0): 139 | """Parse a query in the environment or from a file (default stdin) 140 | 141 | Arguments, all optional: 142 | 143 | fp : file pointer; default: sys.stdin.buffer 144 | 145 | environ : environment dictionary; default: os.environ 146 | 147 | keep_blank_values: flag indicating whether blank values in 148 | percent-encoded forms should be treated as blank strings. 149 | A true value indicates that blanks should be retained as 150 | blank strings. The default false value indicates that 151 | blank values are to be ignored and treated as if they were 152 | not included. 153 | 154 | strict_parsing: flag indicating what to do with parsing errors. 155 | If false (the default), errors are silently ignored. 156 | If true, errors raise a ValueError exception. 157 | """ 158 | if fp is None: 159 | fp = sys.stdin 160 | 161 | # field keys and values (except for files) are returned as strings 162 | # an encoding is required to decode the bytes read from self.fp 163 | if hasattr(fp, "encoding"): 164 | encoding = fp.encoding 165 | else: 166 | encoding = "latin-1" 167 | 168 | # fp.read() must return bytes 169 | if isinstance(fp, TextIOWrapper): 170 | fp = fp.buffer 171 | 172 | if not "REQUEST_METHOD" in environ: 173 | environ["REQUEST_METHOD"] = "GET" # For testing stand-alone 174 | if environ["REQUEST_METHOD"] == "POST": 175 | ctype, pdict = parse_header(environ["CONTENT_TYPE"]) 176 | if ctype == "multipart/form-data": 177 | return parse_multipart(fp, pdict) 178 | elif ctype == "application/x-www-form-urlencoded": 179 | clength = int(environ["CONTENT_LENGTH"]) 180 | if maxlen and clength > maxlen: 181 | raise ValueError("Maximum content length exceeded") 182 | qs = fp.read(clength).decode(encoding) 183 | else: 184 | qs = "" # Unknown content-type 185 | if "QUERY_STRING" in environ: 186 | if qs: 187 | qs = qs + "&" 188 | qs = qs + environ["QUERY_STRING"] 189 | elif sys.argv[1:]: 190 | if qs: 191 | qs = qs + "&" 192 | qs = qs + sys.argv[1] 193 | environ["QUERY_STRING"] = qs # XXX Shouldn't, really 194 | elif "QUERY_STRING" in environ: 195 | qs = environ["QUERY_STRING"] 196 | else: 197 | if sys.argv[1:]: 198 | qs = sys.argv[1] 199 | else: 200 | qs = "" 201 | environ["QUERY_STRING"] = qs # XXX Shouldn't, really 202 | return urllib.parse.parse_qs( 203 | qs, keep_blank_values, strict_parsing, encoding=encoding 204 | ) 205 | 206 | 207 | # parse query string function called from urlparse, 208 | # this is done in order to maintain backward compatibility. 209 | 210 | 211 | def parse_qs(qs, keep_blank_values=0, strict_parsing=0): 212 | """Parse a query given as a string argument.""" 213 | warn( 214 | "cgi.parse_qs is deprecated, use urllib.parse.parse_qs instead", 215 | DeprecationWarning, 216 | 2, 217 | ) 218 | return urllib.parse.parse_qs(qs, keep_blank_values, strict_parsing) 219 | 220 | 221 | def parse_qsl(qs, keep_blank_values=0, strict_parsing=0): 222 | """Parse a query given as a string argument.""" 223 | warn( 224 | "cgi.parse_qsl is deprecated, use urllib.parse.parse_qsl instead", 225 | DeprecationWarning, 226 | 2, 227 | ) 228 | return urllib.parse.parse_qsl(qs, keep_blank_values, strict_parsing) 229 | 230 | 231 | def parse_multipart(fp, pdict, encoding="utf-8", errors="replace"): 232 | """Parse multipart input. 233 | 234 | Arguments: 235 | fp : input file 236 | pdict: dictionary containing other parameters of content-type header 237 | encoding, errors: request encoding and error handler, passed to 238 | FieldStorage 239 | 240 | Returns a dictionary just like parse_qs(): keys are the field names, each 241 | value is a list of values for that field. For non-file fields, the value 242 | is a list of strings. 243 | """ 244 | # RFC 2026, Section 5.1 : The "multipart" boundary delimiters are always 245 | # represented as 7bit US-ASCII. 246 | boundary = pdict["boundary"].decode("ascii") 247 | ctype = "multipart/form-data; boundary={}".format(boundary) 248 | headers = Message() 249 | headers.set_type(ctype) 250 | headers["Content-Length"] = pdict["CONTENT-LENGTH"] 251 | fs = FieldStorage( 252 | fp, 253 | headers=headers, 254 | encoding=encoding, 255 | errors=errors, 256 | environ={"REQUEST_METHOD": "POST"}, 257 | ) 258 | return {k: fs.getlist(k) for k in fs} 259 | 260 | 261 | def _parseparam(s): 262 | while s[:1] == ";": 263 | s = s[1:] 264 | end = s.find(";") 265 | while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2: 266 | end = s.find(";", end + 1) 267 | if end < 0: 268 | end = len(s) 269 | f = s[:end] 270 | yield f.strip() 271 | s = s[end:] 272 | 273 | 274 | def parse_header(line): 275 | """Parse a Content-type like header. 276 | 277 | Return the main content-type and a dictionary of options. 278 | 279 | """ 280 | parts = _parseparam(";" + line) 281 | key = parts.__next__() 282 | pdict = {} 283 | for p in parts: 284 | i = p.find("=") 285 | if i >= 0: 286 | name = p[:i].strip().lower() 287 | value = p[i + 1 :].strip() 288 | if len(value) >= 2 and value[0] == value[-1] == '"': 289 | value = value[1:-1] 290 | value = value.replace("\\\\", "\\").replace('\\"', '"') 291 | pdict[name] = value 292 | return key, pdict 293 | 294 | 295 | # Classes for field storage 296 | # ========================= 297 | 298 | 299 | class MiniFieldStorage: 300 | """Like FieldStorage, for use when no file uploads are possible.""" 301 | 302 | # Dummy attributes 303 | filename = None 304 | list = None 305 | type = None 306 | file = None 307 | type_options = {} 308 | disposition = None 309 | disposition_options = {} 310 | headers = {} 311 | 312 | def __init__(self, name, value): 313 | """Constructor from field name and value.""" 314 | self.name = name 315 | self.value = value 316 | # self.file = StringIO(value) 317 | 318 | def __repr__(self): 319 | """Return printable representation.""" 320 | return "MiniFieldStorage(%r, %r)" % (self.name, self.value) 321 | 322 | 323 | class FieldStorage: 324 | """Store a sequence of fields, reading multipart/form-data. 325 | 326 | This class provides naming, typing, files stored on disk, and 327 | more. At the top level, it is accessible like a dictionary, whose 328 | keys are the field names. (Note: None can occur as a field name.) 329 | The items are either a Python list (if there's multiple values) or 330 | another FieldStorage or MiniFieldStorage object. If it's a single 331 | object, it has the following attributes: 332 | 333 | name: the field name, if specified; otherwise None 334 | 335 | filename: the filename, if specified; otherwise None; this is the 336 | client side filename, *not* the file name on which it is 337 | stored (that's a temporary file you don't deal with) 338 | 339 | value: the value as a *string*; for file uploads, this 340 | transparently reads the file every time you request the value 341 | and returns *bytes* 342 | 343 | file: the file(-like) object from which you can read the data *as 344 | bytes* ; None if the data is stored a simple string 345 | 346 | type: the content-type, or None if not specified 347 | 348 | type_options: dictionary of options specified on the content-type 349 | line 350 | 351 | disposition: content-disposition, or None if not specified 352 | 353 | disposition_options: dictionary of corresponding options 354 | 355 | headers: a dictionary(-like) object (sometimes email.message.Message or a 356 | subclass thereof) containing *all* headers 357 | 358 | The class is subclassable, mostly for the purpose of overriding 359 | the make_file() method, which is called internally to come up with 360 | a file open for reading and writing. This makes it possible to 361 | override the default choice of storing all files in a temporary 362 | directory and unlinking them as soon as they have been opened. 363 | 364 | """ 365 | 366 | def __init__( 367 | self, 368 | fp=None, 369 | headers=None, 370 | outerboundary=b"", 371 | environ=os.environ, 372 | keep_blank_values=0, 373 | strict_parsing=0, 374 | limit=None, 375 | encoding="utf-8", 376 | errors="replace", 377 | max_num_fields=None, 378 | ): 379 | """Constructor. Read multipart/* until last part. 380 | 381 | Arguments, all optional: 382 | 383 | fp : file pointer; default: sys.stdin.buffer 384 | (not used when the request method is GET) 385 | Can be : 386 | 1. a TextIOWrapper object 387 | 2. an object whose read() and readline() methods return bytes 388 | 389 | headers : header dictionary-like object; default: 390 | taken from environ as per CGI spec 391 | 392 | outerboundary : terminating multipart boundary 393 | (for internal use only) 394 | 395 | environ : environment dictionary; default: os.environ 396 | 397 | keep_blank_values: flag indicating whether blank values in 398 | percent-encoded forms should be treated as blank strings. 399 | A true value indicates that blanks should be retained as 400 | blank strings. The default false value indicates that 401 | blank values are to be ignored and treated as if they were 402 | not included. 403 | 404 | strict_parsing: flag indicating what to do with parsing errors. 405 | If false (the default), errors are silently ignored. 406 | If true, errors raise a ValueError exception. 407 | 408 | limit : used internally to read parts of multipart/form-data forms, 409 | to exit from the reading loop when reached. It is the difference 410 | between the form content-length and the number of bytes already 411 | read 412 | 413 | encoding, errors : the encoding and error handler used to decode the 414 | binary stream to strings. Must be the same as the charset defined 415 | for the page sending the form (content-type : meta http-equiv or 416 | header) 417 | 418 | max_num_fields: int. If set, then __init__ throws a ValueError 419 | if there are more than n fields read by parse_qsl(). 420 | 421 | """ 422 | method = "GET" 423 | self.keep_blank_values = keep_blank_values 424 | self.strict_parsing = strict_parsing 425 | self.max_num_fields = max_num_fields 426 | if "REQUEST_METHOD" in environ: 427 | method = environ["REQUEST_METHOD"].upper() 428 | self.qs_on_post = None 429 | if method == "GET" or method == "HEAD": 430 | if "QUERY_STRING" in environ: 431 | qs = environ["QUERY_STRING"] 432 | elif sys.argv[1:]: 433 | qs = sys.argv[1] 434 | else: 435 | qs = "" 436 | qs = qs.encode(locale.getpreferredencoding(), "surrogateescape") 437 | fp = BytesIO(qs) 438 | if headers is None: 439 | headers = {"content-type": "application/x-www-form-urlencoded"} 440 | if headers is None: 441 | headers = {} 442 | if method == "POST": 443 | # Set default content-type for POST to what's traditional 444 | headers["content-type"] = "application/x-www-form-urlencoded" 445 | if "CONTENT_TYPE" in environ: 446 | headers["content-type"] = environ["CONTENT_TYPE"] 447 | if "QUERY_STRING" in environ: 448 | self.qs_on_post = environ["QUERY_STRING"] 449 | if "CONTENT_LENGTH" in environ: 450 | headers["content-length"] = environ["CONTENT_LENGTH"] 451 | else: 452 | if not (isinstance(headers, (Mapping, Message))): 453 | raise TypeError( 454 | "headers must be mapping or an instance of " "email.message.Message" 455 | ) 456 | self.headers = headers 457 | if fp is None: 458 | self.fp = sys.stdin.buffer 459 | # self.fp.read() must return bytes 460 | elif isinstance(fp, TextIOWrapper): 461 | self.fp = fp.buffer 462 | else: 463 | if not (hasattr(fp, "read") and hasattr(fp, "readline")): 464 | raise TypeError("fp must be file pointer") 465 | self.fp = fp 466 | 467 | self.encoding = encoding 468 | self.errors = errors 469 | 470 | if not isinstance(outerboundary, bytes): 471 | raise TypeError( 472 | "outerboundary must be bytes, not %s" % type(outerboundary).__name__ 473 | ) 474 | self.outerboundary = outerboundary 475 | 476 | self.bytes_read = 0 477 | self.limit = limit 478 | 479 | # Process content-disposition header 480 | cdisp, pdict = "", {} 481 | if "content-disposition" in self.headers: 482 | cdisp, pdict = parse_header(self.headers["content-disposition"]) 483 | self.disposition = cdisp 484 | self.disposition_options = pdict 485 | self.name = None 486 | if "name" in pdict: 487 | self.name = pdict["name"] 488 | self.filename = None 489 | if "filename" in pdict: 490 | self.filename = pdict["filename"] 491 | self._binary_file = self.filename is not None 492 | 493 | # Process content-type header 494 | # 495 | # Honor any existing content-type header. But if there is no 496 | # content-type header, use some sensible defaults. Assume 497 | # outerboundary is "" at the outer level, but something non-false 498 | # inside a multi-part. The default for an inner part is text/plain, 499 | # but for an outer part it should be urlencoded. This should catch 500 | # bogus clients which erroneously forget to include a content-type 501 | # header. 502 | # 503 | # See below for what we do if there does exist a content-type header, 504 | # but it happens to be something we don't understand. 505 | if "content-type" in self.headers: 506 | ctype, pdict = parse_header(self.headers["content-type"]) 507 | elif self.outerboundary or method != "POST": 508 | ctype, pdict = "text/plain", {} 509 | else: 510 | ctype, pdict = "application/x-www-form-urlencoded", {} 511 | self.type = ctype 512 | self.type_options = pdict 513 | if "boundary" in pdict: 514 | self.innerboundary = pdict["boundary"].encode(self.encoding, self.errors) 515 | else: 516 | self.innerboundary = b"" 517 | 518 | clen = -1 519 | if "content-length" in self.headers: 520 | try: 521 | clen = int(self.headers["content-length"]) 522 | except ValueError: 523 | pass 524 | if maxlen and clen > maxlen: 525 | raise ValueError("Maximum content length exceeded") 526 | self.length = clen 527 | if self.limit is None and clen: 528 | self.limit = clen 529 | 530 | self.list = self.file = None 531 | self.done = 0 532 | if ctype == "application/x-www-form-urlencoded": 533 | self.read_urlencoded() 534 | elif ctype[:10] == "multipart/": 535 | self.read_multi(environ, keep_blank_values, strict_parsing) 536 | else: 537 | self.read_single() 538 | 539 | def __del__(self): 540 | try: 541 | self.file.close() 542 | except AttributeError: 543 | pass 544 | 545 | def __enter__(self): 546 | return self 547 | 548 | def __exit__(self, *args): 549 | self.file.close() 550 | 551 | def __repr__(self): 552 | """Return a printable representation.""" 553 | return "FieldStorage(%r, %r, %r)" % (self.name, self.filename, self.value) 554 | 555 | def __iter__(self): 556 | return iter(self.keys()) 557 | 558 | def __getattr__(self, name): 559 | if name != "value": 560 | raise AttributeError(name) 561 | if self.file: 562 | self.file.seek(0) 563 | value = self.file.read() 564 | self.file.seek(0) 565 | elif self.list is not None: 566 | value = self.list 567 | else: 568 | value = None 569 | return value 570 | 571 | def __getitem__(self, key): 572 | """Dictionary style indexing.""" 573 | if self.list is None: 574 | raise TypeError("not indexable") 575 | found = [] 576 | for item in self.list: 577 | if item.name == key: 578 | found.append(item) 579 | if not found: 580 | raise KeyError(key) 581 | if len(found) == 1: 582 | return found[0] 583 | else: 584 | return found 585 | 586 | def getvalue(self, key, default=None): 587 | """Dictionary style get() method, including 'value' lookup.""" 588 | if key in self: 589 | value = self[key] 590 | if isinstance(value, list): 591 | return [x.value for x in value] 592 | else: 593 | return value.value 594 | else: 595 | return default 596 | 597 | def getfirst(self, key, default=None): 598 | """Return the first value received.""" 599 | if key in self: 600 | value = self[key] 601 | if isinstance(value, list): 602 | return value[0].value 603 | else: 604 | return value.value 605 | else: 606 | return default 607 | 608 | def getlist(self, key): 609 | """Return list of received values.""" 610 | if key in self: 611 | value = self[key] 612 | if isinstance(value, list): 613 | return [x.value for x in value] 614 | else: 615 | return [value.value] 616 | else: 617 | return [] 618 | 619 | def keys(self): 620 | """Dictionary style keys() method.""" 621 | if self.list is None: 622 | raise TypeError("not indexable") 623 | return list(set(item.name for item in self.list)) 624 | 625 | def __contains__(self, key): 626 | """Dictionary style __contains__ method.""" 627 | if self.list is None: 628 | raise TypeError("not indexable") 629 | return any(item.name == key for item in self.list) 630 | 631 | def __len__(self): 632 | """Dictionary style len(x) support.""" 633 | return len(self.keys()) 634 | 635 | def __bool__(self): 636 | if self.list is None: 637 | raise TypeError("Cannot be converted to bool.") 638 | return bool(self.list) 639 | 640 | def read_urlencoded(self): 641 | """Internal: read data in query string format.""" 642 | qs = self.fp.read(self.length) 643 | if not isinstance(qs, bytes): 644 | raise ValueError( 645 | "%s should return bytes, got %s" % (self.fp, type(qs).__name__) 646 | ) 647 | qs = qs.decode(self.encoding, self.errors) 648 | if self.qs_on_post: 649 | qs += "&" + self.qs_on_post 650 | query = urllib.parse.parse_qsl( 651 | qs, 652 | self.keep_blank_values, 653 | self.strict_parsing, 654 | encoding=self.encoding, 655 | errors=self.errors, 656 | max_num_fields=self.max_num_fields, 657 | ) 658 | self.list = [MiniFieldStorage(key, value) for key, value in query] 659 | self.skip_lines() 660 | 661 | FieldStorageClass = None 662 | 663 | def read_multi(self, environ, keep_blank_values, strict_parsing): 664 | """Internal: read a part that is itself multipart.""" 665 | ib = self.innerboundary 666 | if not valid_boundary(ib): 667 | raise ValueError("Invalid boundary in multipart form: %r" % (ib,)) 668 | self.list = [] 669 | if self.qs_on_post: 670 | query = urllib.parse.parse_qsl( 671 | self.qs_on_post, 672 | self.keep_blank_values, 673 | self.strict_parsing, 674 | encoding=self.encoding, 675 | errors=self.errors, 676 | max_num_fields=self.max_num_fields, 677 | ) 678 | self.list.extend(MiniFieldStorage(key, value) for key, value in query) 679 | 680 | klass = self.FieldStorageClass or self.__class__ 681 | first_line = self.fp.readline() # bytes 682 | if not isinstance(first_line, bytes): 683 | raise ValueError( 684 | "%s should return bytes, got %s" % (self.fp, type(first_line).__name__) 685 | ) 686 | self.bytes_read += len(first_line) 687 | 688 | # Ensure that we consume the file until we've hit our inner boundary 689 | while first_line.strip() != (b"--" + self.innerboundary) and first_line: 690 | first_line = self.fp.readline() 691 | self.bytes_read += len(first_line) 692 | 693 | # Propagate max_num_fields into the sub class appropriately 694 | max_num_fields = self.max_num_fields 695 | if max_num_fields is not None: 696 | max_num_fields -= len(self.list) 697 | 698 | while True: 699 | parser = FeedParser() 700 | hdr_text = b"" 701 | while True: 702 | data = self.fp.readline() 703 | hdr_text += data 704 | if not data.strip(): 705 | break 706 | if not hdr_text: 707 | break 708 | # parser takes strings, not bytes 709 | self.bytes_read += len(hdr_text) 710 | parser.feed(hdr_text.decode(self.encoding, self.errors)) 711 | headers = parser.close() 712 | 713 | # Some clients add Content-Length for part headers, ignore them 714 | if "content-length" in headers: 715 | del headers["content-length"] 716 | 717 | part = klass( 718 | self.fp, 719 | headers, 720 | ib, 721 | environ, 722 | keep_blank_values, 723 | strict_parsing, 724 | self.limit - self.bytes_read, 725 | self.encoding, 726 | self.errors, 727 | max_num_fields, 728 | ) 729 | 730 | if max_num_fields is not None: 731 | max_num_fields -= 1 732 | if part.list: 733 | max_num_fields -= len(part.list) 734 | if max_num_fields < 0: 735 | raise ValueError("Max number of fields exceeded") 736 | 737 | self.bytes_read += part.bytes_read 738 | self.list.append(part) 739 | if part.done or self.bytes_read >= self.length > 0: 740 | break 741 | self.skip_lines() 742 | 743 | def read_single(self): 744 | """Internal: read an atomic part.""" 745 | if self.length >= 0: 746 | self.read_binary() 747 | self.skip_lines() 748 | else: 749 | self.read_lines() 750 | self.file.seek(0) 751 | 752 | bufsize = 8 * 1024 # I/O buffering size for copy to file 753 | 754 | def read_binary(self): 755 | """Internal: read binary data.""" 756 | self.file = self.make_file() 757 | todo = self.length 758 | if todo >= 0: 759 | while todo > 0: 760 | data = self.fp.read(min(todo, self.bufsize)) # bytes 761 | if not isinstance(data, bytes): 762 | raise ValueError( 763 | "%s should return bytes, got %s" 764 | % (self.fp, type(data).__name__) 765 | ) 766 | self.bytes_read += len(data) 767 | if not data: 768 | self.done = -1 769 | break 770 | self.file.write(data) 771 | todo = todo - len(data) 772 | 773 | def read_lines(self): 774 | """Internal: read lines until EOF or outerboundary.""" 775 | if self._binary_file: 776 | self.file = self.__file = BytesIO() # store data as bytes for files 777 | else: 778 | self.file = self.__file = StringIO() # as strings for other fields 779 | if self.outerboundary: 780 | self.read_lines_to_outerboundary() 781 | else: 782 | self.read_lines_to_eof() 783 | 784 | def __write(self, line): 785 | """line is always bytes, not string""" 786 | if self.__file is not None: 787 | if self.__file.tell() + len(line) > 1000: 788 | self.file = self.make_file() 789 | data = self.__file.getvalue() 790 | self.file.write(data) 791 | self.__file = None 792 | if self._binary_file: 793 | # keep bytes 794 | self.file.write(line) 795 | else: 796 | # decode to string 797 | self.file.write(line.decode(self.encoding, self.errors)) 798 | 799 | def read_lines_to_eof(self): 800 | """Internal: read lines until EOF.""" 801 | while 1: 802 | line = self.fp.readline(1 << 16) # bytes 803 | self.bytes_read += len(line) 804 | if not line: 805 | self.done = -1 806 | break 807 | self.__write(line) 808 | 809 | def read_lines_to_outerboundary(self): 810 | """Internal: read lines until outerboundary. 811 | Data is read as bytes: boundaries and line ends must be converted 812 | to bytes for comparisons. 813 | """ 814 | next_boundary = b"--" + self.outerboundary 815 | last_boundary = next_boundary + b"--" 816 | delim = b"" 817 | last_line_lfend = True 818 | _read = 0 819 | while 1: 820 | if _read >= self.limit: 821 | break 822 | line = self.fp.readline(1 << 16) # bytes 823 | self.bytes_read += len(line) 824 | _read += len(line) 825 | if not line: 826 | self.done = -1 827 | break 828 | if delim == b"\r": 829 | line = delim + line 830 | delim = b"" 831 | if line.startswith(b"--") and last_line_lfend: 832 | strippedline = line.rstrip() 833 | if strippedline == next_boundary: 834 | break 835 | if strippedline == last_boundary: 836 | self.done = 1 837 | break 838 | odelim = delim 839 | if line.endswith(b"\r\n"): 840 | delim = b"\r\n" 841 | line = line[:-2] 842 | last_line_lfend = True 843 | elif line.endswith(b"\n"): 844 | delim = b"\n" 845 | line = line[:-1] 846 | last_line_lfend = True 847 | elif line.endswith(b"\r"): 848 | # We may interrupt \r\n sequences if they span the 2**16 849 | # byte boundary 850 | delim = b"\r" 851 | line = line[:-1] 852 | last_line_lfend = False 853 | else: 854 | delim = b"" 855 | last_line_lfend = False 856 | self.__write(odelim + line) 857 | 858 | def skip_lines(self): 859 | """Internal: skip lines until outer boundary if defined.""" 860 | if not self.outerboundary or self.done: 861 | return 862 | next_boundary = b"--" + self.outerboundary 863 | last_boundary = next_boundary + b"--" 864 | last_line_lfend = True 865 | while True: 866 | line = self.fp.readline(1 << 16) 867 | self.bytes_read += len(line) 868 | if not line: 869 | self.done = -1 870 | break 871 | if line.endswith(b"--") and last_line_lfend: 872 | strippedline = line.strip() 873 | if strippedline == next_boundary: 874 | break 875 | if strippedline == last_boundary: 876 | self.done = 1 877 | break 878 | last_line_lfend = line.endswith(b"\n") 879 | 880 | def make_file(self): 881 | """Overridable: return a readable & writable file. 882 | 883 | The file will be used as follows: 884 | - data is written to it 885 | - seek(0) 886 | - data is read from it 887 | 888 | The file is opened in binary mode for files, in text mode 889 | for other fields 890 | 891 | This version opens a temporary file for reading and writing, 892 | and immediately deletes (unlinks) it. The trick (on Unix!) is 893 | that the file can still be used, but it can't be opened by 894 | another process, and it will automatically be deleted when it 895 | is closed or when the current process terminates. 896 | 897 | If you want a more permanent file, you derive a class which 898 | overrides this method. If you want a visible temporary file 899 | that is nevertheless automatically deleted when the script 900 | terminates, try defining a __del__ method in a derived class 901 | which unlinks the temporary files you have created. 902 | 903 | """ 904 | if self._binary_file: 905 | return tempfile.TemporaryFile("wb+") 906 | else: 907 | return tempfile.TemporaryFile("w+", encoding=self.encoding, newline="\n") 908 | 909 | 910 | # Test/debug code 911 | # =============== 912 | 913 | 914 | def test(environ=os.environ): 915 | """Robust test CGI script, usable as main program. 916 | 917 | Write minimal HTTP headers and dump all information provided to 918 | the script in HTML form. 919 | 920 | """ 921 | print("Content-type: text/html") 922 | print() 923 | sys.stderr = sys.stdout 924 | try: 925 | form = FieldStorage() # Replace with other classes to test those 926 | print_directory() 927 | print_arguments() 928 | print_form(form) 929 | print_environ(environ) 930 | print_environ_usage() 931 | 932 | def f(): 933 | exec("testing print_exception() -- <I>italics?</I>") 934 | 935 | def g(f=f): 936 | f() 937 | 938 | print("<H3>What follows is a test, not an actual exception:</H3>") 939 | g() 940 | except: 941 | print_exception() 942 | 943 | print("<H1>Second try with a small maxlen...</H1>") 944 | 945 | global maxlen 946 | maxlen = 50 947 | try: 948 | form = FieldStorage() # Replace with other classes to test those 949 | print_directory() 950 | print_arguments() 951 | print_form(form) 952 | print_environ(environ) 953 | except: 954 | print_exception() 955 | 956 | 957 | def print_exception(type=None, value=None, tb=None, limit=None): 958 | if type is None: 959 | type, value, tb = sys.exc_info() 960 | import traceback 961 | 962 | print() 963 | print("<H3>Traceback (most recent call last):</H3>") 964 | list = traceback.format_tb(tb, limit) + traceback.format_exception_only(type, value) 965 | print( 966 | "<PRE>%s<B>%s</B></PRE>" 967 | % ( 968 | html.escape("".join(list[:-1])), 969 | html.escape(list[-1]), 970 | ) 971 | ) 972 | del tb 973 | 974 | 975 | def print_environ(environ=os.environ): 976 | """Dump the shell environment as HTML.""" 977 | keys = sorted(environ.keys()) 978 | print() 979 | print("<H3>Shell Environment:</H3>") 980 | print("<DL>") 981 | for key in keys: 982 | print("<DT>", html.escape(key), "<DD>", html.escape(environ[key])) 983 | print("</DL>") 984 | print() 985 | 986 | 987 | def print_form(form): 988 | """Dump the contents of a form as HTML.""" 989 | keys = sorted(form.keys()) 990 | print() 991 | print("<H3>Form Contents:</H3>") 992 | if not keys: 993 | print("<P>No form fields.") 994 | print("<DL>") 995 | for key in keys: 996 | print("<DT>" + html.escape(key) + ":", end=" ") 997 | value = form[key] 998 | print("<i>" + html.escape(repr(type(value))) + "</i>") 999 | print("<DD>" + html.escape(repr(value))) 1000 | print("</DL>") 1001 | print() 1002 | 1003 | 1004 | def print_directory(): 1005 | """Dump the current directory as HTML.""" 1006 | print() 1007 | print("<H3>Current Working Directory:</H3>") 1008 | try: 1009 | pwd = os.getcwd() 1010 | except OSError as msg: 1011 | print("OSError:", html.escape(str(msg))) 1012 | else: 1013 | print(html.escape(pwd)) 1014 | print() 1015 | 1016 | 1017 | def print_arguments(): 1018 | print() 1019 | print("<H3>Command Line Arguments:</H3>") 1020 | print() 1021 | print(sys.argv) 1022 | print() 1023 | 1024 | 1025 | def print_environ_usage(): 1026 | """Dump a list of environment variables used by CGI as HTML.""" 1027 | print( 1028 | """ 1029 | <H3>These environment variables could have been set:</H3> 1030 | <UL> 1031 | <LI>AUTH_TYPE 1032 | <LI>CONTENT_LENGTH 1033 | <LI>CONTENT_TYPE 1034 | <LI>DATE_GMT 1035 | <LI>DATE_LOCAL 1036 | <LI>DOCUMENT_NAME 1037 | <LI>DOCUMENT_ROOT 1038 | <LI>DOCUMENT_URI 1039 | <LI>GATEWAY_INTERFACE 1040 | <LI>LAST_MODIFIED 1041 | <LI>PATH 1042 | <LI>PATH_INFO 1043 | <LI>PATH_TRANSLATED 1044 | <LI>QUERY_STRING 1045 | <LI>REMOTE_ADDR 1046 | <LI>REMOTE_HOST 1047 | <LI>REMOTE_IDENT 1048 | <LI>REMOTE_USER 1049 | <LI>REQUEST_METHOD 1050 | <LI>SCRIPT_NAME 1051 | <LI>SERVER_NAME 1052 | <LI>SERVER_PORT 1053 | <LI>SERVER_PROTOCOL 1054 | <LI>SERVER_ROOT 1055 | <LI>SERVER_SOFTWARE 1056 | </UL> 1057 | In addition, HTTP headers sent by the server may be passed in the 1058 | environment as well. Here are some common variable names: 1059 | <UL> 1060 | <LI>HTTP_ACCEPT 1061 | <LI>HTTP_CONNECTION 1062 | <LI>HTTP_HOST 1063 | <LI>HTTP_PRAGMA 1064 | <LI>HTTP_REFERER 1065 | <LI>HTTP_USER_AGENT 1066 | </UL> 1067 | """ 1068 | ) 1069 | 1070 | 1071 | # Utilities 1072 | # ========= 1073 | 1074 | 1075 | def escape(s, quote=None): 1076 | """Deprecated API.""" 1077 | warn( 1078 | "cgi.escape is deprecated, use html.escape instead", 1079 | DeprecationWarning, 1080 | stacklevel=2, 1081 | ) 1082 | s = s.replace("&", "&") # Must be done first! 1083 | s = s.replace("<", "<") 1084 | s = s.replace(">", ">") 1085 | if quote: 1086 | s = s.replace('"', """) 1087 | return s 1088 | 1089 | 1090 | def valid_boundary(s): 1091 | import re 1092 | 1093 | if isinstance(s, bytes): 1094 | _vb_pattern = b"^[ -~]{0,200}[!-~]quot; 1095 | else: 1096 | _vb_pattern = "^[ -~]{0,200}[!-~]quot; 1097 | return re.match(_vb_pattern, s) 1098 | 1099 | 1100 | # Invoke mainline 1101 | # =============== 1102 | 1103 | # Call test() when this file is run as a script (not imported as a module) 1104 | if __name__ == "__main__": 1105 | test() 1106 | -------------------------------------------------------------------------------- /ir/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Tiago Barroso 2 | # Copyright 2013 Frank Kmiec 3 | # Copyright 2013-2016 Aleksej 4 | # Copyright 2018 Timothée Chauvin 5 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 6 | # 7 | # Permission to use, copy, modify, and distribute this software for any purpose 8 | # with or without fee is hereby granted, provided that the above copyright 9 | # notice and this permission notice appear in all copies. 10 | # 11 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 12 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 13 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 14 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 15 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 16 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 17 | # PERFORMANCE OF THIS SOFTWARE. 18 | 19 | from typing import Any, Sequence 20 | 21 | from anki.cards import Card 22 | from anki.hooks import addHook, wrap 23 | from aqt import gui_hooks, mw 24 | from aqt.browser import Browser 25 | from aqt.qt import sip 26 | from aqt.reviewer import Reviewer 27 | 28 | from .about import showAbout 29 | from .gui import SettingsDialog 30 | from .importer.importer import Importer 31 | from .schedule import Scheduler 32 | from .settings import SettingsManager 33 | from .text import TextManager 34 | from .util import addMenuItem, isIrCard, loadFile 35 | from .view import ViewManager 36 | 37 | 38 | class ReadingManager: 39 | shortcuts = [] 40 | 41 | def __init__(self): 42 | self.settings = None 43 | self.importer = Importer() 44 | self.scheduler = Scheduler() 45 | self.textManager = TextManager() 46 | self.viewManager = ViewManager() 47 | gui_hooks.profile_did_open.append(self.onProfileLoaded) 48 | gui_hooks.card_will_show.append(self.onPrepareQA) 49 | 50 | addHook("overviewStateShortcuts", self.setShortcuts) 51 | addHook("reviewStateShortcuts", self.setReviewShortcuts) 52 | 53 | def onProfileLoaded(self) -> None: 54 | self.settings = SettingsManager() 55 | mw.addonManager.setConfigAction(__name__, lambda: SettingsDialog(self.settings)) 56 | self.importer.changeProfile(self.settings) 57 | self.scheduler.changeProfile(self.settings) 58 | self.textManager.changeProfile(self.settings) 59 | self.viewManager.changeProfile(self.settings) 60 | self.viewManager.resetZoom("deckBrowser") 61 | self.addModel() 62 | self.loadMenuItems() 63 | self.shortcuts = [ 64 | (self.settings["extractKey"], self.textManager.extract), 65 | (self.settings["highlightKey"], self.textManager.highlight), 66 | (self.settings["removeKey"], self.textManager.remove), 67 | (self.settings["undoKey"], self.textManager.undo), 68 | (self.settings["overlaySeq"], self.textManager.toggleOverlay), 69 | ( 70 | self.settings["boldSeq"], 71 | lambda: self.textManager.format("bold"), 72 | ), 73 | ( 74 | self.settings["italicSeq"], 75 | lambda: self.textManager.format("italic"), 76 | ), 77 | ( 78 | self.settings["strikeSeq"], 79 | lambda: self.textManager.format("strike"), 80 | ), 81 | ( 82 | self.settings["underlineSeq"], 83 | lambda: self.textManager.format("underline"), 84 | ), 85 | ] 86 | 87 | def loadMenuItems(self): 88 | if hasattr(mw, "customMenus") and "Read" in mw.customMenus: 89 | mw.customMenus["Read"].clear() 90 | 91 | addMenuItem( 92 | "Read", 93 | "Options...", 94 | lambda: SettingsDialog(self.settings), 95 | "Alt+1", 96 | ) 97 | addMenuItem("Read", "Organizer...", self.scheduler.showDialog, "Alt+2") 98 | addMenuItem("Read", "Import Webpage", self.importer.importWebpage, "Alt+3") 99 | addMenuItem("Read", "Import Feed", self.importer.importFeed, "Alt+4") 100 | addMenuItem("Read", "Import Pocket", self.importer.importPocket, "Alt+5") 101 | addMenuItem("Read", "Import Epub", self.importer.importEpub, "Alt+6") 102 | addMenuItem("Read", "Zoom In", self.viewManager.zoomIn, "Ctrl++") 103 | addMenuItem("Read", "Zoom Out", self.viewManager.zoomOut, "Ctrl+-") 104 | addMenuItem("Read", "About...", showAbout) 105 | 106 | self.settings.loadMenuItems() 107 | 108 | def onPrepareQA(self, text: str, card: Card, _kind: str) -> str: 109 | if self.settings["prioEnabled"]: 110 | answerShortcuts = ["1", "2", "3", "4"] 111 | else: 112 | answerShortcuts = ["4"] 113 | 114 | activeAnswerShortcuts = [ 115 | next((s for s in mw.stateShortcuts if s.key().toString() == i), None) 116 | for i in answerShortcuts 117 | ] 118 | 119 | if isIrCard(card): 120 | for shortcut in activeAnswerShortcuts: 121 | if shortcut: 122 | mw.stateShortcuts.remove(shortcut) 123 | sip.delete(shortcut) 124 | else: 125 | for shortcut in answerShortcuts: 126 | if not activeAnswerShortcuts[answerShortcuts.index(shortcut)]: 127 | mw.stateShortcuts += mw.applyShortcuts( 128 | [ 129 | ( 130 | shortcut, 131 | lambda: mw.reviewer._answerCard(int(shortcut)), 132 | ) 133 | ] 134 | ) 135 | 136 | return text 137 | 138 | def setShortcuts(self, shortcuts) -> None: 139 | shortcuts.append(("Ctrl+=", self.viewManager.zoomIn)) 140 | 141 | def setReviewShortcuts(self, shortcuts) -> None: 142 | self.setShortcuts(shortcuts) 143 | shortcuts.extend(self.shortcuts) 144 | 145 | def addModel(self) -> None: 146 | if mw.col.models.by_name(self.settings["modelName"]): 147 | return 148 | 149 | model = mw.col.models.new(self.settings["modelName"]) 150 | model["css"] = loadFile("web", "model.css") 151 | 152 | titleField = mw.col.models.new_field(self.settings["titleField"]) 153 | textField = mw.col.models.new_field(self.settings["textField"]) 154 | sourceField = mw.col.models.new_field(self.settings["sourceField"]) 155 | sourceField["sticky"] = True 156 | 157 | mw.col.models.add_field(model, titleField) 158 | if self.settings["prioEnabled"]: 159 | prioField = mw.col.models.new_field(self.settings["prioField"]) 160 | mw.col.models.add_field(model, prioField) 161 | 162 | mw.col.models.add_field(model, textField) 163 | mw.col.models.add_field(model, sourceField) 164 | 165 | template = mw.col.models.new_template("IR Card") 166 | template["qfmt"] = "\n".join( 167 | [ 168 | '<div class="ir-title">{{%s}}</div>' % self.settings["titleField"], 169 | '<div class="ir-text">{{%s}}</div>' % self.settings["textField"], 170 | '<div class="ir-src">{{%s}}</div>' % self.settings["sourceField"], 171 | '<div class="ir-tags">{{Tags}}</div>', 172 | ] 173 | ) 174 | 175 | if self.settings["prioEnabled"]: 176 | template["afmt"] = "Hit space to move to the next article" 177 | else: 178 | template["afmt"] = "When do you want to see this card again?" 179 | 180 | mw.col.models.add_template(model, template) 181 | mw.col.models.add(model) 182 | 183 | 184 | def answerButtonList(self, _old: Any) -> tuple[tuple[int, str], ...]: 185 | if isIrCard(self.card): 186 | if mw.readingManager.settings["prioEnabled"]: 187 | return ((1, "Next"),) 188 | return ((1, "Soon"), (2, "Later"), (3, "Custom")) 189 | 190 | return _old(self) 191 | 192 | 193 | def answerCard(self, ease: int, _old: Any): 194 | card = self.card 195 | _old(self, ease) 196 | if isIrCard(card): 197 | mw.readingManager.scheduler.answer(card, ease) 198 | 199 | 200 | def buttonTime(self, i: int, v3_labels: Sequence[str], _old: Any) -> str: 201 | if isIrCard(mw.reviewer.card): 202 | return "<div class=spacer></div>" 203 | return _old(self, i, v3_labels) 204 | 205 | 206 | def onBrowserClosed(_self) -> None: 207 | try: 208 | mw.readingManager.scheduler._updateListItems() 209 | except: 210 | return 211 | 212 | 213 | Reviewer._answerButtonList = wrap( 214 | Reviewer._answerButtonList, answerButtonList, "around" 215 | ) 216 | Reviewer._answerCard = wrap(Reviewer._answerCard, answerCard, "around") 217 | Reviewer._buttonTime = wrap(Reviewer._buttonTime, buttonTime, "around") 218 | Browser._closeWindow = wrap(Browser._closeWindow, onBrowserClosed) 219 | -------------------------------------------------------------------------------- /ir/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": "999215520", 3 | "name": "Incremental Reading v4.13.0" 4 | } 5 | -------------------------------------------------------------------------------- /ir/schedule.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Tiago Barroso 2 | # Copyright 2013 Frank Kmiec 3 | # Copyright 2013-2016 Aleksej 4 | # Copyright 2017 Christian Weiß 5 | # Copyright 2018 Timothée Chauvin 6 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 7 | # 8 | # Permission to use, copy, modify, and distribute this software for any purpose 9 | # with or without fee is hereby granted, provided that the above copyright 10 | # notice and this permission notice appear in all copies. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 13 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 14 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 15 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 16 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 17 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 18 | # PERFORMANCE OF THIS SOFTWARE. 19 | 20 | from random import gauss, shuffle 21 | from re import sub 22 | 23 | try: 24 | from PyQt6.QtCore import Qt 25 | except ModuleNotFoundError: 26 | from PyQt5.QtCore import Qt 27 | 28 | from anki.cards import Card 29 | from anki.utils import strip_html 30 | from aqt import mw 31 | from aqt.qt import ( 32 | QAbstractItemView, 33 | QDialog, 34 | QDialogButtonBox, 35 | QHBoxLayout, 36 | QListWidget, 37 | QListWidgetItem, 38 | QPushButton, 39 | QVBoxLayout, 40 | ) 41 | from aqt.utils import showInfo, tooltip 42 | 43 | from .settings import SettingsManager 44 | from .util import showBrowser 45 | 46 | SCHEDULE_EXTRACT = 0 47 | SCHEDULE_SOON = 1 48 | SCHEDULE_LATER = 2 49 | SCHEDULE_CUSTOM = 3 50 | 51 | 52 | class Scheduler: 53 | _deckId = None 54 | _cardListWidget = None 55 | _settings: SettingsManager = None 56 | 57 | def changeProfile(self, settings: SettingsManager): 58 | self._settings = settings 59 | 60 | def showDialog(self, currentCard: Card = None): 61 | if currentCard: 62 | self._deckId = currentCard.did 63 | elif mw._selectedDeck(): 64 | self._deckId = mw._selectedDeck()["id"] 65 | else: 66 | return 67 | 68 | if not self._getCardInfo(self._deckId): 69 | showInfo("Please select an Incremental Reading deck.") 70 | return 71 | 72 | dialog = QDialog(mw) 73 | layout = QVBoxLayout() 74 | self._cardListWidget = QListWidget() 75 | self._cardListWidget.setAlternatingRowColors(True) 76 | self._cardListWidget.setSelectionMode( 77 | QAbstractItemView.SelectionMode.ExtendedSelection 78 | ) 79 | self._cardListWidget.setWordWrap(True) 80 | self._cardListWidget.itemDoubleClicked.connect( 81 | lambda: showBrowser( 82 | self._cardListWidget.currentItem().data(Qt.ItemDataRole.UserRole)["nid"] 83 | ) 84 | ) 85 | 86 | self._updateListItems() 87 | 88 | upButton = QPushButton("Up") 89 | upButton.clicked.connect(self._moveUp) 90 | downButton = QPushButton("Down") 91 | downButton.clicked.connect(self._moveDown) 92 | topButton = QPushButton("Top") 93 | topButton.clicked.connect(self._moveToTop) 94 | bottomButton = QPushButton("Bottom") 95 | bottomButton.clicked.connect(self._moveToBottom) 96 | randomizeButton = QPushButton("Randomize") 97 | randomizeButton.clicked.connect(self._randomize) 98 | 99 | controlsLayout = QHBoxLayout() 100 | controlsLayout.addWidget(topButton) 101 | controlsLayout.addWidget(upButton) 102 | controlsLayout.addWidget(downButton) 103 | controlsLayout.addWidget(bottomButton) 104 | controlsLayout.addStretch() 105 | controlsLayout.addWidget(randomizeButton) 106 | 107 | buttonBox = QDialogButtonBox( 108 | QDialogButtonBox.StandardButton.Close | QDialogButtonBox.StandardButton.Save 109 | ) 110 | buttonBox.accepted.connect(dialog.accept) 111 | buttonBox.rejected.connect(dialog.reject) 112 | buttonBox.setOrientation(Qt.Orientation.Horizontal) 113 | 114 | layout.addLayout(controlsLayout) 115 | layout.addWidget(self._cardListWidget) 116 | layout.addWidget(buttonBox) 117 | 118 | dialog.setLayout(layout) 119 | dialog.setWindowModality(Qt.WindowModality.WindowModal) 120 | dialog.resize(500, 500) 121 | choice = dialog.exec() 122 | 123 | if choice == 1: 124 | cids = [] 125 | for i in range(self._cardListWidget.count()): 126 | card = self._cardListWidget.item(i).data(Qt.ItemDataRole.UserRole) 127 | cids.append(card["id"]) 128 | 129 | self.reorder(cids) 130 | 131 | def _updateListItems(self): 132 | cardInfo = self._getCardInfo(self._deckId) 133 | self._cardListWidget.clear() 134 | posWidth = len(str(len(cardInfo) + 1)) 135 | for i, card in enumerate(cardInfo, start=1): 136 | if self._settings["prioEnabled"]: 137 | info = card["priority"] 138 | else: 139 | info = str(i).zfill(posWidth) 140 | title = sub(r"\s+", " ", strip_html(card["title"])) 141 | text = self._settings["organizerFormat"].format(info=info, title=title) 142 | item = QListWidgetItem(text) 143 | item.setData(Qt.ItemDataRole.UserRole, card) 144 | self._cardListWidget.addItem(item) 145 | 146 | def _moveToTop(self): 147 | selected = self._getSelected() 148 | if not selected: 149 | showInfo("Please select one or several items.") 150 | return 151 | 152 | selected.reverse() 153 | for item in selected: 154 | self._cardListWidget.takeItem(self._cardListWidget.row(item)) 155 | self._cardListWidget.insertItem(0, item) 156 | item.setSelected(True) 157 | 158 | self._cardListWidget.scrollToTop() 159 | 160 | def _moveUp(self): 161 | selected = self._getSelected() 162 | if not selected: 163 | showInfo("Please select one or several items.") 164 | return 165 | 166 | if self._cardListWidget.row(selected[0]) == 0: 167 | return 168 | 169 | for item in selected: 170 | row = self._cardListWidget.row(item) 171 | self._cardListWidget.takeItem(row) 172 | self._cardListWidget.insertItem(row - 1, item) 173 | item.setSelected(True) 174 | self._cardListWidget.scrollToItem(item) 175 | 176 | def _moveDown(self): 177 | selected = self._getSelected() 178 | if not selected: 179 | showInfo("Please select one or several items.") 180 | return 181 | 182 | selected.reverse() 183 | 184 | if self._cardListWidget.row(selected[0]) == self._cardListWidget.count() - 1: 185 | return 186 | 187 | for item in selected: 188 | row = self._cardListWidget.row(item) 189 | self._cardListWidget.takeItem(row) 190 | self._cardListWidget.insertItem(row + 1, item) 191 | item.setSelected(True) 192 | self._cardListWidget.scrollToItem(item) 193 | 194 | def _moveToBottom(self): 195 | selected = self._getSelected() 196 | if not selected: 197 | showInfo("Please select one or several items.") 198 | return 199 | 200 | for item in selected: 201 | self._cardListWidget.takeItem(self._cardListWidget.row(item)) 202 | self._cardListWidget.insertItem(self._cardListWidget.count(), item) 203 | item.setSelected(True) 204 | 205 | self._cardListWidget.scrollToBottom() 206 | 207 | def _getSelected(self): 208 | return [ 209 | self._cardListWidget.item(i) 210 | for i in range(self._cardListWidget.count()) 211 | if self._cardListWidget.item(i).isSelected() 212 | ] 213 | 214 | def _randomize(self): 215 | allItems = [ 216 | self._cardListWidget.takeItem(0) 217 | for _ in range(self._cardListWidget.count()) 218 | ] 219 | if self._settings["prioEnabled"]: 220 | maxPrio = len(self._settings["priorities"]) - 1 221 | for item in allItems: 222 | priority = item.data(Qt.ItemDataRole.UserRole)["priority"] 223 | if priority != "": 224 | item.contNewPos = gauss(maxPrio - int(priority), maxPrio / 20) 225 | else: 226 | item.contNewPos = float("inf") 227 | allItems.sort(key=lambda item: item.contNewPos) 228 | 229 | else: 230 | shuffle(allItems) 231 | 232 | for item in allItems: 233 | self._cardListWidget.addItem(item) 234 | 235 | def answer(self, card: Card, ease: int): 236 | if self._settings["prioEnabled"]: 237 | # reposition the card at the end of the organizer 238 | cardCount = len(self._getCardInfo(card.did)) 239 | self.reposition(card, cardCount) 240 | return 241 | 242 | if ease == SCHEDULE_EXTRACT: 243 | value = self._settings["extractValue"] 244 | randomize = self._settings["extractRandom"] 245 | method = self._settings["extractMethod"] 246 | elif ease == SCHEDULE_SOON: 247 | value = self._settings["soonValue"] 248 | randomize = self._settings["soonRandom"] 249 | method = self._settings["soonMethod"] 250 | elif ease == SCHEDULE_LATER: 251 | value = self._settings["laterValue"] 252 | randomize = self._settings["laterRandom"] 253 | method = self._settings["laterMethod"] 254 | elif ease == SCHEDULE_CUSTOM: 255 | self.reposition(card, 1) 256 | self.showDialog(card) 257 | return 258 | 259 | if method == "percent": 260 | totalCards = len([c["id"] for c in self._getCardInfo(card.did)]) 261 | newPos = totalCards * (value / 100) 262 | elif method == "count": 263 | newPos = value 264 | 265 | if randomize: 266 | newPos = gauss(newPos, newPos / 10) 267 | 268 | newPos = max(1, round(newPos)) 269 | self.reposition(card, newPos) 270 | 271 | if ease != SCHEDULE_EXTRACT: 272 | tooltip("Card moved to position {}".format(newPos)) 273 | 274 | def reposition(self, card, newPos): 275 | cids = [c["id"] for c in self._getCardInfo(card.did)] 276 | mw.col.sched.forgetCards(cids) 277 | cids.remove(card.id) 278 | newOrder = cids[: newPos - 1] + [card.id] + cids[newPos - 1 :] 279 | mw.col.sched.reposition_new_cards( 280 | newOrder, 281 | starting_from=1, 282 | step_size=1, 283 | randomize=False, 284 | shift_existing=False, 285 | ) 286 | 287 | def reorder(self, cids): 288 | mw.col.sched.forgetCards(cids) 289 | mw.col.sched.reposition_new_cards( 290 | cids, starting_from=1, step_size=1, randomize=False, shift_existing=False 291 | ) 292 | 293 | def _getCardInfo(self, did): 294 | cardInfo = [] 295 | 296 | for cid, nid in mw.col.db.execute( 297 | "select id, nid from cards where did = ?", did 298 | ): 299 | note = mw.col.get_note(nid) 300 | if note.note_type()["name"] == self._settings["modelName"]: 301 | if self._settings["prioEnabled"]: 302 | prio = note[self._settings["prioField"]] 303 | else: 304 | prio = None 305 | 306 | cardInfo.append( 307 | { 308 | "id": cid, 309 | "nid": nid, 310 | "title": note[self._settings["titleField"]], 311 | "priority": prio, 312 | } 313 | ) 314 | 315 | return cardInfo 316 | -------------------------------------------------------------------------------- /ir/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Christian Weiß 2 | # Copyright 2018 Timothée Chauvin 3 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 4 | # 5 | # Permission to use, copy, modify, and distribute this software for any purpose 6 | # with or without fee is hereby granted, provided that the above copyright 7 | # notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | # PERFORMANCE OF THIS SOFTWARE. 16 | 17 | import json 18 | import os 19 | from functools import partial 20 | 21 | from anki.hooks import addHook 22 | from aqt import mw 23 | from aqt.utils import showInfo 24 | 25 | from ._version import __version__ 26 | from .about import IR_GITHUB_URL 27 | from .util import addMenuItem, setMenuVisibility, updateModificationTime 28 | 29 | 30 | class SettingsManager: 31 | updated = False 32 | requiredFormatKeys = { 33 | "organizerFormat": ["info", "title"], 34 | "sourceFormat": ["url", "date"], 35 | } 36 | doNotUpdate = ["feedLog", "modified", "quickKeys", "scroll", "zoom"] 37 | defaults = { 38 | "badTags": ["iframe", "script"], 39 | "boldSeq": "Ctrl+B", 40 | "copyTitle": False, 41 | "editExtract": False, 42 | "editSource": False, 43 | "extractBgColor": "Green", 44 | "extractDeck": None, 45 | "extractKey": "x", 46 | "extractMethod": "percent", 47 | "extractRandom": True, 48 | "extractTextColor": "White", 49 | "extractValue": 30, 50 | "feedLog": {}, 51 | "generalZoom": 1, 52 | "highlightBgColor": "Yellow", 53 | "highlightKey": "h", 54 | "highlightTextColor": "Black", 55 | "importDeck": None, 56 | "isQuickKey": False, 57 | "italicSeq": "Ctrl+I", 58 | "laterMethod": "percent", 59 | "laterRandom": True, 60 | "laterValue": 50, 61 | "lineScrollFactor": 0.05, 62 | "modelName": "IR3", 63 | "modified": [], 64 | "organizerFormat": "❰ {info} ❱\t{title}", 65 | "overlaySeq": "Ctrl+Shift+O", 66 | "pageScrollFactor": 0.5, 67 | "plainText": False, 68 | "pocketArchive": True, 69 | "prioDefault": "5", 70 | "prioEnabled": False, 71 | "prioField": "Priority", 72 | "priorities": ["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], 73 | "quickKeys": {}, 74 | "removeKey": "z", 75 | "scheduleExtract": True, 76 | "scroll": {}, 77 | "soonMethod": "percent", 78 | "soonRandom": True, 79 | "soonValue": 10, 80 | "sourceField": "Source", 81 | "sourceFormat": "{url} ({date})", 82 | "strikeSeq": "Ctrl+S", 83 | "textField": "Text", 84 | "titleField": "Title", 85 | "underlineSeq": "Ctrl+U", 86 | "undoKey": "u", 87 | "userAgent": f"IR/{__version__} (+{IR_GITHUB_URL})", 88 | "version": __version__, 89 | "zoom": {}, 90 | "zoomStep": 0.1, 91 | } 92 | 93 | def __init__(self): 94 | self.settings = None 95 | 96 | addHook("unloadProfile", self._unload) 97 | self.load() 98 | 99 | def __setitem__(self, key, value): 100 | if self.settings[key] != value and key not in self.settings["modified"]: 101 | self.settings["modified"].append(key) 102 | 103 | self.settings[key] = value 104 | 105 | def __getitem__(self, key): 106 | return self.settings[key] 107 | 108 | def load(self): 109 | if os.path.isfile(self.getSettingsPath()): 110 | self._loadExisting() 111 | else: 112 | self.settings = self.defaults 113 | 114 | if self.updated: 115 | showInfo("Your Incremental Reading settings have been updated.") 116 | 117 | def _loadExisting(self): 118 | with open(self.getSettingsPath(), encoding="utf-8") as jsonFile: 119 | self.settings = json.load(jsonFile) 120 | self._update() 121 | 122 | def getSettingsPath(self): 123 | return os.path.join(self.getMediaDir(), "_ir.json") 124 | 125 | def getMediaDir(self): 126 | return os.path.join(mw.pm.profileFolder(), "collection.media") 127 | 128 | def _update(self): 129 | self.settings["version"] = self.defaults["version"] 130 | self._addMissing() 131 | self._removeOutdated() 132 | self._updateUnmodified() 133 | self._validateFormatStrings() 134 | 135 | def _addMissing(self): 136 | for k, v in self.defaults.items(): 137 | if k not in self.settings: 138 | self.settings[k] = v 139 | self.updated = True 140 | 141 | def _removeOutdated(self): 142 | required = [ 143 | "alt", 144 | "ctrl", 145 | "editExtract", 146 | "editSource", 147 | "extractBgColor", 148 | "extractDeck", 149 | "extractTextColor", 150 | "isQuickKey", 151 | "modelName", 152 | "regularKey", 153 | "shift", 154 | "sourceField", 155 | "tags", 156 | "textField", 157 | ] 158 | 159 | for keyCombo, settings in self.settings["quickKeys"].copy().items(): 160 | for k in required: 161 | if k not in settings: 162 | self.settings["quickKeys"].pop(keyCombo) 163 | self.updated = True 164 | break 165 | 166 | outdated = [k for k in self.settings if k not in self.defaults] 167 | for k in outdated: 168 | self.settings.pop(k) 169 | self.updated = True 170 | 171 | def _updateUnmodified(self): 172 | for k in self.settings: 173 | if k in self.doNotUpdate: 174 | continue 175 | 176 | if k in self.settings["modified"]: 177 | continue 178 | 179 | if self.settings[k] == self.defaults[k]: 180 | continue 181 | 182 | self.settings[k] = self.defaults[k] 183 | self.updated = True 184 | 185 | def _validateFormatStrings(self): 186 | for name in self.requiredFormatKeys: 187 | if not self.validFormat(name, self.settings[name]): 188 | self.settings[name] = self.defaults[name] 189 | 190 | def validFormat(self, name, fmt): 191 | for k in self.requiredFormatKeys[name]: 192 | if fmt.find("{%s}" % k) == -1: 193 | return False 194 | return True 195 | 196 | def _unload(self): 197 | for menu in mw.customMenus.values(): 198 | mw.form.menubar.removeAction(menu.menuAction()) 199 | 200 | mw.customMenus.clear() 201 | self.save() 202 | 203 | def save(self): 204 | with open(self.getSettingsPath(), "w", encoding="utf-8") as jsonFile: 205 | json.dump(self.settings, jsonFile) 206 | 207 | updateModificationTime(self.getMediaDir()) 208 | 209 | def loadMenuItems(self): 210 | path = "Read::Quick Keys" 211 | 212 | if path in mw.customMenus: 213 | mw.customMenus[path].clear() 214 | 215 | for keyCombo, settings in self.settings["quickKeys"].items(): 216 | text = f"Add Card [{settings['modelName']} -> {settings['extractDeck']}]" 217 | func = partial(mw.readingManager.textManager.extract, settings) 218 | addMenuItem(path, text, func, keyCombo) 219 | 220 | setMenuVisibility(path) 221 | -------------------------------------------------------------------------------- /ir/text.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Tiago Barroso 2 | # Copyright 2013 Frank Kmiec 3 | # Copyright 2013-2016 Aleksej 4 | # Copyright 2017 Christian Weiß 5 | # Copyright 2018 Timothée Chauvin 6 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 7 | # 8 | # Permission to use, copy, modify, and distribute this software for any purpose 9 | # with or without fee is hereby granted, provided that the above copyright 10 | # notice and this permission notice appear in all copies. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 13 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 14 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 15 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 16 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 17 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 18 | # PERFORMANCE OF THIS SOFTWARE. 19 | 20 | from collections import defaultdict 21 | 22 | from anki.decks import DeckId 23 | from anki.notes import Note 24 | from aqt import mw 25 | from aqt.addcards import AddCards 26 | from aqt.editcurrent import EditCurrent 27 | from aqt.utils import getText, showInfo, showWarning, tooltip 28 | 29 | from .settings import SettingsManager 30 | from .util import fixImages, getField, setField 31 | 32 | SCHEDULE_EXTRACT = 0 33 | 34 | 35 | class TextManager: 36 | _history = defaultdict(list) 37 | _settings: SettingsManager = None 38 | 39 | def changeProfile(self, settings: SettingsManager): 40 | self._settings = settings 41 | 42 | def highlight(self, bgColor=None, textColor=None): 43 | if not bgColor: 44 | bgColor = self._settings["highlightBgColor"] 45 | if not textColor: 46 | textColor = self._settings["highlightTextColor"] 47 | 48 | script = f"highlight('{bgColor}', '{textColor}')" 49 | mw.web.eval(script) 50 | self.save() 51 | 52 | def format(self, style): 53 | mw.web.eval(f"format('{style}')") 54 | self.save() 55 | 56 | def toggleOverlay(self): 57 | mw.web.eval("toggleOverlay()") 58 | self.save() 59 | 60 | def extract(self, settings=None): 61 | if mw.state != "review": 62 | return 63 | 64 | if not settings: 65 | settings = self._settings 66 | 67 | if not mw.web.selectedText() and not settings["editExtract"]: 68 | showInfo("Please select some text to extract.") 69 | return 70 | 71 | if settings["plainText"]: 72 | mw.web.evalWithCallback( 73 | "getPlainText()", lambda text: self.create(text, settings) 74 | ) 75 | else: 76 | mw.web.evalWithCallback( 77 | "getHtmlText()", lambda text: self.create(text, settings) 78 | ) 79 | 80 | def create(self, text, settings): 81 | currentCard = mw.reviewer.card 82 | currentNote = currentCard.note() 83 | model = mw.col.models.by_name(settings["modelName"]) 84 | newNote = Note(mw.col, model) 85 | newNote.tags = currentNote.tags 86 | setField(newNote, settings["textField"], fixImages(text)) 87 | 88 | if settings["extractDeck"]: 89 | deck = mw.col.decks.by_name(settings["extractDeck"]) 90 | if not deck: 91 | showWarning( 92 | "Destination deck no longer exists. Please update your settings." 93 | ) 94 | return 95 | did = deck["id"] 96 | else: 97 | did = currentCard.did 98 | 99 | if settings["isQuickKey"]: 100 | newNote.tags += settings["tags"] 101 | 102 | if settings["sourceField"]: 103 | setField( 104 | newNote, 105 | settings["sourceField"], 106 | getField(currentNote, self._settings["sourceField"]), 107 | ) 108 | 109 | if settings["editExtract"]: 110 | highlight = self._editExtract(newNote, did, settings) 111 | else: 112 | highlight = True 113 | newNote.note_type()["did"] = did 114 | mw.col.addNote(newNote) 115 | else: 116 | if settings["copyTitle"]: 117 | title = getField(currentNote, settings["titleField"]) 118 | else: 119 | title = "" 120 | 121 | setField( 122 | newNote, 123 | settings["sourceField"], 124 | getField(currentNote, settings["sourceField"]), 125 | ) 126 | if settings["prioEnabled"]: 127 | setField( 128 | newNote, 129 | settings["prioField"], 130 | getField(currentNote, settings["prioField"]), 131 | ) 132 | 133 | if settings["editExtract"]: 134 | setField(newNote, settings["titleField"], title) 135 | highlight = self._editExtract(newNote, did, settings) 136 | else: 137 | highlight = self._getTitle(newNote, did, title, settings) 138 | 139 | if settings["scheduleExtract"] and not settings["prioEnabled"]: 140 | cards = newNote.cards() 141 | if cards: 142 | mw.readingManager.scheduler.answer(cards[0], SCHEDULE_EXTRACT) 143 | 144 | if highlight: 145 | self.highlight(settings["extractBgColor"], settings["extractTextColor"]) 146 | 147 | if settings["editSource"]: 148 | EditCurrent(mw) 149 | 150 | def _editExtract(self, note: Note, deckId: DeckId, settings: SettingsManager): 151 | def onAdd(): 152 | self.highlight(settings["extractBgColor"], settings["extractTextColor"]) 153 | 154 | addCards = AddCards(mw) 155 | addCards.set_note(note, deckId) 156 | addCards.addButton.clicked.connect(onAdd) 157 | 158 | # Do not highlight immediately, but only after the card is added 159 | return False 160 | 161 | def _getTitle(self, note, did, title, settings): 162 | title, accepted = getText("Enter title", title="Extract Text", default=title) 163 | 164 | if accepted: 165 | setField(note, settings["titleField"], title) 166 | note.note_type()["did"] = did 167 | mw.col.addNote(note) 168 | 169 | return accepted 170 | 171 | def remove(self): 172 | mw.web.eval("removeText()") 173 | self.save() 174 | 175 | def undo(self): 176 | note = mw.reviewer.card.note() 177 | 178 | if note.id not in self._history or not self._history[note.id]: 179 | showInfo("No undo history for this note.") 180 | return 181 | 182 | note["Text"] = self._history[note.id].pop() 183 | # note.flush() 184 | mw.col.update_note(note) 185 | mw.reset() 186 | tooltip("Undone") 187 | 188 | def save(self): 189 | def callback(text): 190 | if text: 191 | note = mw.reviewer.card.note() 192 | self._history[note.id].append(note["Text"]) 193 | note["Text"] = text 194 | # note.flush() 195 | mw.col.update_note(note) 196 | 197 | mw.web.evalWithCallback( 198 | 'document.getElementsByClassName("ir-text")[0].innerHTML;', 199 | callback, 200 | ) 201 | -------------------------------------------------------------------------------- /ir/util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 2 | # 3 | # Permission to use, copy, modify, and distribute this software for any purpose 4 | # with or without fee is hereby granted, provided that the above copyright 5 | # notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | # PERFORMANCE OF THIS SOFTWARE. 14 | 15 | import os 16 | import re 17 | import stat 18 | import time 19 | from dataclasses import dataclass 20 | from typing import Any, List 21 | from urllib.parse import unquote 22 | 23 | from anki.cards import Card 24 | 25 | try: 26 | from PyQt6.QtCore import Qt 27 | from PyQt6.QtGui import QKeySequence 28 | except ModuleNotFoundError: 29 | from PyQt5.QtCore import Qt 30 | from PyQt5.QtGui import QKeySequence 31 | 32 | from aqt import dialogs, mw 33 | from aqt.qt import ( 34 | QAbstractItemView, 35 | QAction, 36 | QDialog, 37 | QDialogButtonBox, 38 | QLabel, 39 | QListWidget, 40 | QListWidgetItem, 41 | QMenu, 42 | QSpinBox, 43 | QVBoxLayout, 44 | ) 45 | from bs4 import BeautifulSoup 46 | 47 | 48 | @dataclass 49 | class Article: 50 | title: str 51 | data: Any 52 | 53 | 54 | def isIrCard(card: Card) -> bool: 55 | return card and ( 56 | card.note_type()["name"] == mw.readingManager.settings["modelName"] 57 | ) 58 | 59 | 60 | def viewingIrText(): 61 | return ( 62 | isIrCard(mw.reviewer.card) 63 | and (mw.reviewer.state == "question") 64 | and (mw.state == "review") 65 | ) 66 | 67 | 68 | def addMenu(fullPath: str): 69 | # FIXME: Subpath doesn't work as quick keys don't show up 70 | if not hasattr(mw, "customMenus"): 71 | mw.customMenus = {} 72 | 73 | if len(fullPath.split("::")) == 2: 74 | menuPath, submenuPath = fullPath.split("::") 75 | hasSubmenu = True 76 | else: 77 | menuPath = fullPath 78 | hasSubmenu = False 79 | 80 | if menuPath not in mw.customMenus: 81 | menu = QMenu("&" + menuPath, mw) 82 | mw.customMenus[menuPath] = menu 83 | mw.form.menubar.insertMenu(mw.form.menuTools.menuAction(), menu) 84 | 85 | if hasSubmenu and (fullPath not in mw.customMenus): 86 | submenu = QMenu("&" + submenuPath, mw) 87 | mw.customMenus[fullPath] = submenu 88 | mw.customMenus[menuPath].addMenu(submenu) 89 | 90 | 91 | def setMenuVisibility(path): 92 | if path not in mw.customMenus: 93 | return 94 | 95 | if mw.customMenus[path].isEmpty(): 96 | mw.customMenus[path].menuAction().setVisible(False) 97 | else: 98 | mw.customMenus[path].menuAction().setVisible(True) 99 | 100 | 101 | def addMenuItem(path: str, text: str, function, keys=None): 102 | action = QAction(text, mw) 103 | 104 | if keys: 105 | action.setShortcut(QKeySequence(keys)) 106 | 107 | # Override surprising behavior in OSX 108 | # https://doc.qt.io/qt-6/qmenubar.html#qmenubar-as-a-global-menu-bar 109 | if _hasSpecialOsxMenuKeywords(text): 110 | action.setMenuRole(QAction.MenuRole.NoRole) 111 | 112 | action.triggered.connect(function) 113 | 114 | menu = None 115 | if path == "File": 116 | menu = mw.form.menuCol 117 | elif path == "Edit": 118 | menu = mw.form.menuEdit 119 | elif path == "Tools": 120 | menu = mw.form.menuTools 121 | elif path == "Help": 122 | menu = mw.form.menuHelp 123 | else: 124 | addMenu(path) 125 | menu = mw.customMenus[path] 126 | 127 | menu.addAction(action) 128 | 129 | 130 | def _hasSpecialOsxMenuKeywords(text: str): 131 | """Checks if a string contains any of the specified keywords. 132 | Args: 133 | text: The string to check. 134 | 135 | Returns: 136 | True if any keyword is found, False otherwise. 137 | """ 138 | keywords = r"about|config|options|setup|settings|preferences|quit|exit" 139 | return bool(re.search(keywords, text, re.IGNORECASE)) 140 | 141 | 142 | def getField(note, fieldName): 143 | model = note.note_type() 144 | index, _ = mw.col.models.field_map(model)[fieldName] 145 | return note.fields[index] 146 | 147 | 148 | def setField(note, field, value): 149 | """Set the value of a note field. Overwrite any existing value.""" 150 | model = note.note_type() 151 | index, _ = mw.col.models.field_map(model)[field] 152 | note.fields[index] = value 153 | 154 | 155 | def getFieldNames(modelName): 156 | """Return list of field names for given model name.""" 157 | if not modelName: 158 | return [] 159 | return mw.col.models.field_names(mw.col.models.by_name(modelName)) 160 | 161 | 162 | def createSpinBox(value, minimum, maximum, step): 163 | spinBox = QSpinBox() 164 | spinBox.setRange(minimum, maximum) 165 | spinBox.setSingleStep(step) 166 | spinBox.setValue(value) 167 | return spinBox 168 | 169 | 170 | def setComboBoxItem(comboBox, text): 171 | index = comboBox.findText(text, Qt.MatchFlag.MatchFixedString) 172 | comboBox.setCurrentIndex(index) 173 | 174 | 175 | def removeComboBoxItem(comboBox, text): 176 | index = comboBox.findText(text, Qt.MatchFlag.MatchFixedString) 177 | comboBox.removeItem(index) 178 | 179 | 180 | def updateModificationTime(path): 181 | accessTime = os.stat(path)[stat.ST_ATIME] 182 | modificationTime = time.time() 183 | os.utime(path, (accessTime, modificationTime)) 184 | 185 | 186 | def fixImages(html): 187 | if not html: 188 | return "" 189 | soup = BeautifulSoup(html, "html.parser") 190 | for img in soup.find_all("img"): 191 | img["src"] = os.path.basename(unquote(img["src"])) 192 | return str(soup) 193 | 194 | 195 | def loadFile(fileDir, filename): 196 | moduleDir, _ = os.path.split(__file__) 197 | path = os.path.join(moduleDir, fileDir, filename) 198 | with open(path, encoding="utf-8") as f: 199 | return f.read() 200 | 201 | 202 | def getColorList(): 203 | moduleDir, _ = os.path.split(__file__) 204 | colorsFilePath = os.path.join(moduleDir, "data", "colors.u8") 205 | with open(colorsFilePath, encoding="utf-8") as colorsFile: 206 | return [line.strip() for line in colorsFile] 207 | 208 | 209 | def showBrowser(nid): 210 | browser = dialogs.open("Browser", mw) 211 | browser.form.searchEdit.lineEdit().setText("nid:" + str(nid)) 212 | browser.onSearchActivated() 213 | 214 | 215 | def selectArticles(articles: List[Article]) -> List[Article]: 216 | """Select which articles to import using a dialog. 217 | 218 | Args: 219 | choices: List of Article objects to select from 220 | 221 | Returns: 222 | List of selected articles 223 | """ 224 | if not articles: 225 | return [] 226 | 227 | dialog = QDialog(mw) 228 | layout = QVBoxLayout() 229 | 230 | textWidget = QLabel() 231 | textWidget.setText("Select articles to import: ") 232 | 233 | listWidget = QListWidget() 234 | listWidget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) 235 | 236 | for article in articles: 237 | item = QListWidgetItem(article.title) 238 | item.setData(Qt.ItemDataRole.UserRole, article) 239 | listWidget.addItem(item) 240 | 241 | buttonBox = QDialogButtonBox( 242 | QDialogButtonBox.StandardButton.Close | QDialogButtonBox.StandardButton.SaveAll 243 | ) 244 | buttonBox.accepted.connect(dialog.accept) 245 | buttonBox.rejected.connect(dialog.reject) 246 | buttonBox.setOrientation(Qt.Orientation.Horizontal) 247 | 248 | layout.addWidget(textWidget) 249 | layout.addWidget(listWidget) 250 | layout.addWidget(buttonBox) 251 | 252 | dialog.setLayout(layout) 253 | dialog.setWindowModality(Qt.WindowModality.WindowModal) 254 | dialog.resize(500, 500) 255 | choice = dialog.exec() 256 | 257 | if choice == 1: 258 | res = [ 259 | item.data(Qt.ItemDataRole.UserRole) for item in listWidget.selectedItems() 260 | ] 261 | return res 262 | return [] 263 | -------------------------------------------------------------------------------- /ir/view.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Tiago Barroso 2 | # Copyright 2013 Frank Kmiec 3 | # Copyright 2013-2016 Aleksej 4 | # Copyright 2017-2019 Joseph Lorimer <joseph@lorimer.me> 5 | # 6 | # Permission to use, copy, modify, and distribute this software for any purpose 7 | # with or without fee is hereby granted, provided that the above copyright 8 | # notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 12 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 14 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 15 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 16 | # PERFORMANCE OF THIS SOFTWARE. 17 | import re 18 | 19 | from anki.cards import Card 20 | from aqt import gui_hooks, mw 21 | 22 | from .settings import SettingsManager 23 | from .util import isIrCard, loadFile, viewingIrText 24 | 25 | 26 | class ViewManager: 27 | _settings: SettingsManager = None 28 | 29 | def __init__(self): 30 | self._scrollScript = loadFile("web", "scroll.js") 31 | self._textScript = loadFile("web", "text.js") 32 | self._tocScript = loadFile("web", "table_of_contents.js") 33 | self._zoomFactor = 1 34 | 35 | gui_hooks.state_did_change.append(self.resetZoom) 36 | gui_hooks.card_will_show.append(self._prepareCard) 37 | mw.web.page().scrollPositionChanged.connect(self._saveScroll) 38 | 39 | def changeProfile(self, settings: SettingsManager): 40 | self._settings = settings 41 | 42 | def resetZoom(self, state, *_args): 43 | if not self._settings: 44 | return 45 | 46 | if state in ["deckBrowser", "overview"]: 47 | mw.web.setZoomFactor(self._settings["generalZoom"]) 48 | elif state == "review" and not isIrCard(mw.reviewer.card): 49 | self._setZoom(self._zoomFactor) 50 | 51 | def zoomIn(self): 52 | if viewingIrText(): 53 | cid = str(mw.reviewer.card.id) 54 | 55 | if cid not in self._settings["zoom"]: 56 | self._settings["zoom"][cid] = 1 57 | 58 | self._settings["zoom"][cid] += self._settings["zoomStep"] 59 | mw.web.setZoomFactor(self._settings["zoom"][cid]) 60 | elif mw.state == "review": 61 | self._zoomFactor += self._settings["zoomStep"] 62 | mw.web.setZoomFactor(self._zoomFactor) 63 | else: 64 | self._settings["generalZoom"] += self._settings["zoomStep"] 65 | mw.web.setZoomFactor(self._settings["generalZoom"]) 66 | 67 | def zoomOut(self): 68 | if viewingIrText(): 69 | cid = str(mw.reviewer.card.id) 70 | 71 | if cid not in self._settings["zoom"]: 72 | self._settings["zoom"][cid] = 1 73 | 74 | self._settings["zoom"][cid] -= self._settings["zoomStep"] 75 | mw.web.setZoomFactor(self._settings["zoom"][cid]) 76 | elif mw.state == "review": 77 | self._zoomFactor -= self._settings["zoomStep"] 78 | mw.web.setZoomFactor(self._zoomFactor) 79 | else: 80 | self._settings["generalZoom"] -= self._settings["zoomStep"] 81 | mw.web.setZoomFactor(self._settings["generalZoom"]) 82 | 83 | def _setZoom(self, factor=None): 84 | if factor: 85 | mw.web.setZoomFactor(factor) 86 | else: 87 | mw.web.setZoomFactor(self._settings["zoom"][str(mw.reviewer.card.id)]) 88 | 89 | def _prepareCard(self, html: str, card: Card, context: str) -> str: 90 | js = "" 91 | 92 | # For available contexts, see: https://addon-docs.ankiweb.net/reviewer-javascript.html 93 | if isIrCard(card) and context.endswith("Question"): 94 | cid = str(card.id) 95 | 96 | if cid not in self._settings["zoom"]: 97 | self._settings["zoom"][cid] = 1 98 | 99 | if cid not in self._settings["scroll"]: 100 | self._settings["scroll"][cid] = 0 101 | 102 | self._setZoom() 103 | js += self._textScript 104 | js += self._tocScript 105 | 106 | js += f""" 107 | SAVED_POSITION = {self._settings["scroll"][cid]}; 108 | LINE_SCROLL_FACTOR = {self._settings["lineScrollFactor"]}; 109 | PAGE_SCROLL_FACTOR = {self._settings["pageScrollFactor"]}; 110 | """ 111 | js += self._scrollScript 112 | 113 | if js: 114 | html += "<script id='ir-script'>" + js + "</script>" 115 | 116 | return html 117 | 118 | def _saveScroll(self, _event=None): 119 | if viewingIrText() and mw.reviewer.card is not None: 120 | 121 | def callback(currentPos): 122 | self._settings["scroll"][str(mw.reviewer.card.id)] = currentPos 123 | 124 | mw.web.evalWithCallback("window.pageYOffset;", callback) 125 | -------------------------------------------------------------------------------- /ir/web/model.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Christian Weiß 3 | * Copyright 2017-2018 Joseph Lorimer <joseph@lorimer.me> 4 | * 5 | * Permission to use, copy, modify, and distribute this software for any 6 | * purpose with or without fee is hereby granted, provided that the above 7 | * copyright notice and this permission notice appear in all copies. 8 | * 9 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 14 | * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | */ 17 | 18 | div { 19 | margin: 20px auto; 20 | } 21 | 22 | pre { 23 | background: #f4f4f4; 24 | border: 1px solid #ddd; 25 | border-left: 3px solid #f36d33; 26 | color: #666; 27 | page-break-inside: avoid; 28 | font-family: monospace; 29 | font-size: 15px; 30 | line-height: 1.6; 31 | margin-bottom: 1.6em; 32 | max-width: 100%; 33 | overflow: auto; 34 | padding: 1em 1.5em; 35 | display: block; 36 | word-wrap: break-word; 37 | } 38 | 39 | .card { 40 | font-family: Georgia; 41 | margin-left: 10%; 42 | margin-right: 10%; 43 | max-width: 40em; 44 | text-align: left; 45 | font-size: 20px; 46 | line-height: 1.6; 47 | background-color: white; 48 | } 49 | 50 | [ir-overlay~="on"].ir-highlight.bold { font-weight: bold; } 51 | [ir-overlay~="on"].ir-highlight.italic { font-style: italic; } 52 | [ir-overlay~="on"].ir-highlight.strike { text-decoration: line-through; } 53 | [ir-overlay~="on"].ir-highlight.underline { text-decoration: underline; } 54 | 55 | .ir-title { 56 | background-color: #333; 57 | border-radius: 10px; 58 | border-style: ridge; 59 | color: white; 60 | padding: 5px; 61 | text-align: center; 62 | } 63 | 64 | .ir-src { 65 | color: grey; 66 | font-style: italic; 67 | font-size: 16px; 68 | text-align: center; 69 | } 70 | 71 | .ir-tags { 72 | box-shadow: inset 0px 2px 0px 0px #ffffff; 73 | background: -webkit-gradient(linear, 74 | left top, 75 | left bottom, 76 | color-stop(0.05, #f9f9f9), 77 | color-stop(1, #e9e9e9)); 78 | background-color: white; 79 | color: black; 80 | border-radius: 4px; 81 | border: 1px solid #dcdcdc; 82 | display: inline-block; 83 | font-size: 14px; 84 | height: 12px; 85 | line-height: 14px; 86 | padding: 2px 4px; 87 | margin: 3px; 88 | text-align: center; 89 | text-shadow: 1px 1px 0px #ffffff; 90 | } 91 | -------------------------------------------------------------------------------- /ir/web/scroll.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Joseph Lorimer <joseph@lorimer.me> 3 | * 4 | * Permission to use, copy, modify, and distribute this software for any 5 | * purpose with or without fee is hereby granted, provided that the above 6 | * copyright notice and this permission notice appear in all copies. 7 | * 8 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 13 | * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 14 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | */ 16 | 17 | // Parameters are set in the Python code 18 | 19 | function restoreScroll() { 20 | window.scrollTo(0, SAVED_POSITION); 21 | } 22 | 23 | function getMovementFactor(keyCode) { 24 | switch (keyCode) { 25 | case "ArrowUp": 26 | return -LINE_SCROLL_FACTOR; 27 | case "ArrowDown": 28 | return LINE_SCROLL_FACTOR; 29 | case "PageUp": 30 | return -PAGE_SCROLL_FACTOR; 31 | case "PageDown": 32 | return PAGE_SCROLL_FACTOR; 33 | default: 34 | return 0; 35 | } 36 | } 37 | 38 | document.addEventListener("keydown", (e) => { 39 | if (["ArrowUp", "ArrowDown", "PageUp", "PageDown"].includes(e.code)) { 40 | let currentPos = window.pageYOffset; 41 | 42 | let movementSize = window.innerHeight * getMovementFactor(e.code); 43 | let newPos = currentPos + movementSize; 44 | newPos = Math.max(newPos, 0); 45 | newPos = Math.min(newPos, document.body.scrollHeight); 46 | 47 | window.scrollTo(0, newPos); 48 | 49 | e.preventDefault(); 50 | } 51 | }); 52 | 53 | onUpdateHook.push(restoreScroll); 54 | -------------------------------------------------------------------------------- /ir/web/table_of_contents.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Vy Hong <contact@vyhong.com> 3 | * 4 | * Permission to use, copy, modify, and distribute this software for any 5 | * purpose with or without fee is hereby granted, provided that the above 6 | * copyright notice and this permission notice appear in all copies. 7 | * 8 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 13 | * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 14 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | */ 16 | 17 | function createTableOfContents() { 18 | addTocStyles(); 19 | 20 | // Use setTimeout to ensure DOM is fully loaded 21 | setTimeout(() => { 22 | removeTocContainerElement(); 23 | 24 | const tocItems = parseHeadings(); 25 | addTocContainerElement(tocItems); 26 | makeTocDraggable(); 27 | }, 500); 28 | } 29 | 30 | function addTocStyles() { 31 | const style = document.createElement('style'); 32 | style.textContent = ` 33 | .ir-toc-container { 34 | position: fixed; 35 | top: 20px; 36 | right: 20px; 37 | width: 250px; 38 | max-height: 80vh; 39 | margin: auto; /* Needed so ToC container doesn't drop when dragged */ 40 | background-color: #fff; 41 | border: 1px solid #ddd; 42 | border-radius: 5px; 43 | box-shadow: 0 2px 5px rgba(0,0,0,0.1); 44 | z-index: 1000; 45 | overflow-y: auto; 46 | font-size: 14px; 47 | opacity: 0.5; 48 | transition: opacity 0.3s; 49 | } 50 | 51 | .ir-toc-container:hover { 52 | opacity: 1; 53 | } 54 | 55 | .ir-toc-header { 56 | margin: auto; 57 | padding-left: 15px; 58 | padding-right: 10px; 59 | background-color: #f5f5f5; 60 | border-bottom: 1px solid #ddd; 61 | display: flex; 62 | font-size: 16px; 63 | justify-content: space-between; 64 | align-items: center; 65 | cursor: move; 66 | font-weight: bold; 67 | } 68 | 69 | #ir-toc-toggle { 70 | background: none; 71 | border: none; 72 | font-size: 16px; 73 | cursor: pointer; 74 | width: 20px; 75 | height: 20px; 76 | padding: 0; 77 | line-height: 1; 78 | } 79 | 80 | .ir-toc-content { 81 | margin: auto; 82 | padding: 10px; 83 | } 84 | 85 | .ir-toc-list { 86 | list-style-type: none; 87 | padding: 0; 88 | margin: 0; 89 | } 90 | 91 | .ir-toc-item { 92 | margin: 5px 0; 93 | } 94 | 95 | .ir-toc-item a { 96 | text-decoration: none; 97 | color: #333; 98 | display: block; 99 | padding: 2px 0; 100 | } 101 | 102 | .ir-toc-item a:hover { 103 | color: #3498db; 104 | } 105 | 106 | .ir-toc-level-h1 { margin-left: 0; } 107 | .ir-toc-level-h2 { margin-left: 10px; } 108 | .ir-toc-level-h3 { margin-left: 20px; } 109 | .ir-toc-level-h4 { margin-left: 30px; } 110 | .ir-toc-level-h5 { margin-left: 40px; } 111 | .ir-toc-level-h6 { margin-left: 50px; } 112 | `; 113 | 114 | document.head.appendChild(style); 115 | } 116 | 117 | function parseHeadings() { 118 | const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); 119 | 120 | if (headings.length === 0) { 121 | return null; 122 | } 123 | 124 | const tocItems = []; 125 | headings.forEach((heading, index) => { 126 | // Add IDs if doesn't exist, so we can link to it 127 | if (!heading.id) { 128 | heading.id = 'toc-heading-' + index; 129 | } 130 | 131 | tocItems.push({ 132 | id: heading.id, 133 | text: heading.textContent, 134 | level: heading.tagName.toLowerCase() 135 | }); 136 | }); 137 | 138 | return tocItems; 139 | } 140 | 141 | function removeTocContainerElement() { 142 | const tocContainer = document.getElementById('ir-toc-container'); 143 | if (tocContainer) { 144 | tocContainer.remove(); 145 | } 146 | } 147 | 148 | function addTocContainerElement(tocItems) { 149 | if (!tocItems) { 150 | return false; 151 | } 152 | 153 | const tocContainer = createTocContainerElement(); 154 | 155 | const tocHeader = createTocHeaderElement(); 156 | tocContainer.appendChild(tocHeader); 157 | 158 | const tocContent = createTocContentElement(tocItems); 159 | tocContainer.appendChild(tocContent); 160 | 161 | document.body.appendChild(tocContainer); 162 | 163 | return true; 164 | } 165 | 166 | function createTocContainerElement() { 167 | const container = document.createElement('div'); 168 | container.id = 'ir-toc-container'; 169 | container.className = 'ir-toc-container'; 170 | return container; 171 | } 172 | 173 | function createTocHeaderElement() { 174 | const header = document.createElement('div'); 175 | header.className = 'ir-toc-header'; 176 | header.innerHTML = '<span>TOC</span><button id="ir-toc-toggle">−</button>'; 177 | 178 | const toggleButton = header.querySelector('#ir-toc-toggle'); 179 | toggleButton.addEventListener('click', function() { 180 | const tocContent = document.getElementById('ir-toc-content'); 181 | const isVisible = tocContent.style.display !== 'none'; 182 | 183 | if (isVisible) { 184 | tocContent.style.display = 'none'; 185 | this.textContent = '+'; 186 | } else { 187 | tocContent.style.display = 'block'; 188 | this.textContent = '−'; 189 | } 190 | }); 191 | 192 | 193 | return header; 194 | } 195 | 196 | function createTocContentElement(tocItems) { 197 | const tocContent = document.createElement('div'); 198 | tocContent.id = 'ir-toc-content'; 199 | tocContent.className = 'ir-toc-content'; 200 | 201 | const tocList = document.createElement('ul'); 202 | tocList.className = 'ir-toc-list'; 203 | 204 | tocItems.forEach(item => { 205 | const tocItem = document.createElement('li'); 206 | tocItem.className = 'ir-toc-item ir-toc-level-' + item.level; 207 | 208 | tocItem.appendChild(createItemLinkElement(item)); 209 | tocList.appendChild(tocItem); 210 | }); 211 | 212 | tocContent.appendChild(tocList); 213 | return tocContent; 214 | } 215 | 216 | function createItemLinkElement(item) { 217 | const link = document.createElement('a'); 218 | link.href = '#' + item.id; 219 | link.textContent = item.text; 220 | link.addEventListener('click', function(e) { 221 | e.preventDefault(); 222 | 223 | // Scroll to heading with a slight offset 224 | const targetHeading = document.getElementById(item.id); 225 | const topOffset = targetHeading.getBoundingClientRect().top + window.scrollY - 20; 226 | window.scrollTo({ 227 | top: topOffset, 228 | behavior: 'smooth' 229 | }); 230 | }); 231 | 232 | return link; 233 | } 234 | 235 | function makeTocDraggable() { 236 | const tocContainer = document.getElementById('ir-toc-container'); 237 | const tocHeader = document.querySelector('.ir-toc-header'); 238 | 239 | let isDragging = false; 240 | let initialX, initialY, initialMouseX, initialMouseY; 241 | 242 | tocHeader.addEventListener('mousedown', function(e) { 243 | e.preventDefault(); // Prevent text selection during drag 244 | isDragging = true; 245 | 246 | initialX = tocContainer.offsetLeft; 247 | initialY = tocContainer.offsetTop; 248 | initialMouseX = e.clientX; 249 | initialMouseY = e.clientY; 250 | }); 251 | 252 | document.addEventListener('mousemove', function(e) { 253 | if (!isDragging) return; 254 | 255 | e.preventDefault(); 256 | 257 | const deltaX = e.clientX - initialMouseX; 258 | const deltaY = e.clientY - initialMouseY; 259 | 260 | tocContainer.style.left = (initialX + deltaX) + 'px'; 261 | tocContainer.style.top = (initialY + deltaY) + 'px'; 262 | tocContainer.style.right = 'auto'; 263 | }); 264 | 265 | document.addEventListener('mouseup', function(e) { 266 | isDragging = false; 267 | }); 268 | } 269 | 270 | // Hook into Anki's onUpdateHook (https://addon-docs.ankiweb.net/reviewer-javascript.html) 271 | onUpdateHook.push(createTableOfContents); 272 | -------------------------------------------------------------------------------- /ir/web/text.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 Tiago Barroso 3 | * Copyright 2013 Frank Kmiec 4 | * Copyright 2017-2018 Joseph Lorimer <joseph@lorimer.me> 5 | * 6 | * Permission to use, copy, modify, and distribute this software for any 7 | * purpose with or without fee is hereby granted, provided that the above 8 | * copyright notice and this permission notice appear in all copies. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 13 | * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 15 | * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 16 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | */ 18 | 19 | function highlight(bgColor, textColor) { 20 | if (window.getSelection) { 21 | var range, sel = window.getSelection(); 22 | 23 | if (sel.rangeCount && sel.getRangeAt) { 24 | range = sel.getRangeAt(0); 25 | } 26 | 27 | document.designMode = "on"; 28 | if (range) { 29 | sel.removeAllRanges(); 30 | sel.addRange(range); 31 | } 32 | 33 | document.execCommand("foreColor", false, textColor); 34 | document.execCommand("hiliteColor", false, bgColor); 35 | 36 | document.designMode = "off"; 37 | sel.removeAllRanges(); 38 | } 39 | } 40 | 41 | 42 | function format(style) { 43 | var selection = window.getSelection().getRangeAt(0); 44 | var selectedText = selection.extractContents(); 45 | var span = document.createElement("span"); 46 | 47 | span.className = "ir-highlight " + style; 48 | span.setAttribute("ir-overlay", "on"); 49 | span.appendChild(selectedText); 50 | 51 | selection.insertNode(span); 52 | } 53 | 54 | 55 | function toggleOverlay() { 56 | var elems = document.getElementsByClassName("ir-highlight"); 57 | for (var i = 0; i < elems.length; i++) { 58 | if (elems[i].getAttribute("ir-overlay") == "off") { 59 | elems[i].setAttribute("ir-overlay", "on") 60 | } else { 61 | elems[i].setAttribute("ir-overlay", "off") 62 | } 63 | } 64 | } 65 | 66 | 67 | function removeText() { 68 | var range, sel = window.getSelection(); 69 | if (sel.rangeCount && sel.getRangeAt) { 70 | range = sel.getRangeAt(0); 71 | var startNode = document.createElement('span'); 72 | range.insertNode(startNode); 73 | var endNode = document.createElement('span'); 74 | range.collapse(false); 75 | range.insertNode(endNode); 76 | range.setStartAfter(startNode); 77 | range.setEndBefore(endNode); 78 | sel.addRange(range); 79 | range.deleteContents(); 80 | } 81 | } 82 | 83 | 84 | function getPlainText() { 85 | return window.getSelection().toString(); 86 | } 87 | 88 | 89 | function getHtmlText() { 90 | var selection = window.getSelection(); 91 | var range = selection.getRangeAt(0); 92 | var div = document.createElement('div'); 93 | div.appendChild(range.cloneContents()); 94 | return div.innerHTML; 95 | } 96 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | package-mode = false 3 | name = "incremental-reading-anki" 4 | version = "4.13.0" 5 | description = "Incremental Reading for Anki" 6 | authors = [ 7 | "Tiago Barroso", 8 | "Frank Kmiec", 9 | "Aleksej", 10 | "Christian Weiß", 11 | "Timothée Chauvin", 12 | "Joseph Lorimer <joseph@lorimer.me>", 13 | "Vy Hong <contact@vyhong.me>"] 14 | license = "ISC" 15 | readme = "README.md" 16 | homepage = "https://ankiweb.net/shared/info/999215520" 17 | repository = "https://github.com/tvhong/incremental-reading/" 18 | 19 | packages = [{include = "ir"}] 20 | 21 | include = ["CHANGELOG.md"] 22 | 23 | [tool.poetry.dependencies] 24 | python = "^3.11" 25 | pyqt6 = "^6.9.0" 26 | pyqt6-webengine = "^6.9.0" 27 | 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | aqt = "^24.11" 31 | black = "^24.10.0" 32 | isort = "^5.13.2" 33 | mypy = "^1.14" 34 | pylint = "^3.3.3" 35 | pytest = "^8.3.4" 36 | pytest-cov = "^6.0.0" 37 | PyQt5 = "^5.15.10" 38 | PyQt5-stubs = "^5.15.5" 39 | markdown2 = "^2.5.2" 40 | 41 | 42 | [build-system] 43 | requires = ["poetry-core>=1.0.0"] 44 | build-backend = "poetry.core.masonry.api" 45 | 46 | 47 | [tool.poetry_bumpversion.file."ir/_version.py"] 48 | [tool.poetry_bumpversion.file."ir/manifest.json"] 49 | [tool.poetry_bumpversion.file."CHANGELOG.md"] 50 | search = '[Unreleased]' 51 | replace = '[{new_version}]' 52 | 53 | [tool.isort] 54 | profile = "black" 55 | line_length = 88 56 | multi_line_output = 3 # Vertical Hanging Indent 57 | include_trailing_comma = true 58 | force_grid_wrap = 0 59 | use_parentheses = true 60 | ensure_newline_before_comments = true 61 | skip_gitignore = true 62 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 63 | default_section = "THIRDPARTY" 64 | known_first_party = ["ir"] 65 | known_third_party = ["anki", "aqt", "bs4", "requests"] 66 | 67 | [tool.black] 68 | line-length = 88 69 | extend-exclude = ''' 70 | # A regex preceded with ^/ will apply only to files and directories 71 | # in the root of the project. 72 | ^/ir/lib/ 73 | ''' -------------------------------------------------------------------------------- /screenshots/extraction-and-highlighting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/screenshots/extraction-and-highlighting.png -------------------------------------------------------------------------------- /screenshots/highlighting-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/screenshots/highlighting-tab.png -------------------------------------------------------------------------------- /screenshots/quick-keys-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvhong/incremental-reading/ac70d1af3f2148d19b682deba2e2fe64d6b719b6/screenshots/quick-keys-tab.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def setup_testing_environment(): 8 | """ 9 | Sets up the IR_TESTING environment variable before each test and cleans it up after. 10 | This fixture runs automatically for all tests due to autouse=True. 11 | """ 12 | # Set up environment variable before test 13 | old_value = os.environ.get("IR_TESTING") 14 | os.environ["IR_TESTING"] = "1" 15 | 16 | # Run the test 17 | yield 18 | 19 | # Clean up after test 20 | if old_value is not None: 21 | os.environ["IR_TESTING"] = old_value 22 | else: 23 | os.environ.pop("IR_TESTING", None) 24 | -------------------------------------------------------------------------------- /tests/test_html_cleaner.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | 5 | class HtmlCleanerTests(TestCase): 6 | def setUp(self): 7 | modules = { 8 | "aqt": MagicMock(), 9 | } 10 | self.patcher = patch.dict("sys.modules", modules) 11 | self.patcher.start() 12 | 13 | def tearDown(self): 14 | self.patcher.stop() 15 | 16 | def test_ignored_tags_are_removed(self): 17 | sut = self._get_sut() 18 | 19 | html = """<html> 20 | <body> 21 | <h1>Test Content</h1> 22 | <iframe src="https://example.com"></iframe> 23 | <p>Some text</p> 24 | <script>alert('test');</script> 25 | <nav> 26 | <ul> 27 | <li><a href="#">Home</a></li> 28 | </ul> 29 | </nav> 30 | <div>More content</div> 31 | </body> 32 | </html> 33 | """ 34 | result = sut.clean(html, "https://example.com") 35 | 36 | # Check that ignored tags are removed 37 | for tag in sut._IGNORED_TAGS: 38 | assert not result.find_all(tag), f"{tag} tag was not removed" 39 | 40 | # Check that other content is preserved 41 | assert result.find("h1").text == "Test Content" 42 | assert result.find("p").text == "Some text" 43 | assert result.find("div").text == "More content" 44 | 45 | def _get_sut(self): 46 | from ir.importer.html_cleaner import HtmlCleaner 47 | 48 | return HtmlCleaner() 49 | -------------------------------------------------------------------------------- /tests/test_scheduler.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | 5 | class SchedulerTests(TestCase): 6 | def setUp(self): 7 | modules = { 8 | "PyQt5": MagicMock(), 9 | "PyQt5.QtCore": MagicMock(), 10 | "PyQt5.QtWidgets": MagicMock(), 11 | "anki": MagicMock(), 12 | "anki.cards": MagicMock(), 13 | "anki.hooks": MagicMock(), 14 | "anki.utils": MagicMock(), 15 | "aqt": MagicMock(), 16 | "aqt.qt": MagicMock(), 17 | "aqt.utils": MagicMock(), 18 | "ir.main": MagicMock(), 19 | "ir.util": MagicMock(), 20 | } 21 | self.patcher = patch.dict("sys.modules", modules) 22 | self.patcher.start() 23 | 24 | def tearDown(self): 25 | self.patcher.stop() 26 | 27 | def test_scheduler(self): 28 | from ir.schedule import Scheduler 29 | 30 | Scheduler() 31 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, mock_open, patch 3 | 4 | 5 | class SettingsTests(TestCase): 6 | def setUp(self): 7 | # TODO: use patch.dict for all 8 | modules = { 9 | "anki.hooks": MagicMock(), 10 | "aqt": MagicMock(), 11 | "aqt.mw": MagicMock(), 12 | "aqt.utils": MagicMock(), 13 | "ir.about": MagicMock(), 14 | "ir.main": MagicMock(), 15 | "ir.util": MagicMock(), 16 | "ir.settings.json.load": MagicMock(), 17 | "ir.settings.mw.pm.profileFolder": MagicMock(return_value=str()), 18 | "ir.settings.open": mock_open(), 19 | "ir.settings.os.path.isfile": MagicMock(return_value=True) 20 | } 21 | self.patcher = patch.dict("sys.modules", modules) 22 | self.patcher.start() 23 | 24 | def tearDown(self): 25 | self.patcher.stop() 26 | 27 | def _create_sut(self): 28 | from ir.settings import SettingsManager 29 | 30 | return SettingsManager() 31 | 32 | class SaveTests(SettingsTests): 33 | def test_save(self): 34 | with patch("ir.settings.open", mock_open()) as open_mock, \ 35 | patch("ir.settings.json.dump", MagicMock()) as dump_mock, \ 36 | patch("ir.settings.updateModificationTime", MagicMock()) as update_mock: 37 | 38 | sut = self._create_sut() 39 | 40 | sut.getSettingsPath = MagicMock(return_value="foo.json") 41 | sut.settings = {"foo": "bar"} 42 | sut.save() 43 | 44 | open_mock.assert_called_once_with("foo.json", "w", encoding="utf-8") 45 | dump_mock.assert_called_once_with({"foo": "bar"}, open_mock()) 46 | update_mock.assert_called_once() 47 | 48 | class PathTests(SettingsTests): 49 | def test_getMediaDir(self): 50 | with patch("ir.settings.mw.pm.profileFolder", MagicMock(return_value="foo")): 51 | sut = self._create_sut() 52 | 53 | self.assertEqual(sut.getMediaDir(), "foo/collection.media") 54 | 55 | def test_getSettingsPath(self): 56 | sut = self._create_sut() 57 | 58 | sut.getMediaDir = MagicMock(return_value="foo") 59 | 60 | self.assertEqual(sut.getSettingsPath(), "foo/_ir.json") 61 | 62 | 63 | class ValidateFormatStringsTests(SettingsTests): 64 | def test_valid(self): 65 | sut = self._create_sut() 66 | 67 | sut.defaults = {"fooFormat": "{foo} {bar}", "barFormat": "{baz} {qux}"} 68 | sut.settings = sut.defaults.copy() 69 | sut.requiredFormatKeys = { 70 | "fooFormat": ["foo", "bar"], 71 | "barFormat": ["baz", "qux"], 72 | } 73 | sut._validateFormatStrings() 74 | 75 | self.assertEqual(sut.settings, sut.defaults) 76 | 77 | def test_invalid(self): 78 | sut = self._create_sut() 79 | 80 | sut.defaults = {"fooFormat": "{foo} {bar}", "barFormat": "{baz} {qux}"} 81 | invalidSettings = {"fooFormat": "{baz} {qux}", "barFormat": "{foo} {bar}"} 82 | sut.settings = invalidSettings 83 | sut.requiredFormatKeys = { 84 | "fooFormat": ["foo", "bar"], 85 | "barFormat": ["baz", "qux"], 86 | } 87 | 88 | sut._validateFormatStrings() 89 | 90 | self.assertEqual(sut.settings, sut.defaults) 91 | 92 | 93 | class ValidFormatTests(SettingsTests): 94 | def test_valid(self): 95 | sut = self._create_sut() 96 | 97 | sut.requiredFormatKeys = {"test": ["foo", "bar", "baz"]} 98 | self.assertTrue(sut.validFormat("test", "{foo} {bar} {baz}")) 99 | 100 | def test_invalid(self): 101 | sut = self._create_sut() 102 | 103 | sut.requiredFormatKeys = {"test": ["foo", "bar", "baz"]} 104 | self.assertFalse(sut.validFormat("test", "{foo} {baz}")) 105 | --------------------------------------------------------------------------------