The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | [![Build Status](https://travis-ci.org/luoliyan/incremental-reading.svg?branch=master)](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 | ![Screenshot #1](https://raw.githubusercontent.com/luoliyan/incremental-reading/master/screenshots/extraction-and-highlighting.png)
 71 | ![Screenshot #2](https://raw.githubusercontent.com/luoliyan/incremental-reading/master/screenshots/highlighting-tab.png)
 72 | ![Screenshot #3](https://raw.githubusercontent.com/luoliyan/incremental-reading/master/screenshots/quick-keys-tab.png)
 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 &lt;contact@vyhong.me&gt;</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("&", "&amp;")  # Must be done first!
1083 |     s = s.replace("<", "&lt;")
1084 |     s = s.replace(">", "&gt;")
1085 |     if quote:
1086 |         s = s.replace('"', "&quot;")
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 | 


--------------------------------------------------------------------------------