├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── 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 ├── ir ├── __init__.py ├── _version.py ├── about.py ├── data │ └── colors.u8 ├── gui.py ├── importer.py ├── lib │ ├── __init__.py │ ├── cgi.py │ └── feedparser.py ├── main.py ├── pocket.py ├── requirements.in ├── requirements.txt ├── schedule.py ├── settings.py ├── text.py ├── util.py ├── view.py └── web │ ├── model.css │ ├── scroll.js │ ├── text.js │ └── width.js ├── screenshots ├── extraction-and-highlighting.png ├── highlighting-tab.png └── quick-keys-tab.png └── tests ├── __init__.py ├── test_scheduler.py └── test_settings.py /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .coverage 3 | .mypy_cache/ 4 | .python-version 5 | .ropeproject/ 6 | __pycache__ 7 | meta.json 8 | .idea 9 | incremental-reading-v*.zip 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - '3.6' 5 | 6 | install: 7 | - pip install nose 8 | 9 | script: nosetests ./tests 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Dependencies 2 | 3 | To install dependencies: 4 | ```shell 5 | pip install -r requirements.txt 6 | ``` 7 | 8 | To update dependencies list: 9 | ```shell 10 | pip-compile - --output-file=- < requirements.in > requirements.txt 11 | ``` 12 | 13 | ## Testing 14 | ### Unit tests 15 | 16 | To run unit tests: 17 | ```shell 18 | make test 19 | ``` 20 | 21 | ### Manual tests 22 | 23 | #### Add local git repo as an Anki addon 24 | Find out where Anki stores its add-on: Open Anki > Tools > Add-ons > View Files 25 | 26 | Then create a symlink from Anki's add-on directory to your "ir" directory. 27 | 28 | For example: 29 | * My Anki add-on directory is `$HOME/.local/share/Anki2/addons21`. 30 | * My local incremental reading workspace is `$HOME/workplace/incremental-reading`. 31 | * Then to add my local workspace as an Anki add-on, I'd run 32 | ```shell 33 | ln -s $HOME/workplace/incremental-reading/ir $HOME/.local/share/Anki2/addons21/ir 34 | ``` 35 | 36 | #### Run Anki from terminal 37 | 38 | Running Anki from terminal will show stdout, which is useful for debugging. 39 | 40 | Note: create a "Test" profile so that you don't accidentally destroy your notes. 41 | 42 | Then run Anki from terminal 43 | ```shell 44 | /usr/local/bin/anki -p Test 45 | ``` 46 | 47 | ## Publishing 48 | 49 | Build zip file: 50 | ```shell 51 | make 52 | ``` 53 | 54 | Then upload it to https://ankiweb.net/shared/addons/ . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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-2018 Joseph Lorimer 9 | 10 | Permission to use, copy, modify, and/or distribute this software for any 11 | purpose with or without fee is hereby granted, provided that the above 12 | copyright notice and this permission notice appear in all copies. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 15 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 16 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 17 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 18 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 19 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 20 | PERFORMANCE OF THIS SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer 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=`cat _version.py | grep __version__ | sed "s/.*'\(.*\)'.*/\1/"` 17 | PROJECT_SHORT=ir 18 | PROJECT_LONG=incremental-reading 19 | 20 | all: test prep pack clean 21 | 22 | test: 23 | pytest --cov="$(PROJECT_SHORT)" tests -v 24 | 25 | prep: 26 | rm -f $(PROJECT_LONG)-v*.zip 27 | find . -name '*.pyc' -type f -delete 28 | find . -name '*~' -type f -delete 29 | find . -name .mypy_cache -type d -exec rm -rf {} + 30 | find . -name .ropeproject -type d -exec rm -rf {} + 31 | find . -name __pycache__ -type d -exec rm -rf {} + 32 | cp LICENSE "$(PROJECT_SHORT)/LICENSE.txt" 33 | 34 | pack: 35 | (cd "$(PROJECT_SHORT)" && zip -r ../$(PROJECT_LONG)-v$(VERSION).zip *) 36 | curl https://raw.githubusercontent.com/luoliyan/anki-misc/master/convert-readme.py --output convert-readme.py 37 | python convert-readme.py 38 | rm convert-readme.py 39 | 40 | clean: 41 | rm "$(PROJECT_SHORT)/LICENSE.txt" 42 | -------------------------------------------------------------------------------- /README.html: -------------------------------------------------------------------------------- 1 | 2021 Project Status: Active development will resume on Saturday the 27th of February. 2 | 3 | Build Status 4 | 5 | 6 | Note: Version 4 of the add-on is only available for Anki 2.1+. Some features will be missing from the earlier versions. 7 | 8 | Introduction 9 | 10 | This is a rewrite of the Incremental Reading add-on, 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 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 | 13 | Main Features 14 | 15 | 16 | New to Version 4 17 | 18 | 19 | New to Version 3 20 | 21 | 22 | Screenshots 23 | 24 | Note: These are fairly outdated. 25 | 26 | Screenshot #1Screenshot #2Screenshot #3 27 | 28 | Installation 29 | 30 | You will first need to have Anki installed. Download the relevant installer here. 31 | 32 | 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) and place the ir folder into your add-ons folder. 33 | 34 | Usage 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 |
  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 Alt+3 while on the deck overview screen)
  2. Set up a shortcut for creating regular Anki cards from IR cards (press Alt+1, or go to the menu, then go to the Quick Keys tab)
  3. Review the IR card that was created, and extract any text you find interesting (by selecting the text and pressing x)
  4. Choose Soon or Later when you want to move to the next card (which will be a portion of text you extracted)
  5. Whenever you want to create a regular Anki note, simply select the desired text and use the shortcut you created earlier
39 | Outdated instructions can be found here. They were written for v2, but the basic behaviour of the add-on is still similar. 40 | 41 | Support 42 | 43 | If any issues are encountered, please post details to the Anki add-ons forum. It’s best if you post in the existing thread (here) so I receive an email notification. Otherwise, note an issue or make a pull request on GitHub. 44 | 45 | Please include the following information in your post: 46 | 47 | 48 | License 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 stated 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 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. 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 Anki Incremental Reading. -------------------------------------------------------------------------------- /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 x 21 | - Highlight selected text by pressing h 22 | - Remove selected text by pressing z 23 | - Undo changes to the text by pressing u 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 (Alt+3) 35 | - Import web feeds (Alt+4) 36 | - Import Pocket articles (Alt+5) 37 | - Apply bold, italics, underline or strikethrough (Ctrl+B, I, U, or S) 38 | - Toggle formatting on and off (Ctrl+Shift+O) 39 | - Choose maximum width of cards (see options: Alt+1) 40 | - Control initial scheduling of extracts (see options: Alt+1) 41 | 42 | ### New to Version 3 43 | 44 | - Remove unwanted text with a single key-press (z) 45 | - Multi-level undo, for reverting text changes (u) 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 Page Up and Page Down 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 x 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 Alt+3 while on the deck overview screen) 85 | 2. Set up a shortcut for creating regular Anki cards from IR cards (press Alt+1, 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 x) 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/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/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/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/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/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/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/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/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/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/docs/Screen-Shot-2013-03-18-at-7.33.22-PM.png -------------------------------------------------------------------------------- /ir/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer 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 | 17 | from .main import ReadingManager 18 | 19 | mw.readingManager = ReadingManager() 20 | -------------------------------------------------------------------------------- /ir/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.11.4' 2 | -------------------------------------------------------------------------------- /ir/about.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer 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 PyQt5.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout 16 | 17 | from aqt import mw 18 | 19 | from ._version import __version__ 20 | 21 | IR_GITHUB_URL = 'https://github.com/luoliyan/incremental-reading' 22 | 23 | 24 | def showAbout(): 25 | dialog = QDialog(mw) 26 | 27 | label = QLabel() 28 | label.setStyleSheet('QLabel { font-size: 14px; }') 29 | names = [ 30 | 'Tiago Barroso', 31 | 'Frank Kmiec', 32 | 'Aleksej', 33 | 'Christian Weiß', 34 | 'Timothée Chauvin', 35 | ] 36 | text = ''' 37 |
Incremental Reading v%s
38 |
Joseph Lorimer <joseph@lorimer.me>
39 |
Contributors: %s
40 |
Website: %s
41 | ''' % ( 42 | __version__, 43 | ', '.join(names), 44 | IR_GITHUB_URL, 45 | IR_GITHUB_URL, 46 | ) 47 | label.setText(text) 48 | 49 | buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) 50 | buttonBox.accepted.connect(dialog.accept) 51 | 52 | layout = QVBoxLayout() 53 | layout.addWidget(label) 54 | layout.addWidget(buttonBox) 55 | 56 | dialog.setLayout(layout) 57 | dialog.setWindowTitle('About') 58 | dialog.exec() 59 | -------------------------------------------------------------------------------- /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/gui.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 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 unicodedata import normalize 21 | 22 | from PyQt5.QtCore import Qt 23 | from PyQt5.QtGui import QFont 24 | from PyQt5.QtWidgets import ( 25 | QButtonGroup, 26 | QCheckBox, 27 | QComboBox, 28 | QDialog, 29 | QDialogButtonBox, 30 | QGroupBox, 31 | QHBoxLayout, 32 | QKeySequenceEdit, 33 | QLabel, 34 | QLineEdit, 35 | QPushButton, 36 | QRadioButton, 37 | QTabWidget, 38 | QVBoxLayout, 39 | QWidget, 40 | ) 41 | 42 | from anki.notes import Note 43 | from aqt import mw 44 | from aqt.tagedit import TagEdit 45 | from aqt.utils import showInfo, showWarning, tooltip 46 | 47 | from .util import ( 48 | createSpinBox, 49 | getColorList, 50 | getFieldNames, 51 | removeComboBoxItem, 52 | setComboBoxItem, 53 | setField, 54 | ) 55 | 56 | 57 | class SettingsDialog: 58 | altKeyCheckBox = None 59 | bgColorComboBox = None 60 | boldSeqEditBox = None 61 | colorPreviewLabel = None 62 | copyTitleCheckBox = None 63 | ctrlKeyCheckBox = None 64 | destDeckComboBox = None 65 | editExtractButton = None 66 | editSourceCheckBox = None 67 | extractDeckComboBox = None 68 | extractKeyComboBox = None 69 | extractPercentButton = None 70 | extractRandomCheckBox = None 71 | extractValueEditBox = None 72 | generalZoomSpinBox = None 73 | highlightKeyComboBox = None 74 | importDeckComboBox = None 75 | italicSeqEditBox = None 76 | laterPercentButton = None 77 | laterRandomCheckBox = None 78 | laterValueEditBox = None 79 | limitAllCardsButton = None 80 | limitIrCardsButton = None 81 | lineStepSpinBox = None 82 | noteTypeComboBox = None 83 | organizerFormatEditBox = None 84 | pageStepSpinBox = None 85 | plainTextCheckBox = None 86 | prioButton = None 87 | quickKeyEditExtractCheckBox = None 88 | quickKeyEditSourceCheckBox = None 89 | quickKeyPlainTextCheckBox = None 90 | quickKeysComboBox = None 91 | regularKeyComboBox = None 92 | removeKeyComboBox = None 93 | scheduleExtractCheckBox = None 94 | shiftKeyCheckBox = None 95 | soonPercentButton = None 96 | soonRandomCheckBox = None 97 | soonValueEditBox = None 98 | sourceFieldComboBox = None 99 | sourceFormatEditBox = None 100 | strikeSeqEditBox = None 101 | tagsEditBox = None 102 | targetComboBox = None 103 | textColorComboBox = None 104 | textFieldComboBox = None 105 | underlineSeqEditBox = None 106 | undoKeyComboBox = None 107 | widthEditBox = None 108 | zoomStepSpinBox = None 109 | 110 | def __init__(self, settings): 111 | self.settings = settings 112 | self.show() 113 | 114 | def show(self): 115 | dialog = QDialog(mw) 116 | 117 | zoomScrollLayout = QHBoxLayout() 118 | zoomScrollLayout.addWidget(self._getZoomGroupBox()) 119 | zoomScrollLayout.addWidget(self._getScrollGroupBox()) 120 | 121 | zoomScrollTab = QWidget() 122 | zoomScrollTab.setLayout(zoomScrollLayout) 123 | 124 | tabWidget = QTabWidget() 125 | tabWidget.setUsesScrollButtons(False) 126 | tabWidget.addTab(self._getGeneralTab(), 'General') 127 | tabWidget.addTab(self._getExtractionTab(), 'Extraction') 128 | tabWidget.addTab(self._getHighlightTab(), 'Formatting') 129 | tabWidget.addTab(self._getSchedulingTab(), 'Scheduling') 130 | tabWidget.addTab(self._getImportingTab(), 'Importing') 131 | tabWidget.addTab(self._getQuickKeysTab(), 'Quick Keys') 132 | tabWidget.addTab(zoomScrollTab, 'Zoom / Scroll') 133 | 134 | buttonBox = QDialogButtonBox( 135 | QDialogButtonBox.StandardButton.Close | QDialogButtonBox.StandardButton.Save 136 | ) 137 | buttonBox.accepted.connect(dialog.accept) 138 | buttonBox.rejected.connect(dialog.reject) 139 | buttonBox.setOrientation(Qt.Orientation.Horizontal) 140 | 141 | mainLayout = QVBoxLayout() 142 | mainLayout.addWidget(tabWidget) 143 | mainLayout.addWidget(buttonBox) 144 | 145 | dialog.setLayout(mainLayout) 146 | dialog.setWindowTitle('Incremental Reading Options') 147 | 148 | done = False 149 | while not done: 150 | if dialog.exec(): 151 | done = self._saveChanges() 152 | else: 153 | done = True 154 | 155 | def _saveChanges(self): 156 | self._saveHighlightSettings() 157 | done = self._saveKeys() 158 | 159 | self.settings['zoomStep'] = self.zoomStepSpinBox.value() / 100.0 160 | self.settings['generalZoom'] = self.generalZoomSpinBox.value() / 100.0 161 | self.settings['lineScrollFactor'] = ( 162 | self.lineStepSpinBox.value() / 100.0 163 | ) 164 | self.settings['pageScrollFactor'] = ( 165 | self.pageStepSpinBox.value() / 100.0 166 | ) 167 | self.settings['editExtract'] = self.editExtractButton.isChecked() 168 | self.settings['editSource'] = self.editSourceCheckBox.isChecked() 169 | self.settings['plainText'] = self.plainTextCheckBox.isChecked() 170 | self.settings['copyTitle'] = self.copyTitleCheckBox.isChecked() 171 | self.settings[ 172 | 'scheduleExtract' 173 | ] = self.scheduleExtractCheckBox.isChecked() 174 | self.settings['soonRandom'] = self.soonRandomCheckBox.isChecked() 175 | self.settings['laterRandom'] = self.laterRandomCheckBox.isChecked() 176 | self.settings['extractRandom'] = self.extractRandomCheckBox.isChecked() 177 | 178 | if self.extractDeckComboBox.currentText() == '[Current Deck]': 179 | self.settings['extractDeck'] = None 180 | else: 181 | self.settings[ 182 | 'extractDeck' 183 | ] = self.extractDeckComboBox.currentText() 184 | 185 | try: 186 | self.settings['soonValue'] = int(self.soonValueEditBox.text()) 187 | self.settings['laterValue'] = int(self.laterValueEditBox.text()) 188 | self.settings['extractValue'] = int( 189 | self.extractValueEditBox.text() 190 | ) 191 | self.settings['maxWidth'] = int(self.widthEditBox.text()) 192 | except ValueError: 193 | showWarning('Integer value expected. Please try again.') 194 | done = False 195 | 196 | if self.importDeckComboBox.currentText() == '[Current Deck]': 197 | self.settings['importDeck'] = None 198 | else: 199 | self.settings['importDeck'] = self.importDeckComboBox.currentText() 200 | 201 | if self.settings['prioEnabled'] != self.prioButton.isChecked(): 202 | self.settings['prioEnabled'] = self.prioButton.isChecked() 203 | self._addPrioFields() 204 | 205 | if self.soonPercentButton.isChecked(): 206 | self.settings['soonMethod'] = 'percent' 207 | else: 208 | self.settings['soonMethod'] = 'count' 209 | 210 | if self.laterPercentButton.isChecked(): 211 | self.settings['laterMethod'] = 'percent' 212 | else: 213 | self.settings['laterMethod'] = 'count' 214 | 215 | if self.extractPercentButton.isChecked(): 216 | self.settings['extractMethod'] = 'percent' 217 | else: 218 | self.settings['extractMethod'] = 'count' 219 | 220 | d = { 221 | 'organizerFormat': self.organizerFormatEditBox, 222 | 'sourceFormat': self.sourceFormatEditBox, 223 | } 224 | for name, editBox in d.items(): 225 | fmt = editBox.text().replace(r'\t', '\t') 226 | if self.settings.validFormat(name, fmt): 227 | self.settings[name] = fmt 228 | else: 229 | showWarning('Missing required keys for format string.') 230 | done = False 231 | break 232 | 233 | if self.limitAllCardsButton.isChecked(): 234 | self.settings['limitWidth'] = True 235 | self.settings['limitWidthAll'] = True 236 | elif self.limitIrCardsButton.isChecked(): 237 | self.settings['limitWidth'] = True 238 | self.settings['limitWidthAll'] = False 239 | else: 240 | self.settings['limitWidth'] = False 241 | self.settings['limitWidthAll'] = False 242 | 243 | self.settings['boldSeq'] = self.boldSeqEditBox.keySequence().toString() 244 | self.settings[ 245 | 'italicSeq' 246 | ] = self.italicSeqEditBox.keySequence().toString() 247 | self.settings[ 248 | 'underlineSeq' 249 | ] = self.underlineSeqEditBox.keySequence().toString() 250 | self.settings[ 251 | 'strikeSeq' 252 | ] = self.strikeSeqEditBox.keySequence().toString() 253 | 254 | mw.readingManager.viewManager.resetZoom(mw.state) 255 | return done 256 | 257 | def _addPrioFields(self): 258 | model = mw.col.models.by_name(self.settings['modelName']) 259 | if self.settings['prioField'] in getFieldNames( 260 | self.settings['modelName'] 261 | ): 262 | return 263 | field = mw.col.models.new_field(self.settings['prioField']) 264 | mw.col.models.add_field(model, field) 265 | for (nid,) in mw.col.db.execute( 266 | 'SELECT id FROM notes WHERE mid = ?', model['id'] 267 | ): 268 | note = mw.col.get_note(nid) 269 | setField( 270 | note, self.settings['prioField'], self.settings['prioDefault'] 271 | ) 272 | note.flush() 273 | showInfo( 274 | 'A Priority field has been added to your IR notes. ' 275 | 'Valid priority values are integers 1-10, where 10 represents ' 276 | 'the higest priority. By default, the field is set to 5. ' 277 | 'When randomizing cards, priorities are taken into account.' 278 | ) 279 | 280 | def _getGeneralTab(self): 281 | highlightKeyLabel = QLabel('Highlight Key') 282 | extractKeyLabel = QLabel('Extract Key') 283 | removeKeyLabel = QLabel('Remove Key') 284 | undoKeyLabel = QLabel('Undo Key') 285 | 286 | self.extractKeyComboBox = QComboBox() 287 | self.highlightKeyComboBox = QComboBox() 288 | self.removeKeyComboBox = QComboBox() 289 | self.undoKeyComboBox = QComboBox() 290 | 291 | keys = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789') 292 | for comboBox in [ 293 | self.highlightKeyComboBox, 294 | self.extractKeyComboBox, 295 | self.removeKeyComboBox, 296 | self.undoKeyComboBox, 297 | ]: 298 | comboBox.addItems(keys) 299 | 300 | self._setCurrentKeys() 301 | 302 | highlightKeyLayout = QHBoxLayout() 303 | highlightKeyLayout.addWidget(highlightKeyLabel) 304 | highlightKeyLayout.addStretch() 305 | highlightKeyLayout.addWidget(self.highlightKeyComboBox) 306 | 307 | extractKeyLayout = QHBoxLayout() 308 | extractKeyLayout.addWidget(extractKeyLabel) 309 | extractKeyLayout.addStretch() 310 | extractKeyLayout.addWidget(self.extractKeyComboBox) 311 | 312 | removeKeyLayout = QHBoxLayout() 313 | removeKeyLayout.addWidget(removeKeyLabel) 314 | removeKeyLayout.addStretch() 315 | removeKeyLayout.addWidget(self.removeKeyComboBox) 316 | 317 | undoKeyLayout = QHBoxLayout() 318 | undoKeyLayout.addWidget(undoKeyLabel) 319 | undoKeyLayout.addStretch() 320 | undoKeyLayout.addWidget(self.undoKeyComboBox) 321 | 322 | controlsLayout = QVBoxLayout() 323 | controlsLayout.addLayout(highlightKeyLayout) 324 | controlsLayout.addLayout(extractKeyLayout) 325 | controlsLayout.addLayout(removeKeyLayout) 326 | controlsLayout.addLayout(undoKeyLayout) 327 | controlsLayout.addStretch() 328 | 329 | controlsGroupBox = QGroupBox('Basic Controls') 330 | controlsGroupBox.setLayout(controlsLayout) 331 | 332 | widthLabel = QLabel('Card Width Limit:') 333 | self.widthEditBox = QLineEdit() 334 | self.widthEditBox.setFixedWidth(50) 335 | self.widthEditBox.setText(str(self.settings['maxWidth'])) 336 | pixelsLabel = QLabel('pixels') 337 | 338 | widthEditLayout = QHBoxLayout() 339 | widthEditLayout.addWidget(widthLabel) 340 | widthEditLayout.addWidget(self.widthEditBox) 341 | widthEditLayout.addWidget(pixelsLabel) 342 | 343 | applyLabel = QLabel('Apply to') 344 | self.limitAllCardsButton = QRadioButton('All Cards') 345 | self.limitIrCardsButton = QRadioButton('IR Cards') 346 | limitNoneButton = QRadioButton('None') 347 | 348 | if self.settings['limitWidth'] and self.settings['limitWidthAll']: 349 | self.limitAllCardsButton.setChecked(True) 350 | elif self.settings['limitWidth']: 351 | self.limitIrCardsButton.setChecked(True) 352 | else: 353 | limitNoneButton.setChecked(True) 354 | 355 | applyLayout = QHBoxLayout() 356 | applyLayout.addWidget(applyLabel) 357 | applyLayout.addWidget(self.limitAllCardsButton) 358 | applyLayout.addWidget(self.limitIrCardsButton) 359 | applyLayout.addWidget(limitNoneButton) 360 | 361 | displayLayout = QVBoxLayout() 362 | displayLayout.addLayout(widthEditLayout) 363 | displayLayout.addLayout(applyLayout) 364 | displayLayout.addStretch() 365 | 366 | displayGroupBox = QGroupBox('Display') 367 | displayGroupBox.setLayout(displayLayout) 368 | 369 | layout = QHBoxLayout() 370 | layout.addWidget(controlsGroupBox) 371 | layout.addWidget(displayGroupBox) 372 | 373 | tab = QWidget() 374 | tab.setLayout(layout) 375 | 376 | return tab 377 | 378 | def _setCurrentKeys(self): 379 | setComboBoxItem( 380 | self.highlightKeyComboBox, self.settings['highlightKey'] 381 | ) 382 | setComboBoxItem(self.extractKeyComboBox, self.settings['extractKey']) 383 | setComboBoxItem(self.removeKeyComboBox, self.settings['removeKey']) 384 | setComboBoxItem(self.undoKeyComboBox, self.settings['undoKey']) 385 | 386 | def _saveKeys(self): 387 | keys = [ 388 | self.highlightKeyComboBox.currentText(), 389 | self.extractKeyComboBox.currentText(), 390 | self.removeKeyComboBox.currentText(), 391 | self.undoKeyComboBox.currentText(), 392 | ] 393 | 394 | if len(set(keys)) < len(keys): 395 | showInfo( 396 | 'There is a conflict with the keys you have chosen. ' 397 | 'Please try again.' 398 | ) 399 | self._setCurrentKeys() 400 | return False 401 | 402 | self.settings[ 403 | 'highlightKey' 404 | ] = self.highlightKeyComboBox.currentText().lower() 405 | self.settings[ 406 | 'extractKey' 407 | ] = self.extractKeyComboBox.currentText().lower() 408 | self.settings[ 409 | 'removeKey' 410 | ] = self.removeKeyComboBox.currentText().lower() 411 | self.settings['undoKey'] = self.undoKeyComboBox.currentText().lower() 412 | return True 413 | 414 | def _getExtractionTab(self): 415 | extractDeckLabel = QLabel('Extracts Deck') 416 | self.extractDeckComboBox = QComboBox() 417 | self.extractDeckComboBox.setFixedWidth(400) 418 | deckNames = sorted([d['name'] for d in mw.col.decks.all()]) 419 | self.extractDeckComboBox.addItem('[Current Deck]') 420 | self.extractDeckComboBox.addItems(deckNames) 421 | 422 | if self.settings['extractDeck']: 423 | setComboBoxItem( 424 | self.extractDeckComboBox, self.settings['extractDeck'] 425 | ) 426 | else: 427 | setComboBoxItem(self.extractDeckComboBox, '[Current Deck]') 428 | 429 | extractDeckLayout = QHBoxLayout() 430 | extractDeckLayout.addWidget(extractDeckLabel) 431 | extractDeckLayout.addWidget(self.extractDeckComboBox) 432 | extractDeckLayout.addStretch() 433 | 434 | self.editExtractButton = QRadioButton('Edit Extracted Note') 435 | enterTitleButton = QRadioButton('Enter Title Only') 436 | 437 | if self.settings['editExtract']: 438 | self.editExtractButton.setChecked(True) 439 | else: 440 | enterTitleButton.setChecked(True) 441 | 442 | radioButtonsLayout = QHBoxLayout() 443 | radioButtonsLayout.addWidget(self.editExtractButton) 444 | radioButtonsLayout.addWidget(enterTitleButton) 445 | radioButtonsLayout.addStretch() 446 | 447 | self.editSourceCheckBox = QCheckBox('Edit Source Note') 448 | self.plainTextCheckBox = QCheckBox('Extract as Plain Text') 449 | self.copyTitleCheckBox = QCheckBox('Copy Title') 450 | self.scheduleExtractCheckBox = QCheckBox('Schedule Extracts') 451 | 452 | if self.settings['editSource']: 453 | self.editSourceCheckBox.setChecked(True) 454 | 455 | if self.settings['plainText']: 456 | self.plainTextCheckBox.setChecked(True) 457 | 458 | if self.settings['copyTitle']: 459 | self.copyTitleCheckBox.setChecked(True) 460 | 461 | if self.settings['scheduleExtract']: 462 | self.scheduleExtractCheckBox.setChecked(True) 463 | 464 | layout = QVBoxLayout() 465 | layout.addLayout(extractDeckLayout) 466 | layout.addLayout(radioButtonsLayout) 467 | layout.addWidget(self.editSourceCheckBox) 468 | layout.addWidget(self.plainTextCheckBox) 469 | layout.addWidget(self.copyTitleCheckBox) 470 | layout.addWidget(self.scheduleExtractCheckBox) 471 | layout.addStretch() 472 | 473 | tab = QWidget() 474 | tab.setLayout(layout) 475 | 476 | return tab 477 | 478 | def _getHighlightTab(self): 479 | highlightGroupBox = self._getHighlightGroupBox() 480 | stylingGroupBox = self._getStylingGroupBox() 481 | 482 | horizontalLayout = QHBoxLayout() 483 | horizontalLayout.addWidget(highlightGroupBox) 484 | horizontalLayout.addWidget(stylingGroupBox) 485 | 486 | layout = QVBoxLayout() 487 | layout.addLayout(horizontalLayout) 488 | layout.addStretch() 489 | 490 | tab = QWidget() 491 | tab.setLayout(layout) 492 | 493 | return tab 494 | 495 | def _saveHighlightSettings(self): 496 | target = self.targetComboBox.currentText() 497 | bgColor = self.bgColorComboBox.currentText() 498 | textColor = self.textColorComboBox.currentText() 499 | 500 | if target == '[Highlight Key]': 501 | self.settings['highlightBgColor'] = bgColor 502 | self.settings['highlightTextColor'] = textColor 503 | elif target == '[Extract Key]': 504 | self.settings['extractBgColor'] = bgColor 505 | self.settings['extractTextColor'] = textColor 506 | else: 507 | self.settings['quickKeys'][target]['extractBgColor'] = bgColor 508 | self.settings['quickKeys'][target]['extractTextColor'] = textColor 509 | 510 | def _getHighlightGroupBox(self): 511 | self.targetComboBox = QComboBox() 512 | self._populateTargetComboBox() 513 | self.targetComboBox.currentIndexChanged.connect( 514 | self._updateHighlightTab 515 | ) 516 | targetLayout = QHBoxLayout() 517 | targetLayout.addStretch() 518 | 519 | colors = getColorList() 520 | self.bgColorComboBox = QComboBox() 521 | self.bgColorComboBox.addItems(colors) 522 | setComboBoxItem( 523 | self.bgColorComboBox, self.settings['highlightBgColor'] 524 | ) 525 | self.bgColorComboBox.currentIndexChanged.connect( 526 | self._updateColorPreview 527 | ) 528 | self.bgColorComboBox.activated.connect(self._saveHighlightSettings) 529 | self.textColorComboBox = QComboBox() 530 | self.textColorComboBox.addItems(colors) 531 | setComboBoxItem( 532 | self.textColorComboBox, self.settings['highlightTextColor'] 533 | ) 534 | self.textColorComboBox.currentIndexChanged.connect( 535 | self._updateColorPreview 536 | ) 537 | self.textColorComboBox.activated.connect(self._saveHighlightSettings) 538 | self.colorPreviewLabel = QLabel('Example Text') 539 | self._updateColorPreview() 540 | 541 | bgColorLabel = QLabel('Background') 542 | bgColorLayout = QHBoxLayout() 543 | bgColorLayout.addWidget(bgColorLabel) 544 | bgColorLayout.addSpacing(10) 545 | bgColorLayout.addWidget(self.bgColorComboBox) 546 | 547 | textColorLabel = QLabel('Text') 548 | textColorLayout = QHBoxLayout() 549 | textColorLayout.addWidget(textColorLabel) 550 | textColorLayout.addSpacing(10) 551 | textColorLayout.addWidget(self.textColorComboBox) 552 | 553 | layout = QVBoxLayout() 554 | layout.addWidget(self.targetComboBox) 555 | layout.addWidget(self.colorPreviewLabel) 556 | layout.addLayout(bgColorLayout) 557 | layout.addLayout(textColorLayout) 558 | layout.addStretch() 559 | 560 | groupBox = QGroupBox('Highlighting') 561 | groupBox.setLayout(layout) 562 | 563 | return groupBox 564 | 565 | def _populateTargetComboBox(self): 566 | self.targetComboBox.clear() 567 | self.targetComboBox.addItem('[Highlight Key]') 568 | self.targetComboBox.addItem('[Extract Key]') 569 | self.targetComboBox.addItems(self.settings['quickKeys'].keys()) 570 | 571 | def _updateHighlightTab(self): 572 | target = self.targetComboBox.currentText() 573 | 574 | if not target: 575 | return 576 | 577 | if target == '[Highlight Key]': 578 | setComboBoxItem( 579 | self.bgColorComboBox, self.settings['highlightBgColor'] 580 | ) 581 | setComboBoxItem( 582 | self.textColorComboBox, self.settings['highlightTextColor'] 583 | ) 584 | elif target == '[Extract Key]': 585 | setComboBoxItem( 586 | self.bgColorComboBox, self.settings['extractBgColor'] 587 | ) 588 | setComboBoxItem( 589 | self.textColorComboBox, self.settings['extractTextColor'] 590 | ) 591 | else: 592 | setComboBoxItem( 593 | self.bgColorComboBox, 594 | self.settings['quickKeys'][target]['extractBgColor'], 595 | ) 596 | setComboBoxItem( 597 | self.textColorComboBox, 598 | self.settings['quickKeys'][target]['extractTextColor'], 599 | ) 600 | 601 | def _updateColorPreview(self): 602 | bgColor = self.bgColorComboBox.currentText() 603 | textColor = self.textColorComboBox.currentText() 604 | styleSheet = ( 605 | 'QLabel {' 606 | 'background-color: %s;' 607 | 'color: %s;' 608 | 'padding: 10px;' 609 | 'font-size: 16px;' 610 | 'font-family: tahoma, geneva, sans-serif;' 611 | '}' 612 | ) % (bgColor, textColor) 613 | self.colorPreviewLabel.setStyleSheet(styleSheet) 614 | self.colorPreviewLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 615 | 616 | def _getStylingGroupBox(self): 617 | boldLabel = QLabel('Bold') 618 | self.boldSeqEditBox = QKeySequenceEdit(self.settings['boldSeq']) 619 | boldLayout = QHBoxLayout() 620 | boldLayout.addWidget(boldLabel) 621 | boldLayout.addStretch() 622 | boldLayout.addWidget(self.boldSeqEditBox) 623 | 624 | italicLabel = QLabel('Italic') 625 | self.italicSeqEditBox = QKeySequenceEdit(self.settings['italicSeq']) 626 | italicLayout = QHBoxLayout() 627 | italicLayout.addWidget(italicLabel) 628 | italicLayout.addStretch() 629 | italicLayout.addWidget(self.italicSeqEditBox) 630 | 631 | underlineLabel = QLabel('Underline') 632 | self.underlineSeqEditBox = QKeySequenceEdit( 633 | self.settings['underlineSeq'] 634 | ) 635 | underlineLayout = QHBoxLayout() 636 | underlineLayout.addWidget(underlineLabel) 637 | underlineLayout.addStretch() 638 | underlineLayout.addWidget(self.underlineSeqEditBox) 639 | 640 | strikeLabel = QLabel('Strikethrough') 641 | self.strikeSeqEditBox = QKeySequenceEdit(self.settings['strikeSeq']) 642 | strikeLayout = QHBoxLayout() 643 | strikeLayout.addWidget(strikeLabel) 644 | strikeLayout.addStretch() 645 | strikeLayout.addWidget(self.strikeSeqEditBox) 646 | 647 | layout = QVBoxLayout() 648 | layout.addLayout(boldLayout) 649 | layout.addLayout(italicLayout) 650 | layout.addLayout(underlineLayout) 651 | layout.addLayout(strikeLayout) 652 | layout.addStretch() 653 | 654 | groupBox = QGroupBox('Styling') 655 | groupBox.setLayout(layout) 656 | 657 | return groupBox 658 | 659 | def _getSchedulingTab(self): 660 | modeLabel = QLabel('Scheduling Mode') 661 | manualButton = QRadioButton('Manual') 662 | self.prioButton = QRadioButton('Priorities') 663 | 664 | soonLabel = QLabel('Soon Button') 665 | laterLabel = QLabel('Later Button') 666 | extractLabel = QLabel('Extracts') 667 | 668 | self.soonPercentButton = QRadioButton('Percent') 669 | soonPositionButton = QRadioButton('Position') 670 | self.laterPercentButton = QRadioButton('Percent') 671 | laterPositionButton = QRadioButton('Position') 672 | self.extractPercentButton = QRadioButton('Percent') 673 | extractPositionButton = QRadioButton('Position') 674 | 675 | self.soonRandomCheckBox = QCheckBox('Randomize') 676 | self.laterRandomCheckBox = QCheckBox('Randomize') 677 | self.extractRandomCheckBox = QCheckBox('Randomize') 678 | 679 | self.soonValueEditBox = QLineEdit() 680 | self.soonValueEditBox.setFixedWidth(100) 681 | self.laterValueEditBox = QLineEdit() 682 | self.laterValueEditBox.setFixedWidth(100) 683 | self.extractValueEditBox = QLineEdit() 684 | self.extractValueEditBox.setFixedWidth(100) 685 | 686 | if self.settings['prioEnabled']: 687 | self.prioButton.setChecked(True) 688 | else: 689 | manualButton.setChecked(True) 690 | 691 | if self.settings['soonMethod'] == 'percent': 692 | self.soonPercentButton.setChecked(True) 693 | else: 694 | soonPositionButton.setChecked(True) 695 | 696 | if self.settings['laterMethod'] == 'percent': 697 | self.laterPercentButton.setChecked(True) 698 | else: 699 | laterPositionButton.setChecked(True) 700 | 701 | if self.settings['extractMethod'] == 'percent': 702 | self.extractPercentButton.setChecked(True) 703 | else: 704 | extractPositionButton.setChecked(True) 705 | 706 | if self.settings['soonRandom']: 707 | self.soonRandomCheckBox.setChecked(True) 708 | 709 | if self.settings['laterRandom']: 710 | self.laterRandomCheckBox.setChecked(True) 711 | 712 | if self.settings['extractRandom']: 713 | self.extractRandomCheckBox.setChecked(True) 714 | 715 | self.soonValueEditBox.setText(str(self.settings['soonValue'])) 716 | self.laterValueEditBox.setText(str(self.settings['laterValue'])) 717 | self.extractValueEditBox.setText(str(self.settings['extractValue'])) 718 | 719 | formatLabel = QLabel('Organizer Format') 720 | self.organizerFormatEditBox = QLineEdit() 721 | self.organizerFormatEditBox.setFixedWidth(400) 722 | self.organizerFormatEditBox.setText( 723 | self.settings['organizerFormat'].replace('\t', r'\t') 724 | ) 725 | font = QFont('Lucida Sans Typewriter') 726 | font.setStyleHint(QFont.StyleHint.Monospace) 727 | self.organizerFormatEditBox.setFont(font) 728 | 729 | modeLayout = QHBoxLayout() 730 | modeLayout.addWidget(modeLabel) 731 | modeLayout.addStretch() 732 | modeLayout.addWidget(manualButton) 733 | modeLayout.addWidget(self.prioButton) 734 | 735 | soonLayout = QHBoxLayout() 736 | soonLayout.addWidget(soonLabel) 737 | soonLayout.addStretch() 738 | soonLayout.addWidget(self.soonValueEditBox) 739 | soonLayout.addWidget(self.soonPercentButton) 740 | soonLayout.addWidget(soonPositionButton) 741 | soonLayout.addWidget(self.soonRandomCheckBox) 742 | 743 | laterLayout = QHBoxLayout() 744 | laterLayout.addWidget(laterLabel) 745 | laterLayout.addStretch() 746 | laterLayout.addWidget(self.laterValueEditBox) 747 | laterLayout.addWidget(self.laterPercentButton) 748 | laterLayout.addWidget(laterPositionButton) 749 | laterLayout.addWidget(self.laterRandomCheckBox) 750 | 751 | extractLayout = QHBoxLayout() 752 | extractLayout.addWidget(extractLabel) 753 | extractLayout.addStretch() 754 | extractLayout.addWidget(self.extractValueEditBox) 755 | extractLayout.addWidget(self.extractPercentButton) 756 | extractLayout.addWidget(extractPositionButton) 757 | extractLayout.addWidget(self.extractRandomCheckBox) 758 | 759 | modeButtonGroup = QButtonGroup(modeLayout) 760 | modeButtonGroup.addButton(manualButton) 761 | modeButtonGroup.addButton(self.prioButton) 762 | 763 | soonButtonGroup = QButtonGroup(soonLayout) 764 | soonButtonGroup.addButton(self.soonPercentButton) 765 | soonButtonGroup.addButton(soonPositionButton) 766 | 767 | laterButtonGroup = QButtonGroup(laterLayout) 768 | laterButtonGroup.addButton(self.laterPercentButton) 769 | laterButtonGroup.addButton(laterPositionButton) 770 | 771 | extractButtonGroup = QButtonGroup(extractLayout) 772 | extractButtonGroup.addButton(self.extractPercentButton) 773 | extractButtonGroup.addButton(extractPositionButton) 774 | 775 | formatLayout = QHBoxLayout() 776 | formatLayout.addWidget(formatLabel) 777 | formatLayout.addWidget(self.organizerFormatEditBox) 778 | 779 | layout = QVBoxLayout() 780 | layout.addLayout(modeLayout) 781 | layout.addLayout(soonLayout) 782 | layout.addLayout(laterLayout) 783 | layout.addLayout(extractLayout) 784 | layout.addLayout(formatLayout) 785 | layout.addStretch() 786 | 787 | tab = QWidget() 788 | tab.setLayout(layout) 789 | 790 | return tab 791 | 792 | def _getQuickKeysTab(self): 793 | destDeckLabel = QLabel('Destination Deck') 794 | noteTypeLabel = QLabel('Note Type') 795 | textFieldLabel = QLabel('Paste Text to Field') 796 | sourceFieldLabel = QLabel('Paste Source to Field') 797 | keyComboLabel = QLabel('Key Combination') 798 | 799 | self.quickKeysComboBox = QComboBox() 800 | self.quickKeysComboBox.addItem('') 801 | self.quickKeysComboBox.addItems(self.settings['quickKeys'].keys()) 802 | self.quickKeysComboBox.currentIndexChanged.connect( 803 | self._updateQuickKeysTab 804 | ) 805 | 806 | self.destDeckComboBox = QComboBox() 807 | self.destDeckComboBox.setFixedWidth(400) 808 | self.noteTypeComboBox = QComboBox() 809 | self.textFieldComboBox = QComboBox() 810 | self.textFieldComboBox.currentIndexChanged.connect( 811 | self._updateSourceFieldComboBox 812 | ) 813 | self.sourceFieldComboBox = QComboBox() 814 | self.quickKeyEditExtractCheckBox = QCheckBox('Edit Extracted Note') 815 | self.quickKeyEditSourceCheckBox = QCheckBox('Edit Source Note') 816 | self.quickKeyPlainTextCheckBox = QCheckBox('Extract as Plain Text') 817 | 818 | self.ctrlKeyCheckBox = QCheckBox('Ctrl') 819 | self.altKeyCheckBox = QCheckBox('Alt') 820 | self.shiftKeyCheckBox = QCheckBox('Shift') 821 | self.regularKeyComboBox = QComboBox() 822 | self.regularKeyComboBox.addItem('') 823 | self.regularKeyComboBox.addItems( 824 | list('ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789') 825 | ) 826 | 827 | destDeckLayout = QHBoxLayout() 828 | destDeckLayout.addWidget(destDeckLabel) 829 | destDeckLayout.addWidget(self.destDeckComboBox) 830 | 831 | noteTypeLayout = QHBoxLayout() 832 | noteTypeLayout.addWidget(noteTypeLabel) 833 | noteTypeLayout.addWidget(self.noteTypeComboBox) 834 | 835 | textFieldLayout = QHBoxLayout() 836 | textFieldLayout.addWidget(textFieldLabel) 837 | textFieldLayout.addWidget(self.textFieldComboBox) 838 | 839 | sourceFieldLayout = QHBoxLayout() 840 | sourceFieldLayout.addWidget(sourceFieldLabel) 841 | sourceFieldLayout.addWidget(self.sourceFieldComboBox) 842 | 843 | keyComboLayout = QHBoxLayout() 844 | keyComboLayout.addWidget(keyComboLabel) 845 | keyComboLayout.addStretch() 846 | keyComboLayout.addWidget(self.ctrlKeyCheckBox) 847 | keyComboLayout.addWidget(self.altKeyCheckBox) 848 | keyComboLayout.addWidget(self.shiftKeyCheckBox) 849 | keyComboLayout.addWidget(self.regularKeyComboBox) 850 | 851 | deckNames = sorted([d['name'] for d in mw.col.decks.all()]) 852 | self.destDeckComboBox.addItem('') 853 | self.destDeckComboBox.addItems(deckNames) 854 | 855 | modelNames = sorted([m['name'] for m in mw.col.models.all()]) 856 | self.noteTypeComboBox.addItem('') 857 | self.noteTypeComboBox.addItems(modelNames) 858 | self.noteTypeComboBox.currentIndexChanged.connect( 859 | self._updateFieldLists 860 | ) 861 | 862 | newButton = QPushButton('New') 863 | newButton.clicked.connect(self._clearQuickKeysTab) 864 | setButton = QPushButton('Set') 865 | setButton.clicked.connect(self._setQuickKey) 866 | unsetButton = QPushButton('Unset') 867 | unsetButton.clicked.connect(self._unsetQuickKey) 868 | 869 | tagsLabel = QLabel('Tags') 870 | self.tagsEditBox = TagEdit(mw) 871 | self.tagsEditBox.setCol(mw.col) 872 | tagsLayout = QHBoxLayout() 873 | tagsLayout.addWidget(tagsLabel) 874 | tagsLayout.addWidget(self.tagsEditBox) 875 | 876 | buttonLayout = QHBoxLayout() 877 | buttonLayout.addStretch() 878 | buttonLayout.addWidget(newButton) 879 | buttonLayout.addWidget(setButton) 880 | buttonLayout.addWidget(unsetButton) 881 | 882 | layout = QVBoxLayout() 883 | layout.addWidget(self.quickKeysComboBox) 884 | layout.addLayout(destDeckLayout) 885 | layout.addLayout(noteTypeLayout) 886 | layout.addLayout(textFieldLayout) 887 | layout.addLayout(sourceFieldLayout) 888 | layout.addLayout(keyComboLayout) 889 | layout.addWidget(self.quickKeyEditExtractCheckBox) 890 | layout.addWidget(self.quickKeyEditSourceCheckBox) 891 | layout.addWidget(self.quickKeyPlainTextCheckBox) 892 | layout.addLayout(tagsLayout) 893 | layout.addLayout(buttonLayout) 894 | 895 | tab = QWidget() 896 | tab.setLayout(layout) 897 | 898 | return tab 899 | 900 | def _updateQuickKeysTab(self): 901 | keyCombo = self.quickKeysComboBox.currentText() 902 | if keyCombo: 903 | settings = self.settings['quickKeys'][keyCombo] 904 | setComboBoxItem(self.destDeckComboBox, settings['extractDeck']) 905 | setComboBoxItem(self.noteTypeComboBox, settings['modelName']) 906 | setComboBoxItem(self.textFieldComboBox, settings['textField']) 907 | setComboBoxItem(self.sourceFieldComboBox, settings['sourceField']) 908 | self.ctrlKeyCheckBox.setChecked(settings['ctrl']) 909 | self.altKeyCheckBox.setChecked(settings['alt']) 910 | self.shiftKeyCheckBox.setChecked(settings['shift']) 911 | setComboBoxItem(self.regularKeyComboBox, settings['regularKey']) 912 | self.quickKeyEditExtractCheckBox.setChecked( 913 | settings['editExtract'] 914 | ) 915 | self.quickKeyEditSourceCheckBox.setChecked(settings['editSource']) 916 | self.quickKeyPlainTextCheckBox.setChecked(settings['plainText']) 917 | self.tagsEditBox.setText(mw.col.tags.join(settings['tags'])) 918 | else: 919 | self._clearQuickKeysTab() 920 | 921 | def _updateFieldLists(self): 922 | self.textFieldComboBox.clear() 923 | modelName = self.noteTypeComboBox.currentText() 924 | self.textFieldComboBox.addItems(getFieldNames(modelName)) 925 | self._updateSourceFieldComboBox() 926 | 927 | def _updateSourceFieldComboBox(self): 928 | self.sourceFieldComboBox.clear() 929 | modelName = self.noteTypeComboBox.currentText() 930 | fieldNames = [ 931 | f 932 | for f in getFieldNames(modelName) 933 | if f != self.textFieldComboBox.currentText() 934 | ] 935 | self.sourceFieldComboBox.addItem('') 936 | self.sourceFieldComboBox.addItems(fieldNames) 937 | 938 | def _clearQuickKeysTab(self): 939 | self.quickKeysComboBox.setCurrentIndex(0) 940 | self.destDeckComboBox.setCurrentIndex(0) 941 | self.noteTypeComboBox.setCurrentIndex(0) 942 | self.textFieldComboBox.setCurrentIndex(0) 943 | self.sourceFieldComboBox.setCurrentIndex(0) 944 | self.ctrlKeyCheckBox.setChecked(False) 945 | self.altKeyCheckBox.setChecked(False) 946 | self.shiftKeyCheckBox.setChecked(False) 947 | self.regularKeyComboBox.setCurrentIndex(0) 948 | self.quickKeyEditExtractCheckBox.setChecked(False) 949 | self.quickKeyEditSourceCheckBox.setChecked(False) 950 | self.quickKeyPlainTextCheckBox.setChecked(False) 951 | self.tagsEditBox.clear() 952 | 953 | def _unsetQuickKey(self): 954 | keyCombo = self.quickKeysComboBox.currentText() 955 | if keyCombo: 956 | self.settings['quickKeys'].pop(keyCombo) 957 | removeComboBoxItem(self.quickKeysComboBox, keyCombo) 958 | self._clearQuickKeysTab() 959 | self._populateTargetComboBox() 960 | self.settings.loadMenuItems() 961 | 962 | def _setQuickKey(self): 963 | tags = mw.col.tags.canonify( 964 | mw.col.tags.split(normalize('NFC', self.tagsEditBox.text())) 965 | ) 966 | 967 | settings = { 968 | 'alt': self.altKeyCheckBox.isChecked(), 969 | 'ctrl': self.ctrlKeyCheckBox.isChecked(), 970 | 'editExtract': self.quickKeyEditExtractCheckBox.isChecked(), 971 | 'editSource': self.quickKeyEditSourceCheckBox.isChecked(), 972 | 'extractBgColor': self.bgColorComboBox.currentText(), 973 | 'extractDeck': self.destDeckComboBox.currentText(), 974 | 'extractTextColor': self.textColorComboBox.currentText(), 975 | 'isQuickKey': True, 976 | 'modelName': self.noteTypeComboBox.currentText(), 977 | 'plainText': self.quickKeyPlainTextCheckBox.isChecked(), 978 | 'regularKey': self.regularKeyComboBox.currentText(), 979 | 'shift': self.shiftKeyCheckBox.isChecked(), 980 | 'sourceField': self.sourceFieldComboBox.currentText(), 981 | 'tags': tags, 982 | 'textField': self.textFieldComboBox.currentText(), 983 | } 984 | 985 | for k in ['extractDeck', 'modelName', 'regularKey']: 986 | if not settings[k]: 987 | showInfo( 988 | 'Please complete all settings. Destination deck, ' 989 | 'note type, and a letter or number for the key ' 990 | 'combination are required.' 991 | ) 992 | return 993 | 994 | keyCombo = '' 995 | if settings['ctrl']: 996 | keyCombo += 'Ctrl+' 997 | if settings['alt']: 998 | keyCombo += 'Alt+' 999 | if settings['shift']: 1000 | keyCombo += 'Shift+' 1001 | keyCombo += settings['regularKey'] 1002 | 1003 | if keyCombo in self.settings['quickKeys']: 1004 | tooltip('Shortcut updated') 1005 | else: 1006 | self.quickKeysComboBox.addItem(keyCombo) 1007 | tooltip('New shortcut added: %s' % keyCombo) 1008 | 1009 | self.settings['quickKeys'][keyCombo] = settings 1010 | setComboBoxItem(self.quickKeysComboBox, keyCombo) 1011 | self._populateTargetComboBox() 1012 | self.settings.loadMenuItems() 1013 | 1014 | def _getZoomGroupBox(self): 1015 | zoomStepLabel = QLabel('Zoom Step') 1016 | zoomStepPercentLabel = QLabel('%') 1017 | generalZoomLabel = QLabel('General Zoom') 1018 | generalZoomPercentLabel = QLabel('%') 1019 | 1020 | zoomStepPercent = round(self.settings['zoomStep'] * 100) 1021 | generalZoomPercent = round(self.settings['generalZoom'] * 100) 1022 | self.zoomStepSpinBox = createSpinBox(zoomStepPercent, 5, 100, 5) 1023 | self.generalZoomSpinBox = createSpinBox( 1024 | generalZoomPercent, 10, 200, 10 1025 | ) 1026 | 1027 | zoomStepLayout = QHBoxLayout() 1028 | zoomStepLayout.addWidget(zoomStepLabel) 1029 | zoomStepLayout.addStretch() 1030 | zoomStepLayout.addWidget(self.zoomStepSpinBox) 1031 | zoomStepLayout.addWidget(zoomStepPercentLabel) 1032 | 1033 | generalZoomLayout = QHBoxLayout() 1034 | generalZoomLayout.addWidget(generalZoomLabel) 1035 | generalZoomLayout.addStretch() 1036 | generalZoomLayout.addWidget(self.generalZoomSpinBox) 1037 | generalZoomLayout.addWidget(generalZoomPercentLabel) 1038 | 1039 | layout = QVBoxLayout() 1040 | layout.addLayout(zoomStepLayout) 1041 | layout.addLayout(generalZoomLayout) 1042 | layout.addStretch() 1043 | 1044 | groupBox = QGroupBox('Zoom') 1045 | groupBox.setLayout(layout) 1046 | 1047 | return groupBox 1048 | 1049 | def _getScrollGroupBox(self): 1050 | lineStepLabel = QLabel('Line Step') 1051 | lineStepPercentLabel = QLabel('%') 1052 | pageStepLabel = QLabel('Page Step') 1053 | pageStepPercentLabel = QLabel('%') 1054 | 1055 | lineStepPercent = round(self.settings['lineScrollFactor'] * 100) 1056 | pageStepPercent = round(self.settings['pageScrollFactor'] * 100) 1057 | self.lineStepSpinBox = createSpinBox(lineStepPercent, 5, 100, 5) 1058 | self.pageStepSpinBox = createSpinBox(pageStepPercent, 5, 100, 5) 1059 | 1060 | lineStepLayout = QHBoxLayout() 1061 | lineStepLayout.addWidget(lineStepLabel) 1062 | lineStepLayout.addStretch() 1063 | lineStepLayout.addWidget(self.lineStepSpinBox) 1064 | lineStepLayout.addWidget(lineStepPercentLabel) 1065 | 1066 | pageStepLayout = QHBoxLayout() 1067 | pageStepLayout.addWidget(pageStepLabel) 1068 | pageStepLayout.addStretch() 1069 | pageStepLayout.addWidget(self.pageStepSpinBox) 1070 | pageStepLayout.addWidget(pageStepPercentLabel) 1071 | 1072 | layout = QVBoxLayout() 1073 | layout.addLayout(lineStepLayout) 1074 | layout.addLayout(pageStepLayout) 1075 | layout.addStretch() 1076 | 1077 | groupBox = QGroupBox('Scroll') 1078 | groupBox.setLayout(layout) 1079 | 1080 | return groupBox 1081 | 1082 | def _getImportingTab(self): 1083 | importDeckLabel = QLabel('Imports Deck') 1084 | self.importDeckComboBox = QComboBox() 1085 | self.importDeckComboBox.setFixedWidth(400) 1086 | deckNames = sorted([d['name'] for d in mw.col.decks.all()]) 1087 | self.importDeckComboBox.addItem('[Current Deck]') 1088 | self.importDeckComboBox.addItems(deckNames) 1089 | 1090 | if self.settings['importDeck']: 1091 | setComboBoxItem( 1092 | self.importDeckComboBox, self.settings['importDeck'] 1093 | ) 1094 | else: 1095 | setComboBoxItem(self.importDeckComboBox, '[Current Deck]') 1096 | 1097 | importDeckLayout = QHBoxLayout() 1098 | importDeckLayout.addWidget(importDeckLabel) 1099 | importDeckLayout.addStretch() 1100 | importDeckLayout.addWidget(self.importDeckComboBox) 1101 | 1102 | sourceFormatLabel = QLabel('Source Format') 1103 | self.sourceFormatEditBox = QLineEdit() 1104 | self.sourceFormatEditBox.setFixedWidth(400) 1105 | self.sourceFormatEditBox.setText(str(self.settings['sourceFormat'])) 1106 | font = QFont('Lucida Sans Typewriter') 1107 | font.setStyleHint(QFont.StyleHint.Monospace) 1108 | self.sourceFormatEditBox.setFont(font) 1109 | 1110 | sourceFormatLayout = QHBoxLayout() 1111 | sourceFormatLayout.addWidget(sourceFormatLabel) 1112 | sourceFormatLayout.addStretch() 1113 | sourceFormatLayout.addWidget(self.sourceFormatEditBox) 1114 | 1115 | layout = QVBoxLayout() 1116 | layout.addLayout(importDeckLayout) 1117 | layout.addLayout(sourceFormatLayout) 1118 | layout.addStretch() 1119 | 1120 | tab = QWidget() 1121 | tab.setLayout(layout) 1122 | 1123 | return tab 1124 | -------------------------------------------------------------------------------- /ir/importer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Timothée Chauvin 2 | # Copyright 2017-2019 Joseph Lorimer 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 datetime import date 17 | from urllib.error import HTTPError 18 | from urllib.parse import urlsplit, urljoin, urlparse 19 | 20 | from anki.notes import Note 21 | from aqt import mw 22 | from aqt.utils import ( 23 | chooseList, 24 | getText, 25 | showInfo, 26 | showCritical, 27 | showWarning, 28 | tooltip, 29 | ) 30 | 31 | from PyQt5.QtCore import Qt 32 | from PyQt5.QtWidgets import ( 33 | QAbstractItemView, 34 | QDialog, 35 | QDialogButtonBox, 36 | QListWidget, 37 | QListWidgetItem, 38 | QVBoxLayout, 39 | ) 40 | 41 | from bs4 import BeautifulSoup, Comment 42 | from requests import get 43 | from requests.exceptions import ConnectionError 44 | 45 | from .lib.feedparser import parse 46 | 47 | from .pocket import Pocket 48 | from .settings import SettingsManager 49 | from .util import setField 50 | 51 | 52 | class Importer: 53 | _pocket = None 54 | _settings: SettingsManager = None 55 | 56 | def changeProfile(self, settings: SettingsManager): 57 | self._settings = settings 58 | 59 | def _fetchWebpage(self, url): 60 | headers = {'User-Agent': self._settings['userAgent']} 61 | html = get(url, headers=headers).content 62 | 63 | webpage = BeautifulSoup(html, 'html.parser') 64 | 65 | for tagName in self._settings['badTags']: 66 | for tag in webpage.find_all(tagName): 67 | tag.decompose() 68 | 69 | for c in webpage.find_all(text=lambda s: isinstance(s, Comment)): 70 | c.extract() 71 | 72 | parsed_url = urlparse(url) 73 | base_path = "".join(parsed_url.path.rpartition("/")[:-1]) 74 | base_url = "{}://{}{}".format(parsed_url.scheme, parsed_url.netloc, base_path) 75 | for a in webpage.find_all("a"): 76 | if a.get("href") is not None: 77 | a["href"] = urljoin(base_url, a.get("href", "")) 78 | 79 | return webpage 80 | 81 | def _createNote(self, title, text, source, priority=None): 82 | if self._settings['importDeck']: 83 | deck = mw.col.decks.by_name(self._settings['importDeck']) 84 | if not deck: 85 | showWarning( 86 | 'Destination deck no longer exists. ' 87 | 'Please update your settings.' 88 | ) 89 | return 90 | did = deck['id'] 91 | else: 92 | did = mw.col.conf['curDeck'] 93 | 94 | model = mw.col.models.by_name(self._settings['modelName']) 95 | note = Note(mw.col, model) 96 | setField(note, self._settings['titleField'], title) 97 | setField(note, self._settings['textField'], text) 98 | setField(note, self._settings['sourceField'], source) 99 | if priority: 100 | setField(note, self._settings['prioField'], priority) 101 | note.note_type()['did'] = did 102 | mw.col.addNote(note) 103 | mw.deckBrowser.show() 104 | return mw.col.decks.get(did)['name'] 105 | 106 | def importWebpage(self, url=None, priority=None, silent=False): 107 | if not url: 108 | url, accepted = getText('Enter URL:', title='Import Webpage') 109 | else: 110 | accepted = True 111 | 112 | if not url or not accepted: 113 | return 114 | 115 | if not urlsplit(url).scheme: 116 | url = 'http://' + url 117 | elif urlsplit(url).scheme not in ['http', 'https']: 118 | showCritical('Only HTTP requests are supported.') 119 | return 120 | 121 | try: 122 | webpage = self._fetchWebpage(url) 123 | except HTTPError as error: 124 | showWarning( 125 | 'The remote server has returned an error: ' 126 | 'HTTP Error {} ({})'.format(error.code, error.reason) 127 | ) 128 | return 129 | except ConnectionError as error: 130 | showWarning('There was a problem connecting to the website.') 131 | return 132 | 133 | body = '\n'.join(map(str, webpage.find('body').children)) 134 | source = self._settings['sourceFormat'].format( 135 | date=date.today(), url='%s' % (url, url) 136 | ) 137 | 138 | if self._settings['prioEnabled'] and not priority: 139 | priority = self._getPriority(webpage.title.string) 140 | 141 | deck = self._createNote(webpage.title.string, body, source, priority) 142 | 143 | if not silent: 144 | tooltip('Added to deck: {}'.format(deck)) 145 | 146 | return deck 147 | 148 | def _getPriority(self, name=None): 149 | if name: 150 | prompt = 'Select priority for {}'.format(name) 151 | else: 152 | prompt = 'Select priority for import' 153 | return self._settings['priorities'][ 154 | chooseList(prompt, self._settings['priorities']) 155 | ] 156 | 157 | def importFeed(self): 158 | url, accepted = getText('Enter URL:', title='Import Feed') 159 | 160 | if not url or not accepted: 161 | return 162 | 163 | if not urlsplit(url).scheme: 164 | url = 'http://' + url 165 | 166 | log = self._settings['feedLog'] 167 | 168 | try: 169 | feed = parse( 170 | url, 171 | agent=self._settings['userAgent'], 172 | etag=log[url]['etag'], 173 | modified=log[url]['modified'], 174 | ) 175 | except KeyError: 176 | log[url] = {'downloaded': []} 177 | feed = parse(url, agent=self._settings['userAgent']) 178 | 179 | if feed['status'] not in [200, 301, 302]: 180 | showWarning( 181 | 'The remote server has returned an unexpected status: ' 182 | '{}'.format(feed['status']) 183 | ) 184 | 185 | if self._settings['prioEnabled']: 186 | priority = self._getPriority() 187 | else: 188 | priority = None 189 | 190 | entries = [ 191 | {'text': e['title'], 'data': e} 192 | for e in feed['entries'] 193 | if e['link'] not in log[url]['downloaded'] 194 | ] 195 | 196 | if not entries: 197 | showInfo('There are no new items in this feed.') 198 | return 199 | 200 | selected = self._select(entries) 201 | 202 | if not selected: 203 | return 204 | 205 | n = len(selected) 206 | 207 | mw.progress.start( 208 | label='Importing feed entries...', max=n, immediate=True 209 | ) 210 | 211 | for i, entry in enumerate(selected, start=1): 212 | deck = self.importWebpage(entry['link'], priority, True) 213 | log[url]['downloaded'].append(entry['link']) 214 | mw.progress.update(value=i) 215 | 216 | log[url]['etag'] = feed.etag if hasattr(feed, 'etag') else '' 217 | log[url]['modified'] = ( 218 | feed.modified if hasattr(feed, 'modified') else '' 219 | ) 220 | 221 | mw.progress.finish() 222 | tooltip('Added {} item(s) to deck: {}'.format(n, deck)) 223 | 224 | def importPocket(self): 225 | if not self._pocket: 226 | self._pocket = Pocket() 227 | 228 | articles = self._pocket.getArticles() 229 | if not articles: 230 | return 231 | 232 | selected = self._select(articles) 233 | 234 | if self._settings['prioEnabled']: 235 | priority = self._getPriority() 236 | else: 237 | priority = None 238 | 239 | if selected: 240 | n = len(selected) 241 | 242 | mw.progress.start( 243 | label='Importing Pocket articles...', max=n, immediate=True 244 | ) 245 | 246 | for i, article in enumerate(selected, start=1): 247 | deck = self.importWebpage(article['given_url'], priority, True) 248 | if self._settings['pocketArchive']: 249 | self._pocket.archive(article) 250 | mw.progress.update(value=i) 251 | 252 | mw.progress.finish() 253 | tooltip('Added {} item(s) to deck: {}'.format(n, deck)) 254 | 255 | def _select(self, choices): 256 | if not choices: 257 | return [] 258 | 259 | dialog = QDialog(mw) 260 | layout = QVBoxLayout() 261 | listWidget = QListWidget() 262 | listWidget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) 263 | 264 | for c in choices: 265 | item = QListWidgetItem(c['text']) 266 | item.setData(Qt.ItemDataRole.UserRole, c['data']) 267 | listWidget.addItem(item) 268 | 269 | buttonBox = QDialogButtonBox( 270 | QDialogButtonBox.StandardButton.Close | QDialogButtonBox.StandardButton.Save 271 | ) 272 | buttonBox.accepted.connect(dialog.accept) 273 | buttonBox.rejected.connect(dialog.reject) 274 | buttonBox.setOrientation(Qt.Orientation.Horizontal) 275 | 276 | layout.addWidget(listWidget) 277 | layout.addWidget(buttonBox) 278 | 279 | dialog.setLayout(layout) 280 | dialog.setWindowModality(Qt.WindowModality.WindowModal) 281 | dialog.resize(500, 500) 282 | choice = dialog.exec() 283 | 284 | if choice == 1: 285 | return [ 286 | listWidget.item(i).data(Qt.ItemDataRole.UserRole) 287 | for i in range(listWidget.count()) 288 | if listWidget.item(i).isSelected() 289 | ] 290 | return [] 291 | -------------------------------------------------------------------------------- /ir/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/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 | from io import StringIO, BytesIO, TextIOWrapper 35 | from collections.abc import Mapping 36 | import sys 37 | import os 38 | import urllib.parse 39 | from email.parser import FeedParser 40 | from email.message import Message 41 | from warnings import warn 42 | import html 43 | import locale 44 | import tempfile 45 | 46 | __all__ = ["MiniFieldStorage", "FieldStorage", 47 | "parse", "parse_qs", "parse_qsl", "parse_multipart", 48 | "parse_header", "test", "print_exception", "print_environ", 49 | "print_form", "print_directory", "print_arguments", 50 | "print_environ_usage", "escape"] 51 | 52 | # Logging support 53 | # =============== 54 | 55 | logfile = "" # Filename to log to, if not empty 56 | logfp = None # File object to log to, if not None 57 | 58 | def initlog(*allargs): 59 | """Write a log message, if there is a log file. 60 | 61 | Even though this function is called initlog(), you should always 62 | use log(); log is a variable that is set either to initlog 63 | (initially), to dolog (once the log file has been opened), or to 64 | nolog (when logging is disabled). 65 | 66 | The first argument is a format string; the remaining arguments (if 67 | any) are arguments to the % operator, so e.g. 68 | log("%s: %s", "a", "b") 69 | will write "a: b" to the log file, followed by a newline. 70 | 71 | If the global logfp is not None, it should be a file object to 72 | which log data is written. 73 | 74 | If the global logfp is None, the global logfile may be a string 75 | giving a filename to open, in append mode. This file should be 76 | world writable!!! If the file can't be opened, logging is 77 | silently disabled (since there is no safe place where we could 78 | send an error message). 79 | 80 | """ 81 | global log, logfile, logfp 82 | if logfile and not logfp: 83 | try: 84 | logfp = open(logfile, "a") 85 | except OSError: 86 | pass 87 | if not logfp: 88 | log = nolog 89 | else: 90 | log = dolog 91 | log(*allargs) 92 | 93 | def dolog(fmt, *args): 94 | """Write a log message to the log file. See initlog() for docs.""" 95 | logfp.write(fmt%args + "\n") 96 | 97 | def nolog(*allargs): 98 | """Dummy function, assigned to log when logging is disabled.""" 99 | pass 100 | 101 | def closelog(): 102 | """Close the log file.""" 103 | global log, logfile, logfp 104 | logfile = '' 105 | if logfp: 106 | logfp.close() 107 | logfp = None 108 | log = initlog 109 | 110 | log = initlog # The current logging function 111 | 112 | 113 | # Parsing functions 114 | # ================= 115 | 116 | # Maximum input we will accept when REQUEST_METHOD is POST 117 | # 0 ==> unlimited input 118 | maxlen = 0 119 | 120 | def parse(fp=None, environ=os.environ, keep_blank_values=0, strict_parsing=0): 121 | """Parse a query in the environment or from a file (default stdin) 122 | 123 | Arguments, all optional: 124 | 125 | fp : file pointer; default: sys.stdin.buffer 126 | 127 | environ : environment dictionary; default: os.environ 128 | 129 | keep_blank_values: flag indicating whether blank values in 130 | percent-encoded forms should be treated as blank strings. 131 | A true value indicates that blanks should be retained as 132 | blank strings. The default false value indicates that 133 | blank values are to be ignored and treated as if they were 134 | not included. 135 | 136 | strict_parsing: flag indicating what to do with parsing errors. 137 | If false (the default), errors are silently ignored. 138 | If true, errors raise a ValueError exception. 139 | """ 140 | if fp is None: 141 | fp = sys.stdin 142 | 143 | # field keys and values (except for files) are returned as strings 144 | # an encoding is required to decode the bytes read from self.fp 145 | if hasattr(fp,'encoding'): 146 | encoding = fp.encoding 147 | else: 148 | encoding = 'latin-1' 149 | 150 | # fp.read() must return bytes 151 | if isinstance(fp, TextIOWrapper): 152 | fp = fp.buffer 153 | 154 | if not 'REQUEST_METHOD' in environ: 155 | environ['REQUEST_METHOD'] = 'GET' # For testing stand-alone 156 | if environ['REQUEST_METHOD'] == 'POST': 157 | ctype, pdict = parse_header(environ['CONTENT_TYPE']) 158 | if ctype == 'multipart/form-data': 159 | return parse_multipart(fp, pdict) 160 | elif ctype == 'application/x-www-form-urlencoded': 161 | clength = int(environ['CONTENT_LENGTH']) 162 | if maxlen and clength > maxlen: 163 | raise ValueError('Maximum content length exceeded') 164 | qs = fp.read(clength).decode(encoding) 165 | else: 166 | qs = '' # Unknown content-type 167 | if 'QUERY_STRING' in environ: 168 | if qs: qs = qs + '&' 169 | qs = qs + environ['QUERY_STRING'] 170 | elif sys.argv[1:]: 171 | if qs: qs = qs + '&' 172 | qs = qs + sys.argv[1] 173 | environ['QUERY_STRING'] = qs # XXX Shouldn't, really 174 | elif 'QUERY_STRING' in environ: 175 | qs = environ['QUERY_STRING'] 176 | else: 177 | if sys.argv[1:]: 178 | qs = sys.argv[1] 179 | else: 180 | qs = "" 181 | environ['QUERY_STRING'] = qs # XXX Shouldn't, really 182 | return urllib.parse.parse_qs(qs, keep_blank_values, strict_parsing, 183 | encoding=encoding) 184 | 185 | 186 | # parse query string function called from urlparse, 187 | # this is done in order to maintain backward compatibility. 188 | 189 | def parse_qs(qs, keep_blank_values=0, strict_parsing=0): 190 | """Parse a query given as a string argument.""" 191 | warn("cgi.parse_qs is deprecated, use urllib.parse.parse_qs instead", 192 | DeprecationWarning, 2) 193 | return urllib.parse.parse_qs(qs, keep_blank_values, strict_parsing) 194 | 195 | def parse_qsl(qs, keep_blank_values=0, strict_parsing=0): 196 | """Parse a query given as a string argument.""" 197 | warn("cgi.parse_qsl is deprecated, use urllib.parse.parse_qsl instead", 198 | DeprecationWarning, 2) 199 | return urllib.parse.parse_qsl(qs, keep_blank_values, strict_parsing) 200 | 201 | def parse_multipart(fp, pdict, encoding="utf-8", errors="replace"): 202 | """Parse multipart input. 203 | 204 | Arguments: 205 | fp : input file 206 | pdict: dictionary containing other parameters of content-type header 207 | encoding, errors: request encoding and error handler, passed to 208 | FieldStorage 209 | 210 | Returns a dictionary just like parse_qs(): keys are the field names, each 211 | value is a list of values for that field. For non-file fields, the value 212 | is a list of strings. 213 | """ 214 | # RFC 2026, Section 5.1 : The "multipart" boundary delimiters are always 215 | # represented as 7bit US-ASCII. 216 | boundary = pdict['boundary'].decode('ascii') 217 | ctype = "multipart/form-data; boundary={}".format(boundary) 218 | headers = Message() 219 | headers.set_type(ctype) 220 | headers['Content-Length'] = pdict['CONTENT-LENGTH'] 221 | fs = FieldStorage(fp, headers=headers, encoding=encoding, errors=errors, 222 | environ={'REQUEST_METHOD': 'POST'}) 223 | return {k: fs.getlist(k) for k in fs} 224 | 225 | def _parseparam(s): 226 | while s[:1] == ';': 227 | s = s[1:] 228 | end = s.find(';') 229 | while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2: 230 | end = s.find(';', end + 1) 231 | if end < 0: 232 | end = len(s) 233 | f = s[:end] 234 | yield f.strip() 235 | s = s[end:] 236 | 237 | def parse_header(line): 238 | """Parse a Content-type like header. 239 | 240 | Return the main content-type and a dictionary of options. 241 | 242 | """ 243 | parts = _parseparam(';' + line) 244 | key = parts.__next__() 245 | pdict = {} 246 | for p in parts: 247 | i = p.find('=') 248 | if i >= 0: 249 | name = p[:i].strip().lower() 250 | value = p[i+1:].strip() 251 | if len(value) >= 2 and value[0] == value[-1] == '"': 252 | value = value[1:-1] 253 | value = value.replace('\\\\', '\\').replace('\\"', '"') 254 | pdict[name] = value 255 | return key, pdict 256 | 257 | 258 | # Classes for field storage 259 | # ========================= 260 | 261 | class MiniFieldStorage: 262 | 263 | """Like FieldStorage, for use when no file uploads are possible.""" 264 | 265 | # Dummy attributes 266 | filename = None 267 | list = None 268 | type = None 269 | file = None 270 | type_options = {} 271 | disposition = None 272 | disposition_options = {} 273 | headers = {} 274 | 275 | def __init__(self, name, value): 276 | """Constructor from field name and value.""" 277 | self.name = name 278 | self.value = value 279 | # self.file = StringIO(value) 280 | 281 | def __repr__(self): 282 | """Return printable representation.""" 283 | return "MiniFieldStorage(%r, %r)" % (self.name, self.value) 284 | 285 | 286 | class FieldStorage: 287 | 288 | """Store a sequence of fields, reading multipart/form-data. 289 | 290 | This class provides naming, typing, files stored on disk, and 291 | more. At the top level, it is accessible like a dictionary, whose 292 | keys are the field names. (Note: None can occur as a field name.) 293 | The items are either a Python list (if there's multiple values) or 294 | another FieldStorage or MiniFieldStorage object. If it's a single 295 | object, it has the following attributes: 296 | 297 | name: the field name, if specified; otherwise None 298 | 299 | filename: the filename, if specified; otherwise None; this is the 300 | client side filename, *not* the file name on which it is 301 | stored (that's a temporary file you don't deal with) 302 | 303 | value: the value as a *string*; for file uploads, this 304 | transparently reads the file every time you request the value 305 | and returns *bytes* 306 | 307 | file: the file(-like) object from which you can read the data *as 308 | bytes* ; None if the data is stored a simple string 309 | 310 | type: the content-type, or None if not specified 311 | 312 | type_options: dictionary of options specified on the content-type 313 | line 314 | 315 | disposition: content-disposition, or None if not specified 316 | 317 | disposition_options: dictionary of corresponding options 318 | 319 | headers: a dictionary(-like) object (sometimes email.message.Message or a 320 | subclass thereof) containing *all* headers 321 | 322 | The class is subclassable, mostly for the purpose of overriding 323 | the make_file() method, which is called internally to come up with 324 | a file open for reading and writing. This makes it possible to 325 | override the default choice of storing all files in a temporary 326 | directory and unlinking them as soon as they have been opened. 327 | 328 | """ 329 | def __init__(self, fp=None, headers=None, outerboundary=b'', 330 | environ=os.environ, keep_blank_values=0, strict_parsing=0, 331 | limit=None, encoding='utf-8', errors='replace', 332 | max_num_fields=None): 333 | """Constructor. Read multipart/* until last part. 334 | 335 | Arguments, all optional: 336 | 337 | fp : file pointer; default: sys.stdin.buffer 338 | (not used when the request method is GET) 339 | Can be : 340 | 1. a TextIOWrapper object 341 | 2. an object whose read() and readline() methods return bytes 342 | 343 | headers : header dictionary-like object; default: 344 | taken from environ as per CGI spec 345 | 346 | outerboundary : terminating multipart boundary 347 | (for internal use only) 348 | 349 | environ : environment dictionary; default: os.environ 350 | 351 | keep_blank_values: flag indicating whether blank values in 352 | percent-encoded forms should be treated as blank strings. 353 | A true value indicates that blanks should be retained as 354 | blank strings. The default false value indicates that 355 | blank values are to be ignored and treated as if they were 356 | not included. 357 | 358 | strict_parsing: flag indicating what to do with parsing errors. 359 | If false (the default), errors are silently ignored. 360 | If true, errors raise a ValueError exception. 361 | 362 | limit : used internally to read parts of multipart/form-data forms, 363 | to exit from the reading loop when reached. It is the difference 364 | between the form content-length and the number of bytes already 365 | read 366 | 367 | encoding, errors : the encoding and error handler used to decode the 368 | binary stream to strings. Must be the same as the charset defined 369 | for the page sending the form (content-type : meta http-equiv or 370 | header) 371 | 372 | max_num_fields: int. If set, then __init__ throws a ValueError 373 | if there are more than n fields read by parse_qsl(). 374 | 375 | """ 376 | method = 'GET' 377 | self.keep_blank_values = keep_blank_values 378 | self.strict_parsing = strict_parsing 379 | self.max_num_fields = max_num_fields 380 | if 'REQUEST_METHOD' in environ: 381 | method = environ['REQUEST_METHOD'].upper() 382 | self.qs_on_post = None 383 | if method == 'GET' or method == 'HEAD': 384 | if 'QUERY_STRING' in environ: 385 | qs = environ['QUERY_STRING'] 386 | elif sys.argv[1:]: 387 | qs = sys.argv[1] 388 | else: 389 | qs = "" 390 | qs = qs.encode(locale.getpreferredencoding(), 'surrogateescape') 391 | fp = BytesIO(qs) 392 | if headers is None: 393 | headers = {'content-type': 394 | "application/x-www-form-urlencoded"} 395 | if headers is None: 396 | headers = {} 397 | if method == 'POST': 398 | # Set default content-type for POST to what's traditional 399 | headers['content-type'] = "application/x-www-form-urlencoded" 400 | if 'CONTENT_TYPE' in environ: 401 | headers['content-type'] = environ['CONTENT_TYPE'] 402 | if 'QUERY_STRING' in environ: 403 | self.qs_on_post = environ['QUERY_STRING'] 404 | if 'CONTENT_LENGTH' in environ: 405 | headers['content-length'] = environ['CONTENT_LENGTH'] 406 | else: 407 | if not (isinstance(headers, (Mapping, Message))): 408 | raise TypeError("headers must be mapping or an instance of " 409 | "email.message.Message") 410 | self.headers = headers 411 | if fp is None: 412 | self.fp = sys.stdin.buffer 413 | # self.fp.read() must return bytes 414 | elif isinstance(fp, TextIOWrapper): 415 | self.fp = fp.buffer 416 | else: 417 | if not (hasattr(fp, 'read') and hasattr(fp, 'readline')): 418 | raise TypeError("fp must be file pointer") 419 | self.fp = fp 420 | 421 | self.encoding = encoding 422 | self.errors = errors 423 | 424 | if not isinstance(outerboundary, bytes): 425 | raise TypeError('outerboundary must be bytes, not %s' 426 | % type(outerboundary).__name__) 427 | self.outerboundary = outerboundary 428 | 429 | self.bytes_read = 0 430 | self.limit = limit 431 | 432 | # Process content-disposition header 433 | cdisp, pdict = "", {} 434 | if 'content-disposition' in self.headers: 435 | cdisp, pdict = parse_header(self.headers['content-disposition']) 436 | self.disposition = cdisp 437 | self.disposition_options = pdict 438 | self.name = None 439 | if 'name' in pdict: 440 | self.name = pdict['name'] 441 | self.filename = None 442 | if 'filename' in pdict: 443 | self.filename = pdict['filename'] 444 | self._binary_file = self.filename is not None 445 | 446 | # Process content-type header 447 | # 448 | # Honor any existing content-type header. But if there is no 449 | # content-type header, use some sensible defaults. Assume 450 | # outerboundary is "" at the outer level, but something non-false 451 | # inside a multi-part. The default for an inner part is text/plain, 452 | # but for an outer part it should be urlencoded. This should catch 453 | # bogus clients which erroneously forget to include a content-type 454 | # header. 455 | # 456 | # See below for what we do if there does exist a content-type header, 457 | # but it happens to be something we don't understand. 458 | if 'content-type' in self.headers: 459 | ctype, pdict = parse_header(self.headers['content-type']) 460 | elif self.outerboundary or method != 'POST': 461 | ctype, pdict = "text/plain", {} 462 | else: 463 | ctype, pdict = 'application/x-www-form-urlencoded', {} 464 | self.type = ctype 465 | self.type_options = pdict 466 | if 'boundary' in pdict: 467 | self.innerboundary = pdict['boundary'].encode(self.encoding, 468 | self.errors) 469 | else: 470 | self.innerboundary = b"" 471 | 472 | clen = -1 473 | if 'content-length' in self.headers: 474 | try: 475 | clen = int(self.headers['content-length']) 476 | except ValueError: 477 | pass 478 | if maxlen and clen > maxlen: 479 | raise ValueError('Maximum content length exceeded') 480 | self.length = clen 481 | if self.limit is None and clen: 482 | self.limit = clen 483 | 484 | self.list = self.file = None 485 | self.done = 0 486 | if ctype == 'application/x-www-form-urlencoded': 487 | self.read_urlencoded() 488 | elif ctype[:10] == 'multipart/': 489 | self.read_multi(environ, keep_blank_values, strict_parsing) 490 | else: 491 | self.read_single() 492 | 493 | def __del__(self): 494 | try: 495 | self.file.close() 496 | except AttributeError: 497 | pass 498 | 499 | def __enter__(self): 500 | return self 501 | 502 | def __exit__(self, *args): 503 | self.file.close() 504 | 505 | def __repr__(self): 506 | """Return a printable representation.""" 507 | return "FieldStorage(%r, %r, %r)" % ( 508 | self.name, self.filename, self.value) 509 | 510 | def __iter__(self): 511 | return iter(self.keys()) 512 | 513 | def __getattr__(self, name): 514 | if name != 'value': 515 | raise AttributeError(name) 516 | if self.file: 517 | self.file.seek(0) 518 | value = self.file.read() 519 | self.file.seek(0) 520 | elif self.list is not None: 521 | value = self.list 522 | else: 523 | value = None 524 | return value 525 | 526 | def __getitem__(self, key): 527 | """Dictionary style indexing.""" 528 | if self.list is None: 529 | raise TypeError("not indexable") 530 | found = [] 531 | for item in self.list: 532 | if item.name == key: found.append(item) 533 | if not found: 534 | raise KeyError(key) 535 | if len(found) == 1: 536 | return found[0] 537 | else: 538 | return found 539 | 540 | def getvalue(self, key, default=None): 541 | """Dictionary style get() method, including 'value' lookup.""" 542 | if key in self: 543 | value = self[key] 544 | if isinstance(value, list): 545 | return [x.value for x in value] 546 | else: 547 | return value.value 548 | else: 549 | return default 550 | 551 | def getfirst(self, key, default=None): 552 | """ Return the first value received.""" 553 | if key in self: 554 | value = self[key] 555 | if isinstance(value, list): 556 | return value[0].value 557 | else: 558 | return value.value 559 | else: 560 | return default 561 | 562 | def getlist(self, key): 563 | """ Return list of received values.""" 564 | if key in self: 565 | value = self[key] 566 | if isinstance(value, list): 567 | return [x.value for x in value] 568 | else: 569 | return [value.value] 570 | else: 571 | return [] 572 | 573 | def keys(self): 574 | """Dictionary style keys() method.""" 575 | if self.list is None: 576 | raise TypeError("not indexable") 577 | return list(set(item.name for item in self.list)) 578 | 579 | def __contains__(self, key): 580 | """Dictionary style __contains__ method.""" 581 | if self.list is None: 582 | raise TypeError("not indexable") 583 | return any(item.name == key for item in self.list) 584 | 585 | def __len__(self): 586 | """Dictionary style len(x) support.""" 587 | return len(self.keys()) 588 | 589 | def __bool__(self): 590 | if self.list is None: 591 | raise TypeError("Cannot be converted to bool.") 592 | return bool(self.list) 593 | 594 | def read_urlencoded(self): 595 | """Internal: read data in query string format.""" 596 | qs = self.fp.read(self.length) 597 | if not isinstance(qs, bytes): 598 | raise ValueError("%s should return bytes, got %s" \ 599 | % (self.fp, type(qs).__name__)) 600 | qs = qs.decode(self.encoding, self.errors) 601 | if self.qs_on_post: 602 | qs += '&' + self.qs_on_post 603 | query = urllib.parse.parse_qsl( 604 | qs, self.keep_blank_values, self.strict_parsing, 605 | encoding=self.encoding, errors=self.errors, 606 | max_num_fields=self.max_num_fields) 607 | self.list = [MiniFieldStorage(key, value) for key, value in query] 608 | self.skip_lines() 609 | 610 | FieldStorageClass = None 611 | 612 | def read_multi(self, environ, keep_blank_values, strict_parsing): 613 | """Internal: read a part that is itself multipart.""" 614 | ib = self.innerboundary 615 | if not valid_boundary(ib): 616 | raise ValueError('Invalid boundary in multipart form: %r' % (ib,)) 617 | self.list = [] 618 | if self.qs_on_post: 619 | query = urllib.parse.parse_qsl( 620 | self.qs_on_post, self.keep_blank_values, self.strict_parsing, 621 | encoding=self.encoding, errors=self.errors, 622 | max_num_fields=self.max_num_fields) 623 | self.list.extend(MiniFieldStorage(key, value) for key, value in query) 624 | 625 | klass = self.FieldStorageClass or self.__class__ 626 | first_line = self.fp.readline() # bytes 627 | if not isinstance(first_line, bytes): 628 | raise ValueError("%s should return bytes, got %s" \ 629 | % (self.fp, type(first_line).__name__)) 630 | self.bytes_read += len(first_line) 631 | 632 | # Ensure that we consume the file until we've hit our inner boundary 633 | while (first_line.strip() != (b"--" + self.innerboundary) and 634 | first_line): 635 | first_line = self.fp.readline() 636 | self.bytes_read += len(first_line) 637 | 638 | # Propagate max_num_fields into the sub class appropriately 639 | max_num_fields = self.max_num_fields 640 | if max_num_fields is not None: 641 | max_num_fields -= len(self.list) 642 | 643 | while True: 644 | parser = FeedParser() 645 | hdr_text = b"" 646 | while True: 647 | data = self.fp.readline() 648 | hdr_text += data 649 | if not data.strip(): 650 | break 651 | if not hdr_text: 652 | break 653 | # parser takes strings, not bytes 654 | self.bytes_read += len(hdr_text) 655 | parser.feed(hdr_text.decode(self.encoding, self.errors)) 656 | headers = parser.close() 657 | 658 | # Some clients add Content-Length for part headers, ignore them 659 | if 'content-length' in headers: 660 | del headers['content-length'] 661 | 662 | part = klass(self.fp, headers, ib, environ, keep_blank_values, 663 | strict_parsing,self.limit-self.bytes_read, 664 | self.encoding, self.errors, max_num_fields) 665 | 666 | if max_num_fields is not None: 667 | max_num_fields -= 1 668 | if part.list: 669 | max_num_fields -= len(part.list) 670 | if max_num_fields < 0: 671 | raise ValueError('Max number of fields exceeded') 672 | 673 | self.bytes_read += part.bytes_read 674 | self.list.append(part) 675 | if part.done or self.bytes_read >= self.length > 0: 676 | break 677 | self.skip_lines() 678 | 679 | def read_single(self): 680 | """Internal: read an atomic part.""" 681 | if self.length >= 0: 682 | self.read_binary() 683 | self.skip_lines() 684 | else: 685 | self.read_lines() 686 | self.file.seek(0) 687 | 688 | bufsize = 8*1024 # I/O buffering size for copy to file 689 | 690 | def read_binary(self): 691 | """Internal: read binary data.""" 692 | self.file = self.make_file() 693 | todo = self.length 694 | if todo >= 0: 695 | while todo > 0: 696 | data = self.fp.read(min(todo, self.bufsize)) # bytes 697 | if not isinstance(data, bytes): 698 | raise ValueError("%s should return bytes, got %s" 699 | % (self.fp, type(data).__name__)) 700 | self.bytes_read += len(data) 701 | if not data: 702 | self.done = -1 703 | break 704 | self.file.write(data) 705 | todo = todo - len(data) 706 | 707 | def read_lines(self): 708 | """Internal: read lines until EOF or outerboundary.""" 709 | if self._binary_file: 710 | self.file = self.__file = BytesIO() # store data as bytes for files 711 | else: 712 | self.file = self.__file = StringIO() # as strings for other fields 713 | if self.outerboundary: 714 | self.read_lines_to_outerboundary() 715 | else: 716 | self.read_lines_to_eof() 717 | 718 | def __write(self, line): 719 | """line is always bytes, not string""" 720 | if self.__file is not None: 721 | if self.__file.tell() + len(line) > 1000: 722 | self.file = self.make_file() 723 | data = self.__file.getvalue() 724 | self.file.write(data) 725 | self.__file = None 726 | if self._binary_file: 727 | # keep bytes 728 | self.file.write(line) 729 | else: 730 | # decode to string 731 | self.file.write(line.decode(self.encoding, self.errors)) 732 | 733 | def read_lines_to_eof(self): 734 | """Internal: read lines until EOF.""" 735 | while 1: 736 | line = self.fp.readline(1<<16) # bytes 737 | self.bytes_read += len(line) 738 | if not line: 739 | self.done = -1 740 | break 741 | self.__write(line) 742 | 743 | def read_lines_to_outerboundary(self): 744 | """Internal: read lines until outerboundary. 745 | Data is read as bytes: boundaries and line ends must be converted 746 | to bytes for comparisons. 747 | """ 748 | next_boundary = b"--" + self.outerboundary 749 | last_boundary = next_boundary + b"--" 750 | delim = b"" 751 | last_line_lfend = True 752 | _read = 0 753 | while 1: 754 | if _read >= self.limit: 755 | break 756 | line = self.fp.readline(1<<16) # bytes 757 | self.bytes_read += len(line) 758 | _read += len(line) 759 | if not line: 760 | self.done = -1 761 | break 762 | if delim == b"\r": 763 | line = delim + line 764 | delim = b"" 765 | if line.startswith(b"--") and last_line_lfend: 766 | strippedline = line.rstrip() 767 | if strippedline == next_boundary: 768 | break 769 | if strippedline == last_boundary: 770 | self.done = 1 771 | break 772 | odelim = delim 773 | if line.endswith(b"\r\n"): 774 | delim = b"\r\n" 775 | line = line[:-2] 776 | last_line_lfend = True 777 | elif line.endswith(b"\n"): 778 | delim = b"\n" 779 | line = line[:-1] 780 | last_line_lfend = True 781 | elif line.endswith(b"\r"): 782 | # We may interrupt \r\n sequences if they span the 2**16 783 | # byte boundary 784 | delim = b"\r" 785 | line = line[:-1] 786 | last_line_lfend = False 787 | else: 788 | delim = b"" 789 | last_line_lfend = False 790 | self.__write(odelim + line) 791 | 792 | def skip_lines(self): 793 | """Internal: skip lines until outer boundary if defined.""" 794 | if not self.outerboundary or self.done: 795 | return 796 | next_boundary = b"--" + self.outerboundary 797 | last_boundary = next_boundary + b"--" 798 | last_line_lfend = True 799 | while True: 800 | line = self.fp.readline(1<<16) 801 | self.bytes_read += len(line) 802 | if not line: 803 | self.done = -1 804 | break 805 | if line.endswith(b"--") and last_line_lfend: 806 | strippedline = line.strip() 807 | if strippedline == next_boundary: 808 | break 809 | if strippedline == last_boundary: 810 | self.done = 1 811 | break 812 | last_line_lfend = line.endswith(b'\n') 813 | 814 | def make_file(self): 815 | """Overridable: return a readable & writable file. 816 | 817 | The file will be used as follows: 818 | - data is written to it 819 | - seek(0) 820 | - data is read from it 821 | 822 | The file is opened in binary mode for files, in text mode 823 | for other fields 824 | 825 | This version opens a temporary file for reading and writing, 826 | and immediately deletes (unlinks) it. The trick (on Unix!) is 827 | that the file can still be used, but it can't be opened by 828 | another process, and it will automatically be deleted when it 829 | is closed or when the current process terminates. 830 | 831 | If you want a more permanent file, you derive a class which 832 | overrides this method. If you want a visible temporary file 833 | that is nevertheless automatically deleted when the script 834 | terminates, try defining a __del__ method in a derived class 835 | which unlinks the temporary files you have created. 836 | 837 | """ 838 | if self._binary_file: 839 | return tempfile.TemporaryFile("wb+") 840 | else: 841 | return tempfile.TemporaryFile("w+", 842 | encoding=self.encoding, newline = '\n') 843 | 844 | 845 | # Test/debug code 846 | # =============== 847 | 848 | def test(environ=os.environ): 849 | """Robust test CGI script, usable as main program. 850 | 851 | Write minimal HTTP headers and dump all information provided to 852 | the script in HTML form. 853 | 854 | """ 855 | print("Content-type: text/html") 856 | print() 857 | sys.stderr = sys.stdout 858 | try: 859 | form = FieldStorage() # Replace with other classes to test those 860 | print_directory() 861 | print_arguments() 862 | print_form(form) 863 | print_environ(environ) 864 | print_environ_usage() 865 | def f(): 866 | exec("testing print_exception() -- italics?") 867 | def g(f=f): 868 | f() 869 | print("

What follows is a test, not an actual exception:

") 870 | g() 871 | except: 872 | print_exception() 873 | 874 | print("

Second try with a small maxlen...

") 875 | 876 | global maxlen 877 | maxlen = 50 878 | try: 879 | form = FieldStorage() # Replace with other classes to test those 880 | print_directory() 881 | print_arguments() 882 | print_form(form) 883 | print_environ(environ) 884 | except: 885 | print_exception() 886 | 887 | def print_exception(type=None, value=None, tb=None, limit=None): 888 | if type is None: 889 | type, value, tb = sys.exc_info() 890 | import traceback 891 | print() 892 | print("

Traceback (most recent call last):

") 893 | list = traceback.format_tb(tb, limit) + \ 894 | traceback.format_exception_only(type, value) 895 | print("
%s%s
" % ( 896 | html.escape("".join(list[:-1])), 897 | html.escape(list[-1]), 898 | )) 899 | del tb 900 | 901 | def print_environ(environ=os.environ): 902 | """Dump the shell environment as HTML.""" 903 | keys = sorted(environ.keys()) 904 | print() 905 | print("

Shell Environment:

") 906 | print("
") 907 | for key in keys: 908 | print("
", html.escape(key), "
", html.escape(environ[key])) 909 | print("
") 910 | print() 911 | 912 | def print_form(form): 913 | """Dump the contents of a form as HTML.""" 914 | keys = sorted(form.keys()) 915 | print() 916 | print("

Form Contents:

") 917 | if not keys: 918 | print("

No form fields.") 919 | print("

") 920 | for key in keys: 921 | print("
" + html.escape(key) + ":", end=' ') 922 | value = form[key] 923 | print("" + html.escape(repr(type(value))) + "") 924 | print("
" + html.escape(repr(value))) 925 | print("
") 926 | print() 927 | 928 | def print_directory(): 929 | """Dump the current directory as HTML.""" 930 | print() 931 | print("

Current Working Directory:

") 932 | try: 933 | pwd = os.getcwd() 934 | except OSError as msg: 935 | print("OSError:", html.escape(str(msg))) 936 | else: 937 | print(html.escape(pwd)) 938 | print() 939 | 940 | def print_arguments(): 941 | print() 942 | print("

Command Line Arguments:

") 943 | print() 944 | print(sys.argv) 945 | print() 946 | 947 | def print_environ_usage(): 948 | """Dump a list of environment variables used by CGI as HTML.""" 949 | print(""" 950 |

These environment variables could have been set:

951 |
    952 |
  • AUTH_TYPE 953 |
  • CONTENT_LENGTH 954 |
  • CONTENT_TYPE 955 |
  • DATE_GMT 956 |
  • DATE_LOCAL 957 |
  • DOCUMENT_NAME 958 |
  • DOCUMENT_ROOT 959 |
  • DOCUMENT_URI 960 |
  • GATEWAY_INTERFACE 961 |
  • LAST_MODIFIED 962 |
  • PATH 963 |
  • PATH_INFO 964 |
  • PATH_TRANSLATED 965 |
  • QUERY_STRING 966 |
  • REMOTE_ADDR 967 |
  • REMOTE_HOST 968 |
  • REMOTE_IDENT 969 |
  • REMOTE_USER 970 |
  • REQUEST_METHOD 971 |
  • SCRIPT_NAME 972 |
  • SERVER_NAME 973 |
  • SERVER_PORT 974 |
  • SERVER_PROTOCOL 975 |
  • SERVER_ROOT 976 |
  • SERVER_SOFTWARE 977 |
978 | In addition, HTTP headers sent by the server may be passed in the 979 | environment as well. Here are some common variable names: 980 |
    981 |
  • HTTP_ACCEPT 982 |
  • HTTP_CONNECTION 983 |
  • HTTP_HOST 984 |
  • HTTP_PRAGMA 985 |
  • HTTP_REFERER 986 |
  • HTTP_USER_AGENT 987 |
988 | """) 989 | 990 | 991 | # Utilities 992 | # ========= 993 | 994 | def escape(s, quote=None): 995 | """Deprecated API.""" 996 | warn("cgi.escape is deprecated, use html.escape instead", 997 | DeprecationWarning, stacklevel=2) 998 | s = s.replace("&", "&") # Must be done first! 999 | s = s.replace("<", "<") 1000 | s = s.replace(">", ">") 1001 | if quote: 1002 | s = s.replace('"', """) 1003 | return s 1004 | 1005 | 1006 | def valid_boundary(s): 1007 | import re 1008 | if isinstance(s, bytes): 1009 | _vb_pattern = b"^[ -~]{0,200}[!-~]$" 1010 | else: 1011 | _vb_pattern = "^[ -~]{0,200}[!-~]$" 1012 | return re.match(_vb_pattern, s) 1013 | 1014 | # Invoke mainline 1015 | # =============== 1016 | 1017 | # Call test() when this file is run as a script (not imported as a module) 1018 | if __name__ == '__main__': 1019 | test() 1020 | -------------------------------------------------------------------------------- /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 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 anki.cards import Card 20 | from anki.hooks import addHook, wrap 21 | from aqt import gui_hooks, mw 22 | from aqt.browser import Browser 23 | from aqt.reviewer import Reviewer 24 | from typing import Any, Sequence 25 | 26 | import sip 27 | 28 | from .about import showAbout 29 | from .gui import SettingsDialog 30 | from .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.importer = Importer() 43 | self.scheduler = Scheduler() 44 | self.textManager = TextManager() 45 | self.viewManager = ViewManager() 46 | gui_hooks.profile_did_open.append(self.onProfileLoaded) 47 | gui_hooks.card_will_show.append(self.onPrepareQA) 48 | 49 | addHook('overviewStateShortcuts', self.setShortcuts) 50 | addHook('reviewStateShortcuts', self.setReviewShortcuts) 51 | 52 | def onProfileLoaded(self) -> None: 53 | self.settings = SettingsManager() 54 | mw.addonManager.setConfigAction( 55 | __name__, lambda: SettingsDialog(self.settings) 56 | ) 57 | self.importer.changeProfile(self.settings) 58 | self.scheduler.changeProfile(self.settings) 59 | self.textManager.changeProfile(self.settings) 60 | self.viewManager.changeProfile(self.settings) 61 | self.viewManager.resetZoom('deckBrowser') 62 | self.addModel() 63 | self.loadMenuItems() 64 | self.shortcuts = [ 65 | (self.settings['extractKey'], self.textManager.extract), 66 | (self.settings['highlightKey'], self.textManager.highlight), 67 | (self.settings['removeKey'], self.textManager.remove), 68 | (self.settings['undoKey'], self.textManager.undo), 69 | (self.settings['overlaySeq'], self.textManager.toggleOverlay), 70 | ( 71 | self.settings['boldSeq'], 72 | lambda: self.textManager.format('bold'), 73 | ), 74 | ( 75 | self.settings['italicSeq'], 76 | lambda: self.textManager.format('italic'), 77 | ), 78 | ( 79 | self.settings['strikeSeq'], 80 | lambda: self.textManager.format('strike'), 81 | ), 82 | ( 83 | self.settings['underlineSeq'], 84 | lambda: self.textManager.format('underline'), 85 | ), 86 | ] 87 | 88 | def loadMenuItems(self): 89 | if hasattr(mw, 'customMenus') and 'Read' in mw.customMenus: 90 | mw.customMenus['Read'].clear() 91 | 92 | addMenuItem( 93 | 'Read', 94 | 'Options...', 95 | lambda: SettingsDialog(self.settings), 96 | 'Alt+1', 97 | ) 98 | addMenuItem('Read', 'Organizer...', self.scheduler.showDialog, 'Alt+2') 99 | addMenuItem( 100 | 'Read', 'Import Webpage', self.importer.importWebpage, 'Alt+3' 101 | ) 102 | addMenuItem('Read', 'Import Feed', self.importer.importFeed, 'Alt+4') 103 | addMenuItem( 104 | 'Read', 'Import Pocket', self.importer.importPocket, 'Alt+5' 105 | ) 106 | addMenuItem('Read', 'Zoom In', self.viewManager.zoomIn, 'Ctrl++') 107 | addMenuItem('Read', 'Zoom Out', self.viewManager.zoomOut, 'Ctrl+-') 108 | addMenuItem('Read', 'About...', showAbout) 109 | 110 | self.settings.loadMenuItems() 111 | 112 | def onPrepareQA(self, text: str, card: Card, kind: str) -> str: 113 | if self.settings['prioEnabled']: 114 | answerShortcuts = ['1', '2', '3', '4'] 115 | else: 116 | answerShortcuts = ['4'] 117 | 118 | activeAnswerShortcuts = [ 119 | next( 120 | (s for s in mw.stateShortcuts if s.key().toString() == i), None 121 | ) 122 | for i in answerShortcuts 123 | ] 124 | 125 | if isIrCard(card): 126 | for shortcut in activeAnswerShortcuts: 127 | if shortcut: 128 | mw.stateShortcuts.remove(shortcut) 129 | sip.delete(shortcut) 130 | else: 131 | for shortcut in answerShortcuts: 132 | if not activeAnswerShortcuts[answerShortcuts.index(shortcut)]: 133 | mw.stateShortcuts += mw.applyShortcuts( 134 | [ 135 | ( 136 | shortcut, 137 | lambda: mw.reviewer._answerCard(int(shortcut)), 138 | ) 139 | ] 140 | ) 141 | 142 | return text 143 | 144 | def setShortcuts(self, shortcuts) -> None: 145 | shortcuts.append(('Ctrl+=', self.viewManager.zoomIn)) 146 | 147 | def setReviewShortcuts(self, shortcuts) -> None: 148 | self.setShortcuts(shortcuts) 149 | shortcuts.extend(self.shortcuts) 150 | 151 | def addModel(self) -> None: 152 | if mw.col.models.by_name(self.settings['modelName']): 153 | return 154 | 155 | model = mw.col.models.new(self.settings['modelName']) 156 | model['css'] = loadFile('web', 'model.css') 157 | 158 | titleField = mw.col.models.new_field(self.settings['titleField']) 159 | textField = mw.col.models.new_field(self.settings['textField']) 160 | sourceField = mw.col.models.new_field(self.settings['sourceField']) 161 | sourceField['sticky'] = True 162 | 163 | mw.col.models.add_field(model, titleField) 164 | if self.settings['prioEnabled']: 165 | prioField = mw.col.models.new_field(self.settings['prioField']) 166 | mw.col.models.add_field(model, prioField) 167 | 168 | mw.col.models.add_field(model, textField) 169 | mw.col.models.add_field(model, sourceField) 170 | 171 | template = mw.col.models.new_template('IR Card') 172 | template['qfmt'] = '\n'.join( 173 | [ 174 | '
{{%s}}
' 175 | % self.settings['titleField'], 176 | '
{{%s}}
' 177 | % self.settings['textField'], 178 | '
{{%s}}
' 179 | % self.settings['sourceField'], 180 | '
{{Tags}}
', 181 | ] 182 | ) 183 | 184 | if self.settings['prioEnabled']: 185 | template['afmt'] = 'Hit space to move to the next article' 186 | else: 187 | template['afmt'] = 'When do you want to see this card again?' 188 | 189 | mw.col.models.add_template(model, template) 190 | mw.col.models.add(model) 191 | 192 | 193 | def answerButtonList(self, _old: Any) -> tuple[tuple[int, str], ...]: 194 | if isIrCard(self.card): 195 | if mw.readingManager.settings['prioEnabled']: 196 | return ((1, 'Next'),) 197 | return ((1, 'Soon'), (2, 'Later'), (3, 'Custom')) 198 | 199 | return _old(self) 200 | 201 | 202 | def answerCard(self, ease: int, _old: Any): 203 | card = self.card 204 | _old(self, ease) 205 | if isIrCard(card): 206 | mw.readingManager.scheduler.answer(card, ease) 207 | 208 | 209 | def buttonTime(self, i: int, v3_labels: Sequence[str], _old: Any) -> str: 210 | if isIrCard(mw.reviewer.card): 211 | return '
' 212 | return _old(self, i) 213 | 214 | 215 | def onBrowserClosed(self) -> None: 216 | try: 217 | mw.readingManager.scheduler._updateListItems() 218 | except: 219 | return 220 | 221 | 222 | Reviewer._answerButtonList = wrap( 223 | Reviewer._answerButtonList, answerButtonList, 'around' 224 | ) 225 | Reviewer._answerCard = wrap(Reviewer._answerCard, answerCard, 'around') 226 | Reviewer._buttonTime = wrap(Reviewer._buttonTime, buttonTime, 'around') 227 | Browser._closeWindow = wrap(Browser._closeWindow, onBrowserClosed) 228 | -------------------------------------------------------------------------------- /ir/pocket.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer 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 urllib.parse import urlencode 17 | 18 | from anki.utils import is_mac, is_win 19 | from aqt.utils import askUser, openLink, showCritical, showInfo 20 | 21 | from requests import post 22 | 23 | 24 | class Pocket: 25 | _accessToken = None 26 | _redirectURI = 'https://github.com/luoliyan/incremental-reading' 27 | _headers = {'X-Accept': 'application/json'} 28 | 29 | if is_win: 30 | consumerKey = '71462-da4f02100e7e381cbc4a86df' 31 | elif is_mac: 32 | consumerKey = '71462-ed224e5a561a545814023bf9' 33 | else: 34 | consumerKey = '71462-05fb63bf0314903c7e73c52f' 35 | 36 | def getArticles(self): 37 | if not self._accessToken: 38 | self._accessToken = self._authenticate() 39 | 40 | if not self._accessToken: 41 | showCritical('Authentication failed.') 42 | return [] 43 | 44 | response = post( 45 | 'https://getpocket.com/v3/get', 46 | json={ 47 | 'consumer_key': self.consumerKey, 48 | 'access_token': self._accessToken, 49 | 'contentType': 'article', 50 | 'count': 30, 51 | 'detailType': 'complete', 52 | 'sort': 'newest', 53 | }, 54 | headers=self._headers, 55 | ) 56 | 57 | if response.json()['list']: 58 | return [ 59 | {'text': a['resolved_title'], 'data': a} 60 | for a in response.json()['list'].values() 61 | ] 62 | 63 | showInfo('You have no unread articles remaining.') 64 | return [] 65 | 66 | def _authenticate(self): 67 | response = post( 68 | 'https://getpocket.com/v3/oauth/request', 69 | json={ 70 | 'consumer_key': self.consumerKey, 71 | 'redirect_uri': self._redirectURI, 72 | }, 73 | headers=self._headers, 74 | ) 75 | 76 | requestToken = response.json()['code'] 77 | 78 | authUrl = 'https://getpocket.com/auth/authorize?' 79 | authParams = { 80 | 'request_token': requestToken, 81 | 'redirect_uri': self._redirectURI, 82 | } 83 | 84 | openLink(authUrl + urlencode(authParams)) 85 | if not askUser('I have authenticated with Pocket.'): 86 | return None 87 | 88 | response = post( 89 | 'https://getpocket.com/v3/oauth/authorize', 90 | json={'consumer_key': self.consumerKey, 'code': requestToken}, 91 | headers=self._headers, 92 | ) 93 | 94 | try: 95 | return response.json()['access_token'] 96 | except JSONDecodeError: 97 | return None 98 | 99 | def archive(self, article): 100 | post( 101 | 'https://getpocket.com/v3/send', 102 | json={ 103 | 'consumer_key': self.consumerKey, 104 | 'access_token': self._accessToken, 105 | 'actions': [ 106 | {'action': 'archive', 'item_id': article['item_id']} 107 | ], 108 | }, 109 | ) 110 | -------------------------------------------------------------------------------- /ir/requirements.in: -------------------------------------------------------------------------------- 1 | pip-tools 2 | mypy 3 | aqt 4 | PyQt5 5 | PyQt5-stubs 6 | markdown2 7 | 8 | pytest 9 | pytest-cov==3.* 10 | -------------------------------------------------------------------------------- /ir/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.9 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=- - 6 | # 7 | anki==2.1.54 8 | # via aqt 9 | aqt==2.1.54 10 | # via -r - 11 | attrs==21.4.0 12 | # via 13 | # jsonschema 14 | # pytest 15 | beautifulsoup4==4.11.1 16 | # via 17 | # anki 18 | # aqt 19 | build==0.8.0 20 | # via pip-tools 21 | certifi==2022.6.15 22 | # via requests 23 | charset-normalizer==2.1.0 24 | # via requests 25 | click==8.1.3 26 | # via 27 | # flask 28 | # pip-tools 29 | coverage[toml]==6.4.1 30 | # via pytest-cov 31 | decorator==5.1.1 32 | # via anki 33 | distro==1.7.0 34 | # via anki 35 | flask==2.1.2 36 | # via 37 | # aqt 38 | # flask-cors 39 | flask-cors==3.0.10 40 | # via aqt 41 | idna==3.3 42 | # via requests 43 | importlib-metadata==4.12.0 44 | # via 45 | # flask 46 | # markdown 47 | iniconfig==1.1.1 48 | # via pytest 49 | itsdangerous==2.1.2 50 | # via flask 51 | jinja2==3.1.2 52 | # via flask 53 | jsonschema==4.6.2 54 | # via aqt 55 | markdown==3.3.7 56 | # via anki 57 | markdown2==2.4.3 58 | # via -r - 59 | markupsafe==2.1.1 60 | # via jinja2 61 | mypy==0.961 62 | # via -r - 63 | mypy-extensions==0.4.3 64 | # via mypy 65 | orjson==3.7.7 66 | # via anki 67 | packaging==21.3 68 | # via 69 | # build 70 | # pytest 71 | pep517==0.12.0 72 | # via build 73 | pip-tools==6.8.0 74 | # via -r - 75 | pluggy==1.0.0 76 | # via pytest 77 | protobuf==4.21.2 78 | # via anki 79 | py==1.11.0 80 | # via pytest 81 | pyparsing==3.0.9 82 | # via packaging 83 | pyqt5==5.15.7 84 | # via -r - 85 | pyqt5-qt5==5.15.2 86 | # via pyqt5 87 | pyqt5-sip==12.11.0 88 | # via pyqt5 89 | pyqt5-stubs==5.15.6.0 90 | # via -r - 91 | pyrsistent==0.18.1 92 | # via jsonschema 93 | pysocks==1.7.1 94 | # via requests 95 | pytest==7.1.2 96 | # via 97 | # -r - 98 | # pytest-cov 99 | pytest-cov==3.0.0 100 | # via -r - 101 | requests[socks]==2.28.1 102 | # via 103 | # anki 104 | # aqt 105 | send2trash==1.8.0 106 | # via aqt 107 | six==1.16.0 108 | # via flask-cors 109 | soupsieve==2.3.2.post1 110 | # via beautifulsoup4 111 | tomli==2.0.1 112 | # via 113 | # build 114 | # coverage 115 | # mypy 116 | # pep517 117 | # pytest 118 | typing-extensions==4.3.0 119 | # via mypy 120 | urllib3==1.26.10 121 | # via requests 122 | waitress==2.1.2 123 | # via aqt 124 | werkzeug==2.1.2 125 | # via flask 126 | wheel==0.37.1 127 | # via pip-tools 128 | zipp==3.8.0 129 | # via importlib-metadata 130 | 131 | # The following packages are considered to be unsafe in a requirements file: 132 | # pip 133 | # setuptools 134 | -------------------------------------------------------------------------------- /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 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 | from PyQt5.QtCore import Qt 24 | from PyQt5.QtWidgets import ( 25 | QAbstractItemView, 26 | QDialog, 27 | QDialogButtonBox, 28 | QHBoxLayout, 29 | QListWidget, 30 | QListWidgetItem, 31 | QPushButton, 32 | QVBoxLayout, 33 | ) 34 | 35 | from anki.cards import Card 36 | from anki.utils import strip_html 37 | from aqt import mw 38 | from aqt.utils import showInfo, tooltip 39 | 40 | from .settings import SettingsManager 41 | from .util import showBrowser 42 | 43 | SCHEDULE_EXTRACT = 0 44 | SCHEDULE_SOON = 1 45 | SCHEDULE_LATER = 2 46 | SCHEDULE_CUSTOM = 3 47 | 48 | 49 | class Scheduler: 50 | _deckId = None 51 | _cardListWidget = None 52 | _settings: SettingsManager = None 53 | 54 | def changeProfile(self, settings: SettingsManager): 55 | self._settings = settings 56 | 57 | def showDialog(self, currentCard: Card = None): 58 | if currentCard: 59 | self._deckId = currentCard.did 60 | elif mw._selectedDeck(): 61 | self._deckId = mw._selectedDeck()['id'] 62 | else: 63 | return 64 | 65 | if not self._getCardInfo(self._deckId): 66 | showInfo('Please select an Incremental Reading deck.') 67 | return 68 | 69 | dialog = QDialog(mw) 70 | layout = QVBoxLayout() 71 | self._cardListWidget = QListWidget() 72 | self._cardListWidget.setAlternatingRowColors(True) 73 | self._cardListWidget.setSelectionMode( 74 | QAbstractItemView.SelectionMode.ExtendedSelection 75 | ) 76 | self._cardListWidget.setWordWrap(True) 77 | self._cardListWidget.itemDoubleClicked.connect( 78 | lambda: showBrowser( 79 | self._cardListWidget.currentItem().data(Qt.ItemDataRole.UserRole)['nid'] 80 | ) 81 | ) 82 | 83 | self._updateListItems() 84 | 85 | upButton = QPushButton('Up') 86 | upButton.clicked.connect(self._moveUp) 87 | downButton = QPushButton('Down') 88 | downButton.clicked.connect(self._moveDown) 89 | topButton = QPushButton('Top') 90 | topButton.clicked.connect(self._moveToTop) 91 | bottomButton = QPushButton('Bottom') 92 | bottomButton.clicked.connect(self._moveToBottom) 93 | randomizeButton = QPushButton('Randomize') 94 | randomizeButton.clicked.connect(self._randomize) 95 | 96 | controlsLayout = QHBoxLayout() 97 | controlsLayout.addWidget(topButton) 98 | controlsLayout.addWidget(upButton) 99 | controlsLayout.addWidget(downButton) 100 | controlsLayout.addWidget(bottomButton) 101 | controlsLayout.addStretch() 102 | controlsLayout.addWidget(randomizeButton) 103 | 104 | buttonBox = QDialogButtonBox( 105 | QDialogButtonBox.StandardButton.Close | QDialogButtonBox.StandardButton.Save 106 | ) 107 | buttonBox.accepted.connect(dialog.accept) 108 | buttonBox.rejected.connect(dialog.reject) 109 | buttonBox.setOrientation(Qt.Orientation.Horizontal) 110 | 111 | layout.addLayout(controlsLayout) 112 | layout.addWidget(self._cardListWidget) 113 | layout.addWidget(buttonBox) 114 | 115 | dialog.setLayout(layout) 116 | dialog.setWindowModality(Qt.WindowModality.WindowModal) 117 | dialog.resize(500, 500) 118 | choice = dialog.exec() 119 | 120 | if choice == 1: 121 | cids = [] 122 | for i in range(self._cardListWidget.count()): 123 | card = self._cardListWidget.item(i).data(Qt.ItemDataRole.UserRole) 124 | cids.append(card['id']) 125 | 126 | self.reorder(cids) 127 | 128 | def _updateListItems(self): 129 | cardInfo = self._getCardInfo(self._deckId) 130 | self._cardListWidget.clear() 131 | posWidth = len(str(len(cardInfo) + 1)) 132 | for i, card in enumerate(cardInfo, start=1): 133 | if self._settings['prioEnabled']: 134 | info = card['priority'] 135 | else: 136 | info = str(i).zfill(posWidth) 137 | title = sub(r'\s+', ' ', strip_html(card['title'])) 138 | text = self._settings['organizerFormat'].format( 139 | info=info, title=title 140 | ) 141 | item = QListWidgetItem(text) 142 | item.setData(Qt.ItemDataRole.UserRole, card) 143 | self._cardListWidget.addItem(item) 144 | 145 | def _moveToTop(self): 146 | selected = self._getSelected() 147 | if not selected: 148 | showInfo('Please select one or several items.') 149 | return 150 | 151 | selected.reverse() 152 | for item in selected: 153 | self._cardListWidget.takeItem(self._cardListWidget.row(item)) 154 | self._cardListWidget.insertItem(0, item) 155 | item.setSelected(True) 156 | 157 | self._cardListWidget.scrollToTop() 158 | 159 | def _moveUp(self): 160 | selected = self._getSelected() 161 | if not selected: 162 | showInfo('Please select one or several items.') 163 | return 164 | 165 | if self._cardListWidget.row(selected[0]) == 0: 166 | return 167 | 168 | for item in selected: 169 | row = self._cardListWidget.row(item) 170 | self._cardListWidget.takeItem(row) 171 | self._cardListWidget.insertItem(row - 1, item) 172 | item.setSelected(True) 173 | self._cardListWidget.scrollToItem(item) 174 | 175 | def _moveDown(self): 176 | selected = self._getSelected() 177 | if not selected: 178 | showInfo('Please select one or several items.') 179 | return 180 | 181 | selected.reverse() 182 | 183 | if ( 184 | self._cardListWidget.row(selected[0]) 185 | == self._cardListWidget.count() - 1 186 | ): 187 | return 188 | 189 | for item in selected: 190 | row = self._cardListWidget.row(item) 191 | self._cardListWidget.takeItem(row) 192 | self._cardListWidget.insertItem(row + 1, item) 193 | item.setSelected(True) 194 | self._cardListWidget.scrollToItem(item) 195 | 196 | def _moveToBottom(self): 197 | selected = self._getSelected() 198 | if not selected: 199 | showInfo('Please select one or several items.') 200 | return 201 | 202 | for item in selected: 203 | self._cardListWidget.takeItem(self._cardListWidget.row(item)) 204 | self._cardListWidget.insertItem(self._cardListWidget.count(), item) 205 | item.setSelected(True) 206 | 207 | self._cardListWidget.scrollToBottom() 208 | 209 | def _getSelected(self): 210 | return [ 211 | self._cardListWidget.item(i) 212 | for i in range(self._cardListWidget.count()) 213 | if self._cardListWidget.item(i).isSelected() 214 | ] 215 | 216 | def _randomize(self): 217 | allItems = [ 218 | self._cardListWidget.takeItem(0) 219 | for _ in range(self._cardListWidget.count()) 220 | ] 221 | if self._settings['prioEnabled']: 222 | maxPrio = len(self._settings['priorities']) - 1 223 | for item in allItems: 224 | priority = item.data(Qt.ItemDataRole.UserRole)['priority'] 225 | if priority != '': 226 | item.contNewPos = gauss( 227 | maxPrio - int(priority), maxPrio / 20 228 | ) 229 | else: 230 | item.contNewPos = float('inf') 231 | allItems.sort(key=lambda item: item.contNewPos) 232 | 233 | else: 234 | shuffle(allItems) 235 | 236 | for item in allItems: 237 | self._cardListWidget.addItem(item) 238 | 239 | def answer(self, card: Card, ease: int): 240 | if self._settings['prioEnabled']: 241 | # reposition the card at the end of the organizer 242 | cardCount = len(self._getCardInfo(card.did)) 243 | self.reposition(card, cardCount) 244 | return 245 | 246 | if ease == SCHEDULE_EXTRACT: 247 | value = self._settings['extractValue'] 248 | randomize = self._settings['extractRandom'] 249 | method = self._settings['extractMethod'] 250 | elif ease == SCHEDULE_SOON: 251 | value = self._settings['soonValue'] 252 | randomize = self._settings['soonRandom'] 253 | method = self._settings['soonMethod'] 254 | elif ease == SCHEDULE_LATER: 255 | value = self._settings['laterValue'] 256 | randomize = self._settings['laterRandom'] 257 | method = self._settings['laterMethod'] 258 | elif ease == SCHEDULE_CUSTOM: 259 | self.reposition(card, 1) 260 | self.showDialog(card) 261 | return 262 | 263 | if method == 'percent': 264 | totalCards = len([c['id'] for c in self._getCardInfo(card.did)]) 265 | newPos = totalCards * (value / 100) 266 | elif method == 'count': 267 | newPos = value 268 | 269 | if randomize: 270 | newPos = gauss(newPos, newPos / 10) 271 | 272 | newPos = max(1, round(newPos)) 273 | self.reposition(card, newPos) 274 | 275 | if ease != SCHEDULE_EXTRACT: 276 | tooltip('Card moved to position {}'.format(newPos)) 277 | 278 | def reposition(self, card, newPos): 279 | cids = [c['id'] for c in self._getCardInfo(card.did)] 280 | mw.col.sched.forgetCards(cids) 281 | cids.remove(card.id) 282 | newOrder = cids[: newPos - 1] + [card.id] + cids[newPos - 1 :] 283 | mw.col.sched.reposition_new_cards(newOrder, starting_from=1, step_size=1, randomize=False, shift_existing=False) 284 | 285 | def reorder(self, cids): 286 | mw.col.sched.forgetCards(cids) 287 | mw.col.sched.reposition_new_cards(cids, starting_from=1, step_size=1, randomize=False, shift_existing=False) 288 | 289 | def _getCardInfo(self, did): 290 | cardInfo = [] 291 | 292 | for cid, nid in mw.col.db.execute( 293 | 'select id, nid from cards where did = ?', did 294 | ): 295 | note = mw.col.get_note(nid) 296 | if note.note_type()['name'] == self._settings['modelName']: 297 | if self._settings['prioEnabled']: 298 | prio = note[self._settings['prioField']] 299 | else: 300 | prio = None 301 | 302 | cardInfo.append( 303 | { 304 | 'id': cid, 305 | 'nid': nid, 306 | 'title': note[self._settings['titleField']], 307 | 'priority': prio, 308 | } 309 | ) 310 | 311 | return cardInfo 312 | -------------------------------------------------------------------------------- /ir/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Christian Weiß 2 | # Copyright 2018 Timothée Chauvin 3 | # Copyright 2017-2019 Joseph Lorimer 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 | from functools import partial 18 | import json 19 | import os 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 | 'limitWidth': True, 62 | 'limitWidthAll': False, 63 | 'lineScrollFactor': 0.05, 64 | 'maxWidth': 600, 65 | 'modelName': 'IR3', 66 | 'modified': [], 67 | 'organizerFormat': '❰ {info} ❱\t{title}', 68 | 'overlaySeq': 'Ctrl+Shift+O', 69 | 'pageScrollFactor': 0.5, 70 | 'plainText': False, 71 | 'pocketArchive': True, 72 | 'prioDefault': '5', 73 | 'prioEnabled': False, 74 | 'prioField': 'Priority', 75 | 'priorities': ['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'], 76 | 'quickKeys': {}, 77 | 'removeKey': 'z', 78 | 'scheduleExtract': True, 79 | 'scroll': {}, 80 | 'soonMethod': 'percent', 81 | 'soonRandom': True, 82 | 'soonValue': 10, 83 | 'sourceField': 'Source', 84 | 'sourceFormat': '{url} ({date})', 85 | 'strikeSeq': 'Ctrl+S', 86 | 'textField': 'Text', 87 | 'titleField': 'Title', 88 | 'underlineSeq': 'Ctrl+U', 89 | 'undoKey': 'u', 90 | 'userAgent': 'IR/{} (+{})'.format(__version__, IR_GITHUB_URL), 91 | 'version': __version__, 92 | 'zoom': {}, 93 | 'zoomStep': 0.1, 94 | } 95 | 96 | def __init__(self): 97 | addHook('unloadProfile', self._unload) 98 | self.load() 99 | 100 | def __setitem__(self, key, value): 101 | if ( 102 | self.settings[key] != value 103 | and key not in self.settings['modified'] 104 | ): 105 | self.settings['modified'].append(key) 106 | 107 | self.settings[key] = value 108 | 109 | def __getitem__(self, key): 110 | return self.settings[key] 111 | 112 | def load(self): 113 | if os.path.isfile(self.getSettingsPath()): 114 | self._loadExisting() 115 | else: 116 | self.settings = self.defaults 117 | 118 | if self.updated: 119 | showInfo('Your Incremental Reading settings have been updated.') 120 | 121 | def _loadExisting(self): 122 | with open(self.getSettingsPath(), encoding='utf-8') as jsonFile: 123 | self.settings = json.load(jsonFile) 124 | self._update() 125 | 126 | def getSettingsPath(self): 127 | return os.path.join(self.getMediaDir(), '_ir.json') 128 | 129 | def getMediaDir(self): 130 | return os.path.join(mw.pm.profileFolder(), 'collection.media') 131 | 132 | def _update(self): 133 | self.settings['version'] = self.defaults['version'] 134 | self._addMissing() 135 | self._removeOutdated() 136 | self._updateUnmodified() 137 | self._validateFormatStrings() 138 | 139 | def _addMissing(self): 140 | for k, v in self.defaults.items(): 141 | if k not in self.settings: 142 | self.settings[k] = v 143 | self.updated = True 144 | 145 | def _removeOutdated(self): 146 | required = [ 147 | 'alt', 148 | 'ctrl', 149 | 'editExtract', 150 | 'editSource', 151 | 'extractBgColor', 152 | 'extractDeck', 153 | 'extractTextColor', 154 | 'isQuickKey', 155 | 'modelName', 156 | 'regularKey', 157 | 'shift', 158 | 'sourceField', 159 | 'tags', 160 | 'textField', 161 | ] 162 | 163 | for keyCombo, settings in self.settings['quickKeys'].copy().items(): 164 | for k in required: 165 | if k not in settings: 166 | self.settings['quickKeys'].pop(keyCombo) 167 | self.updated = True 168 | break 169 | 170 | outdated = [k for k in self.settings if k not in self.defaults] 171 | for k in outdated: 172 | self.settings.pop(k) 173 | self.updated = True 174 | 175 | def _updateUnmodified(self): 176 | for k in self.settings: 177 | if k in self.doNotUpdate: 178 | continue 179 | 180 | if k in self.settings['modified']: 181 | continue 182 | 183 | if self.settings[k] == self.defaults[k]: 184 | continue 185 | 186 | self.settings[k] = self.defaults[k] 187 | self.updated = True 188 | 189 | def _validateFormatStrings(self): 190 | for name in self.requiredFormatKeys: 191 | if not self.validFormat(name, self.settings[name]): 192 | self.settings[name] = self.defaults[name] 193 | 194 | def validFormat(self, name, fmt): 195 | for k in self.requiredFormatKeys[name]: 196 | if fmt.find('{%s}' % k) == -1: 197 | return False 198 | return True 199 | 200 | def _unload(self): 201 | for menu in mw.customMenus.values(): 202 | mw.form.menubar.removeAction(menu.menuAction()) 203 | 204 | mw.customMenus.clear() 205 | self.save() 206 | 207 | def save(self): 208 | with open(self.getSettingsPath(), 'w', encoding='utf-8') as jsonFile: 209 | json.dump(self.settings, jsonFile) 210 | 211 | updateModificationTime(self.getMediaDir()) 212 | 213 | def loadMenuItems(self): 214 | path = 'Read::Quick Keys' 215 | 216 | if path in mw.customMenus: 217 | mw.customMenus[path].clear() 218 | 219 | for keyCombo, settings in self.settings['quickKeys'].items(): 220 | text = 'Add Card [%s -> %s]' % ( 221 | settings['modelName'], 222 | settings['extractDeck'], 223 | ) 224 | func = partial(mw.readingManager.textManager.extract, settings) 225 | addMenuItem(path, text, func, keyCombo) 226 | 227 | setMenuVisibility(path) 228 | -------------------------------------------------------------------------------- /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 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 | 33 | SCHEDULE_EXTRACT = 0 34 | 35 | 36 | class TextManager: 37 | _history = defaultdict(list) 38 | _settings: SettingsManager = None 39 | 40 | def changeProfile(self, settings: SettingsManager): 41 | self._settings = settings 42 | 43 | def highlight(self, bgColor=None, textColor=None): 44 | if not bgColor: 45 | bgColor = self._settings['highlightBgColor'] 46 | if not textColor: 47 | textColor = self._settings['highlightTextColor'] 48 | 49 | script = "highlight('%s', '%s')" % (bgColor, textColor) 50 | mw.web.eval(script) 51 | self.save() 52 | 53 | def format(self, style): 54 | mw.web.eval('format("%s")' % style) 55 | self.save() 56 | 57 | def toggleOverlay(self): 58 | mw.web.eval('toggleOverlay()') 59 | self.save() 60 | 61 | def extract(self, settings=None): 62 | if mw.state != 'review': 63 | return 64 | 65 | if not settings: 66 | settings = self._settings 67 | 68 | if not mw.web.selectedText() and not settings['editExtract']: 69 | showInfo('Please select some text to extract.') 70 | return 71 | 72 | if settings['plainText']: 73 | mw.web.evalWithCallback( 74 | 'getPlainText()', lambda text: self.create(text, settings) 75 | ) 76 | else: 77 | mw.web.evalWithCallback( 78 | 'getHtmlText()', lambda text: self.create(text, settings) 79 | ) 80 | 81 | def create(self, text, settings): 82 | currentCard = mw.reviewer.card 83 | currentNote = currentCard.note() 84 | model = mw.col.models.by_name(settings['modelName']) 85 | newNote = Note(mw.col, model) 86 | newNote.tags = currentNote.tags 87 | setField(newNote, settings['textField'], fixImages(text)) 88 | 89 | if settings['extractDeck']: 90 | deck = mw.col.decks.by_name(settings['extractDeck']) 91 | if not deck: 92 | showWarning( 93 | 'Destination deck no longer exists. ' 94 | 'Please update your settings.' 95 | ) 96 | return 97 | did = deck['id'] 98 | else: 99 | did = currentCard.did 100 | 101 | if settings['isQuickKey']: 102 | newNote.tags += settings['tags'] 103 | 104 | if settings['sourceField']: 105 | setField( 106 | newNote, 107 | settings['sourceField'], 108 | getField(currentNote, self._settings['sourceField']), 109 | ) 110 | 111 | if settings['editExtract']: 112 | highlight = self._editExtract(newNote, did, settings) 113 | else: 114 | highlight = True 115 | newNote.note_type()['did'] = did 116 | mw.col.addNote(newNote) 117 | else: 118 | if settings['copyTitle']: 119 | title = getField(currentNote, settings['titleField']) 120 | else: 121 | title = '' 122 | 123 | setField( 124 | newNote, 125 | settings['sourceField'], 126 | getField(currentNote, settings['sourceField']), 127 | ) 128 | if settings['prioEnabled']: 129 | setField( 130 | newNote, 131 | settings['prioField'], 132 | getField(currentNote, settings['prioField']), 133 | ) 134 | 135 | if settings['editExtract']: 136 | setField(newNote, settings['titleField'], title) 137 | highlight = self._editExtract(newNote, did, settings) 138 | else: 139 | highlight = self._getTitle(newNote, did, title, settings) 140 | 141 | if settings['scheduleExtract'] and not settings['prioEnabled']: 142 | cards = newNote.cards() 143 | if cards: 144 | mw.readingManager.scheduler.answer( 145 | cards[0], SCHEDULE_EXTRACT 146 | ) 147 | 148 | if highlight: 149 | self.highlight( 150 | settings['extractBgColor'], settings['extractTextColor'] 151 | ) 152 | 153 | if settings['editSource']: 154 | EditCurrent(mw) 155 | 156 | def _editExtract(self, note: Note, deckId: DeckId, settings: SettingsManager): 157 | def onAdd(): 158 | self.highlight( 159 | settings['extractBgColor'], settings['extractTextColor'] 160 | ) 161 | 162 | addCards = AddCards(mw) 163 | addCards.set_note(note, deckId) 164 | addCards.addButton.clicked.connect(onAdd) 165 | 166 | # Do not highlight immediately, but only after the card is added 167 | return False 168 | 169 | def _getTitle(self, note, did, title, settings): 170 | title, accepted = getText( 171 | 'Enter title', title='Extract Text', default=title 172 | ) 173 | 174 | if accepted: 175 | setField(note, settings['titleField'], title) 176 | note.note_type()['did'] = did 177 | mw.col.addNote(note) 178 | 179 | return accepted 180 | 181 | def remove(self): 182 | mw.web.eval('removeText()') 183 | self.save() 184 | 185 | def undo(self): 186 | note = mw.reviewer.card.note() 187 | 188 | if note.id not in self._history or not self._history[note.id]: 189 | showInfo('No undo history for this note.') 190 | return 191 | 192 | note['Text'] = self._history[note.id].pop() 193 | note.flush() 194 | mw.reset() 195 | tooltip('Undone') 196 | 197 | def save(self): 198 | def callback(text): 199 | if text: 200 | note = mw.reviewer.card.note() 201 | self._history[note.id].append(note['Text']) 202 | note['Text'] = text 203 | note.flush() 204 | 205 | mw.web.evalWithCallback( 206 | 'document.getElementsByClassName("ir-text")[0].innerHTML;', 207 | callback, 208 | ) 209 | -------------------------------------------------------------------------------- /ir/util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019 Joseph Lorimer 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 stat 17 | import time 18 | from urllib.parse import unquote 19 | from anki.cards import Card 20 | 21 | from PyQt5.QtCore import Qt 22 | from PyQt5.QtGui import QKeySequence 23 | from PyQt5.QtWidgets import QAction, QMenu, QSpinBox 24 | from aqt import dialogs, mw 25 | from bs4 import BeautifulSoup 26 | 27 | 28 | def isIrCard(card: Card) -> bool: 29 | return card and ( 30 | card.note_type()['name'] == mw.readingManager.settings['modelName'] 31 | ) 32 | 33 | 34 | def viewingIrText(): 35 | return ( 36 | isIrCard(mw.reviewer.card) 37 | and (mw.reviewer.state == 'question') 38 | and (mw.state == 'review') 39 | ) 40 | 41 | 42 | def addMenu(fullPath): 43 | if not hasattr(mw, 'customMenus'): 44 | mw.customMenus = {} 45 | 46 | if len(fullPath.split('::')) == 2: 47 | menuPath, submenuPath = fullPath.split('::') 48 | hasSubmenu = True 49 | else: 50 | menuPath = fullPath 51 | hasSubmenu = False 52 | 53 | if menuPath not in mw.customMenus: 54 | menu = QMenu('&' + menuPath, mw) 55 | mw.customMenus[menuPath] = menu 56 | mw.form.menubar.insertMenu(mw.form.menuTools.menuAction(), menu) 57 | 58 | if hasSubmenu and (fullPath not in mw.customMenus): 59 | submenu = QMenu('&' + submenuPath, mw) 60 | mw.customMenus[fullPath] = submenu 61 | mw.customMenus[menuPath].addMenu(submenu) 62 | 63 | 64 | def setMenuVisibility(path): 65 | if path not in mw.customMenus: 66 | return 67 | 68 | if mw.customMenus[path].isEmpty(): 69 | mw.customMenus[path].menuAction().setVisible(False) 70 | else: 71 | mw.customMenus[path].menuAction().setVisible(True) 72 | 73 | 74 | def addMenuItem(path, text, function, keys=None): 75 | action = QAction(text, mw) 76 | 77 | if keys: 78 | action.setShortcut(QKeySequence(keys)) 79 | 80 | action.triggered.connect(function) 81 | 82 | if path == 'File': 83 | mw.form.menuCol.addAction(action) 84 | elif path == 'Edit': 85 | mw.form.menuEdit.addAction(action) 86 | elif path == 'Tools': 87 | mw.form.menuTools.addAction(action) 88 | elif path == 'Help': 89 | mw.form.menuHelp.addAction(action) 90 | else: 91 | addMenu(path) 92 | mw.customMenus[path].addAction(action) 93 | 94 | 95 | def getField(note, fieldName): 96 | model = note.note_type() 97 | index, _ = mw.col.models.field_map(model)[fieldName] 98 | return note.fields[index] 99 | 100 | 101 | def setField(note, field, value): 102 | """Set the value of a note field. Overwrite any existing value.""" 103 | model = note.note_type() 104 | index, _ = mw.col.models.field_map(model)[field] 105 | note.fields[index] = value 106 | 107 | 108 | def getFieldNames(modelName): 109 | """Return list of field names for given model name.""" 110 | if not modelName: 111 | return [] 112 | return mw.col.models.field_names(mw.col.models.by_name(modelName)) 113 | 114 | 115 | def createSpinBox(value, minimum, maximum, step): 116 | spinBox = QSpinBox() 117 | spinBox.setRange(minimum, maximum) 118 | spinBox.setSingleStep(step) 119 | spinBox.setValue(value) 120 | return spinBox 121 | 122 | 123 | def setComboBoxItem(comboBox, text): 124 | index = comboBox.findText(text, Qt.MatchFlag.MatchFixedString) 125 | comboBox.setCurrentIndex(index) 126 | 127 | 128 | def removeComboBoxItem(comboBox, text): 129 | index = comboBox.findText(text, Qt.MatchFlag.MatchFixedString) 130 | comboBox.removeItem(index) 131 | 132 | 133 | def updateModificationTime(path): 134 | accessTime = os.stat(path)[stat.ST_ATIME] 135 | modificationTime = time.time() 136 | os.utime(path, (accessTime, modificationTime)) 137 | 138 | 139 | def fixImages(html): 140 | if not html: 141 | return '' 142 | soup = BeautifulSoup(html, 'html.parser') 143 | for img in soup.find_all('img'): 144 | img['src'] = os.path.basename(unquote(img['src'])) 145 | return str(soup) 146 | 147 | 148 | def loadFile(fileDir, filename): 149 | moduleDir, _ = os.path.split(__file__) 150 | path = os.path.join(moduleDir, fileDir, filename) 151 | with open(path, encoding='utf-8') as f: 152 | return f.read() 153 | 154 | 155 | def getColorList(): 156 | moduleDir, _ = os.path.split(__file__) 157 | colorsFilePath = os.path.join(moduleDir, 'data', 'colors.u8') 158 | with open(colorsFilePath, encoding='utf-8') as colorsFile: 159 | return [line.strip() for line in colorsFile] 160 | 161 | 162 | def showBrowser(nid): 163 | browser = dialogs.open('Browser', mw) 164 | browser.form.searchEdit.lineEdit().setText('nid:' + str(nid)) 165 | browser.onSearchActivated() 166 | -------------------------------------------------------------------------------- /ir/view.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Tiago Barroso 2 | # Copyright 2013 Frank Kmiec 3 | # Copyright 2013-2016 Aleksej 4 | # Copyright 2017-2019 Joseph Lorimer 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 | from anki.cards import Card 18 | from aqt import mw 19 | from aqt import gui_hooks 20 | 21 | from .settings import SettingsManager 22 | from .util import isIrCard, loadFile, viewingIrText 23 | 24 | 25 | class ViewManager: 26 | _settings: SettingsManager = None 27 | 28 | def __init__(self): 29 | self._scrollScript = loadFile('web', 'scroll.js') 30 | self._textScript = loadFile('web', 'text.js') 31 | self._widthScript = loadFile('web', 'width.js') 32 | self._zoomFactor = 1 33 | 34 | gui_hooks.state_did_change.append(self.resetZoom) 35 | gui_hooks.card_will_show.append(self._prepareCard) 36 | mw.web.page().scrollPositionChanged.connect(self._saveScroll) 37 | 38 | def changeProfile(self, settings: SettingsManager): 39 | self._settings = settings 40 | 41 | def resetZoom(self, state, *args): 42 | if not self._settings: 43 | return 44 | 45 | if state in ['deckBrowser', 'overview']: 46 | mw.web.setZoomFactor(self._settings['generalZoom']) 47 | elif state == 'review' and not isIrCard(mw.reviewer.card): 48 | self._setZoom(self._zoomFactor) 49 | 50 | def zoomIn(self): 51 | if viewingIrText(): 52 | cid = str(mw.reviewer.card.id) 53 | 54 | if cid not in self._settings['zoom']: 55 | self._settings['zoom'][cid] = 1 56 | 57 | self._settings['zoom'][cid] += self._settings['zoomStep'] 58 | mw.web.setZoomFactor(self._settings['zoom'][cid]) 59 | elif mw.state == 'review': 60 | self._zoomFactor += self._settings['zoomStep'] 61 | mw.web.setZoomFactor(self._zoomFactor) 62 | else: 63 | self._settings['generalZoom'] += self._settings['zoomStep'] 64 | mw.web.setZoomFactor(self._settings['generalZoom']) 65 | 66 | def zoomOut(self): 67 | if viewingIrText(): 68 | cid = str(mw.reviewer.card.id) 69 | 70 | if cid not in self._settings['zoom']: 71 | self._settings['zoom'][cid] = 1 72 | 73 | self._settings['zoom'][cid] -= self._settings['zoomStep'] 74 | mw.web.setZoomFactor(self._settings['zoom'][cid]) 75 | elif mw.state == 'review': 76 | self._zoomFactor -= self._settings['zoomStep'] 77 | mw.web.setZoomFactor(self._zoomFactor) 78 | else: 79 | self._settings['generalZoom'] -= self._settings['zoomStep'] 80 | mw.web.setZoomFactor(self._settings['generalZoom']) 81 | 82 | def _setZoom(self, factor=None): 83 | if factor: 84 | mw.web.setZoomFactor(factor) 85 | else: 86 | mw.web.setZoomFactor( 87 | self._settings['zoom'][str(mw.reviewer.card.id)] 88 | ) 89 | 90 | def _prepareCard(self, html: str, card: Card, kind: str) -> str: 91 | if (isIrCard(card) and self._settings['limitWidth']) or self._settings['limitWidthAll']: 92 | js = self._widthScript.format(maxWidth=self._settings['maxWidth']) 93 | else: 94 | js = '' 95 | 96 | if isIrCard(card) and kind.startswith('review'): 97 | cid = str(card.id) 98 | 99 | if cid not in self._settings['zoom']: 100 | self._settings['zoom'][cid] = 1 101 | 102 | if cid not in self._settings['scroll']: 103 | self._settings['scroll'][cid] = 0 104 | 105 | self._setZoom() 106 | js += self._textScript 107 | js += self._scrollScript.format( 108 | savedPos=self._settings['scroll'][cid], 109 | lineScrollFactor=self._settings['lineScrollFactor'], 110 | pageScrollFactor=self._settings['pageScrollFactor'], 111 | ) 112 | 113 | if js: 114 | html += '' 115 | 116 | return html 117 | 118 | def _saveScroll(self, event=None): 119 | if viewingIrText(): 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 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 | background-color: #fff; 41 | color: #000; 42 | font-family: Helvetica; 43 | font-size: 16px; 44 | word-spacing: .5px !important; 45 | line-height: 140% !important; 46 | } 47 | 48 | [ir-overlay~="on"].ir-highlight.bold { font-weight: bold; } 49 | [ir-overlay~="on"].ir-highlight.italic { font-style: italic; } 50 | [ir-overlay~="on"].ir-highlight.strike { text-decoration: line-through; } 51 | [ir-overlay~="on"].ir-highlight.underline { text-decoration: underline; } 52 | 53 | .ir-title { 54 | background-color: #333; 55 | border-radius: 10px; 56 | border-style: ridge; 57 | color: white; 58 | padding: 5px; 59 | text-align: center; 60 | } 61 | 62 | .ir-src { 63 | color: grey; 64 | font-style: italic; 65 | font-size: 16px; 66 | text-align: center; 67 | } 68 | 69 | .ir-tags { 70 | box-shadow: inset 0px 2px 0px 0px #ffffff; 71 | background: -webkit-gradient(linear, 72 | left top, 73 | left bottom, 74 | color-stop(0.05, #f9f9f9), 75 | color-stop(1, #e9e9e9)); 76 | background-color: white; 77 | color: black; 78 | border-radius: 4px; 79 | border: 1px solid #dcdcdc; 80 | display: inline-block; 81 | font-size: 14px; 82 | height: 12px; 83 | line-height: 14px; 84 | padding: 2px 4px; 85 | margin: 3px; 86 | text-align: center; 87 | text-shadow: 1px 1px 0px #ffffff; 88 | } 89 | -------------------------------------------------------------------------------- /ir/web/scroll.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Joseph Lorimer 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 restoreScroll() {{ 18 | window.scrollTo(0, {savedPos}); 19 | }} 20 | 21 | function getMovementFactor(keyCode) {{ 22 | switch (keyCode) {{ 23 | case "ArrowUp": 24 | return -{lineScrollFactor}; 25 | case "ArrowDown": 26 | return {lineScrollFactor}; 27 | case "PageUp": 28 | return -{pageScrollFactor}; 29 | case "PageDown": 30 | return {pageScrollFactor}; 31 | default: 32 | return 0; 33 | }} 34 | }} 35 | 36 | document.addEventListener("keydown", (e) => {{ 37 | if (["ArrowUp", "ArrowDown", "PageUp", "PageDown"].includes(e.code)) {{ 38 | let currentPos = window.pageYOffset; 39 | 40 | let movementSize = window.innerHeight * getMovementFactor(e.code); 41 | let newPos = currentPos + movementSize; 42 | newPos = Math.max(newPos, 0); 43 | newPos = Math.min(newPos, document.body.scrollHeight); 44 | 45 | window.scrollTo(0, newPos); 46 | 47 | e.preventDefault(); 48 | }} 49 | }}); 50 | 51 | onUpdateHook.push(restoreScroll); 52 | -------------------------------------------------------------------------------- /ir/web/text.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 Tiago Barroso 3 | * Copyright 2013 Frank Kmiec 4 | * Copyright 2017-2018 Joseph Lorimer 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 | -------------------------------------------------------------------------------- /ir/web/width.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Joseph Lorimer 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 | if (screen.width > {maxWidth}) {{ 18 | var styleSheet = document.styleSheets[0]; 19 | styleSheet.insertRule( 20 | "div {{ width: {maxWidth}px; margin: 20px auto }}", 0 21 | ); 22 | }} 23 | -------------------------------------------------------------------------------- /screenshots/extraction-and-highlighting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/screenshots/extraction-and-highlighting.png -------------------------------------------------------------------------------- /screenshots/highlighting-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/screenshots/highlighting-tab.png -------------------------------------------------------------------------------- /screenshots/quick-keys-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdlorimer/incremental-reading/2ed63908d447e712e5109b3ab281220624fa86fa/screenshots/quick-keys-tab.png -------------------------------------------------------------------------------- /tests/__init__.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 | modules = { 8 | 'anki.hooks': MagicMock(), 9 | 'aqt': MagicMock(), 10 | 'aqt.utils': MagicMock(), 11 | 'ir.about': MagicMock(), 12 | 'ir.main': MagicMock(), 13 | 'ir.util': MagicMock(), 14 | } 15 | patch.dict('sys.modules', modules).start() 16 | pf_mock = MagicMock(return_value=str()) 17 | if_mock = MagicMock(return_value=True) 18 | patch('ir.settings.json.load', MagicMock()).start() 19 | patch('ir.settings.mw.pm.profileFolder', pf_mock).start() 20 | patch('ir.settings.open', mock_open()).start() 21 | patch('ir.settings.os.path.isfile', if_mock).start() 22 | from ir.settings import SettingsManager 23 | self.sm = SettingsManager() 24 | 25 | def tearDown(self): 26 | patch.stopall() 27 | -------------------------------------------------------------------------------- /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.utils': MagicMock(), 17 | 'ir.main': MagicMock(), 18 | 'ir.util': MagicMock(), 19 | } 20 | self.patcher = patch.dict('sys.modules', modules) 21 | self.patcher.start() 22 | 23 | def tearDown(self): 24 | self.patcher.stop() 25 | 26 | def test_scheduler(self): 27 | from ir.schedule import Scheduler 28 | Scheduler() 29 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, mock_open, patch 2 | 3 | from . import SettingsTests 4 | 5 | 6 | class SaveTests(SettingsTests): 7 | def test_save(self): 8 | open_mock = mock_open() 9 | dump_mock = MagicMock() 10 | open_patcher = patch('ir.settings.open', open_mock) 11 | dump_patcher = patch('ir.settings.json.dump', dump_mock) 12 | open_patcher.start() 13 | dump_patcher.start() 14 | self.sm.getSettingsPath = MagicMock(return_value='foo.json') 15 | self.sm.settings = {'foo': 'bar'} 16 | self.sm.save() 17 | open_mock.assert_called_once_with('foo.json', 'w', encoding='utf-8') 18 | dump_mock.assert_called_once_with({'foo': 'bar'}, open_mock()) 19 | dump_patcher.stop() 20 | 21 | 22 | class PathTests(SettingsTests): 23 | def test_getMediaDir(self): 24 | with patch('ir.settings.mw.pm.profileFolder', 25 | MagicMock(return_value='foo')): 26 | self.assertEqual(self.sm.getMediaDir(), 'foo/collection.media') 27 | 28 | def test_getSettingsPath(self): 29 | self.sm.getMediaDir = MagicMock(return_value='foo') 30 | self.assertEqual(self.sm.getSettingsPath(), 'foo/_ir.json') 31 | 32 | 33 | class ValidateFormatStringsTests(SettingsTests): 34 | def test_valid(self): 35 | self.sm.defaults = { 36 | 'fooFormat': '{foo} {bar}', 37 | 'barFormat': '{baz} {qux}' 38 | } 39 | self.sm.settings = self.sm.defaults.copy() 40 | self.sm.requiredFormatKeys = { 41 | 'fooFormat': ['foo', 'bar'], 42 | 'barFormat': ['baz', 'qux'] 43 | } 44 | self.sm._validateFormatStrings() 45 | self.assertEqual(self.sm.settings, self.sm.defaults) 46 | 47 | def test_invalid(self): 48 | self.sm.defaults = { 49 | 'fooFormat': '{foo} {bar}', 50 | 'barFormat': '{baz} {qux}' 51 | } 52 | invalidSettings = { 53 | 'fooFormat': '{baz} {qux}', 54 | 'barFormat': '{foo} {bar}' 55 | } 56 | self.sm.settings = invalidSettings 57 | self.sm.requiredFormatKeys = { 58 | 'fooFormat': ['foo', 'bar'], 59 | 'barFormat': ['baz', 'qux'] 60 | } 61 | self.sm._validateFormatStrings() 62 | self.assertEqual(self.sm.settings, self.sm.defaults) 63 | 64 | 65 | class ValidFormatTests(SettingsTests): 66 | def test_valid(self): 67 | self.sm.requiredFormatKeys = {'test': ['foo', 'bar', 'baz']} 68 | self.assertTrue(self.sm.validFormat('test', '{foo} {bar} {baz}')) 69 | 70 | def test_invalid(self): 71 | self.sm.requiredFormatKeys = {'test': ['foo', 'bar', 'baz']} 72 | self.assertFalse(self.sm.validFormat('test', '{foo} {baz}')) 73 | --------------------------------------------------------------------------------