hanky 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hanky-0.0.1/.gitignore ADDED
@@ -0,0 +1,129 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ target/
76
+
77
+ # Jupyter Notebook
78
+ .ipynb_checkpoints
79
+
80
+ # IPython
81
+ profile_default/
82
+ ipython_config.py
83
+
84
+ # pyenv
85
+ .python-version
86
+
87
+ # pipenv
88
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
90
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
91
+ # install all needed dependencies.
92
+ #Pipfile.lock
93
+
94
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95
+ __pypackages__/
96
+
97
+ # Celery stuff
98
+ celerybeat-schedule
99
+ celerybeat.pid
100
+
101
+ # SageMath parsed files
102
+ *.sage.py
103
+
104
+ # Environments
105
+ .env
106
+ .venv
107
+ env/
108
+ venv/
109
+ ENV/
110
+ env.bak/
111
+ venv.bak/
112
+
113
+ # Spyder project settings
114
+ .spyderproject
115
+ .spyproject
116
+
117
+ # Rope project settings
118
+ .ropeproject
119
+
120
+ # mkdocs documentation
121
+ /site
122
+
123
+ # mypy
124
+ .mypy_cache/
125
+ .dmypy.json
126
+ dmypy.json
127
+
128
+ # Pyre type checker
129
+ .pyre/
hanky-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [year] [fullname]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hanky-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.3
2
+ Name: hanky
3
+ Version: 0.0.1
4
+ Summary: Simple library and command line tool for loading flash cards into anki.
5
+ Project-URL: Homepage, https://github.com/Haeata-Ash/hanky
6
+ Project-URL: Issues, https://github.com/Haeata-Ash/hanky/issues
7
+ Author-email: HBA <hanky-pypi.8ebs0@simplelogin.com>
8
+ License-File: LICENSE
9
+ Keywords: anki
10
+ Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: Unix
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.8
15
+ Requires-Dist: anki
16
+ Requires-Dist: psutil
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy; extra == 'dev'
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # hanky
23
+
24
+ [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
25
+
26
+ Library and command line application for loading flash cards into anki.
27
+
28
+ > **:information_source: Note:**
29
+ > This project is currently in alpha and is not stable.
30
+
31
+ ## Installation
32
+
33
+ Install via pip:
34
+
35
+ `pip install hanky`
36
+
37
+ Optionally install text to speech code seen in tutorial:
38
+
39
+ ` pip install hanky[toc]`
40
+
41
+ ## Configuration
42
+
43
+ Currently two configuration options are exposed:
44
+
45
+ - `anki_database`: tells hanky where to find the anki collection (an sqlite database where anki stores flash cards and other data). The normal locations at the time of writing are as follows:
46
+ - MAC OS
47
+ - `~/Library/Application Support/Anki2/User 1/collection.anki2`
48
+ - Linux
49
+ - `~/.local/share/Anki2/User 1/collection.anki2`
50
+
51
+ - `database_safety_check`: a boolean which when set to `true` will check for any running processes using the anki collection.
52
+ > **:warning: Caution:**
53
+ > Setting this option to false may result in database corruption. Always ensure your anki is backed up.
54
+
55
+ - `allow_duplicates`: a boolean which when set to `true` allows duplicate cards (all field values match another cards field values) to be added.
56
+ s
57
+ Example configuration:
58
+
59
+ ```toml
60
+ # where to find the anki collection (sqlite db where anki stores data)
61
+ # Usual system lo
62
+ anki_database = "~/.local/share/Anki2/User 1/collection.anki2"
63
+
64
+ # whether or not to check for other processes using the anki database
65
+ database_safety_check = true
66
+
67
+ # whether or not to allow duplicate cards to be added
68
+ allow_duplicates = false
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ Hanky can be used as both a command line application and a library.
74
+
75
+ - If you want to do something more complex than simply adding cards directly from files, such as generating speech, querying an api or performing other operations at runtime, see the [Library Tutorial](#library-tutorial)
76
+
77
+ - If you just want to load flash cards from files, jump to the [command line usage](#command-line-usage)
78
+
79
+ ## Library Tutorial
80
+
81
+
82
+ ## Command Line Usage
83
+
84
+ Hanky can be used out of the box as a command line application. If running your own hanky script, omit the `-m` seen in the below examples.
85
+
86
+ ### Recursively load decks from files in a folder
87
+
88
+ Recursively load all csv files as decks of cards using the 'basic' anki model/note type. The relative path from the specified folder will be used as the deck name.
89
+
90
+ `python3 -m hanky load "basic" "~/french/" "*.csv" -r`
91
+
92
+ For example, given the following folder structure:
93
+ ```
94
+ french
95
+ ├── animals.csv
96
+ ├── bodies.csv
97
+ ├── clothing.csv
98
+ └── grammar
99
+ └── passe_compose.csv
100
+ ```
101
+
102
+ The following decks will be created:
103
+ - `french`: top level deck
104
+ - `french::animals`: nested animal vocab deck
105
+ - `french::bodies`: nested bodies vocab deck
106
+ - `french::clothing`: nested clothing vocab deck
107
+ - `french::grammar`: nested container deck for grammar
108
+ - `french::grammar::passe_compose`: doubly nested deck for passe compose rules
109
+
110
+ The created anki decks will have the following structure:
111
+ ```
112
+ french
113
+ ├── animals
114
+ ├── bodies
115
+ ├── clothing
116
+ └── grammar
117
+ └── passe_compose
118
+ ```
119
+
120
+ ### Load decks from files from a folder
121
+
122
+ Load all csv files in a folder as decks of cards using the 'basic' anki model/note type. The relative path from the specified folder will be used as the deck name.
123
+
124
+ `python3 -m hanky load "basic" "~/french/" "*.csv"`
125
+
126
+ For example, given the following folder structure:
127
+ ```
128
+ french
129
+ ├── animals.csv
130
+ ├── bodies.csv
131
+ ├── clothing.csv
132
+ └── grammar
133
+ └── passe_compose.csv
134
+ ```
135
+
136
+ The following decks will be created:
137
+ - `french`: top level deck
138
+ - `french::animals`: nested animal vocab deck
139
+ - `french::bodies`: nested bodies vocab deck
140
+ - `french::clothing`: nested clothing vocab deck
141
+
142
+ The created anki decks will have the following structure:
143
+ ```
144
+ french
145
+ ├── animals
146
+ ├── bodies
147
+ ├── clothing
148
+ ```
149
+
150
+ ### Load a deck from a file
151
+
152
+ Load a single deck using the 'basic' anki model/note type from a file
153
+
154
+ `python3 -m hanky load-deck "basic" ~/my-folder/countries.csv`
155
+
156
+ The following deck will be created:
157
+ - `countries`
hanky-0.0.1/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # hanky
2
+
3
+ [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
4
+
5
+ Library and command line application for loading flash cards into anki.
6
+
7
+ > **:information_source: Note:**
8
+ > This project is currently in alpha and is not stable.
9
+
10
+ ## Installation
11
+
12
+ Install via pip:
13
+
14
+ `pip install hanky`
15
+
16
+ Optionally install text to speech code seen in tutorial:
17
+
18
+ ` pip install hanky[toc]`
19
+
20
+ ## Configuration
21
+
22
+ Currently two configuration options are exposed:
23
+
24
+ - `anki_database`: tells hanky where to find the anki collection (an sqlite database where anki stores flash cards and other data). The normal locations at the time of writing are as follows:
25
+ - MAC OS
26
+ - `~/Library/Application Support/Anki2/User 1/collection.anki2`
27
+ - Linux
28
+ - `~/.local/share/Anki2/User 1/collection.anki2`
29
+
30
+ - `database_safety_check`: a boolean which when set to `true` will check for any running processes using the anki collection.
31
+ > **:warning: Caution:**
32
+ > Setting this option to false may result in database corruption. Always ensure your anki is backed up.
33
+
34
+ - `allow_duplicates`: a boolean which when set to `true` allows duplicate cards (all field values match another cards field values) to be added.
35
+ s
36
+ Example configuration:
37
+
38
+ ```toml
39
+ # where to find the anki collection (sqlite db where anki stores data)
40
+ # Usual system lo
41
+ anki_database = "~/.local/share/Anki2/User 1/collection.anki2"
42
+
43
+ # whether or not to check for other processes using the anki database
44
+ database_safety_check = true
45
+
46
+ # whether or not to allow duplicate cards to be added
47
+ allow_duplicates = false
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ Hanky can be used as both a command line application and a library.
53
+
54
+ - If you want to do something more complex than simply adding cards directly from files, such as generating speech, querying an api or performing other operations at runtime, see the [Library Tutorial](#library-tutorial)
55
+
56
+ - If you just want to load flash cards from files, jump to the [command line usage](#command-line-usage)
57
+
58
+ ## Library Tutorial
59
+
60
+
61
+ ## Command Line Usage
62
+
63
+ Hanky can be used out of the box as a command line application. If running your own hanky script, omit the `-m` seen in the below examples.
64
+
65
+ ### Recursively load decks from files in a folder
66
+
67
+ Recursively load all csv files as decks of cards using the 'basic' anki model/note type. The relative path from the specified folder will be used as the deck name.
68
+
69
+ `python3 -m hanky load "basic" "~/french/" "*.csv" -r`
70
+
71
+ For example, given the following folder structure:
72
+ ```
73
+ french
74
+ ├── animals.csv
75
+ ├── bodies.csv
76
+ ├── clothing.csv
77
+ └── grammar
78
+ └── passe_compose.csv
79
+ ```
80
+
81
+ The following decks will be created:
82
+ - `french`: top level deck
83
+ - `french::animals`: nested animal vocab deck
84
+ - `french::bodies`: nested bodies vocab deck
85
+ - `french::clothing`: nested clothing vocab deck
86
+ - `french::grammar`: nested container deck for grammar
87
+ - `french::grammar::passe_compose`: doubly nested deck for passe compose rules
88
+
89
+ The created anki decks will have the following structure:
90
+ ```
91
+ french
92
+ ├── animals
93
+ ├── bodies
94
+ ├── clothing
95
+ └── grammar
96
+ └── passe_compose
97
+ ```
98
+
99
+ ### Load decks from files from a folder
100
+
101
+ Load all csv files in a folder as decks of cards using the 'basic' anki model/note type. The relative path from the specified folder will be used as the deck name.
102
+
103
+ `python3 -m hanky load "basic" "~/french/" "*.csv"`
104
+
105
+ For example, given the following folder structure:
106
+ ```
107
+ french
108
+ ├── animals.csv
109
+ ├── bodies.csv
110
+ ├── clothing.csv
111
+ └── grammar
112
+ └── passe_compose.csv
113
+ ```
114
+
115
+ The following decks will be created:
116
+ - `french`: top level deck
117
+ - `french::animals`: nested animal vocab deck
118
+ - `french::bodies`: nested bodies vocab deck
119
+ - `french::clothing`: nested clothing vocab deck
120
+
121
+ The created anki decks will have the following structure:
122
+ ```
123
+ french
124
+ ├── animals
125
+ ├── bodies
126
+ ├── clothing
127
+ ```
128
+
129
+ ### Load a deck from a file
130
+
131
+ Load a single deck using the 'basic' anki model/note type from a file
132
+
133
+ `python3 -m hanky load-deck "basic" ~/my-folder/countries.csv`
134
+
135
+ The following deck will be created:
136
+ - `countries`
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hanky"
7
+ authors = [{ name = "HBA", email = "hanky-pypi.8ebs0@simplelogin.com" }]
8
+ description = "Simple library and command line tool for loading flash cards into anki."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Environment :: Console",
15
+ "Operating System :: Unix",
16
+ ]
17
+
18
+ keywords = ["anki"]
19
+ dynamic = ["version"]
20
+
21
+ dependencies = [
22
+ "anki",
23
+ "psutil"
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ # tos = [
28
+ # "boto3"
29
+ # ]
30
+
31
+ dev = [
32
+ "pytest",
33
+ "mypy"
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Haeata-Ash/hanky"
38
+ Issues = "https://github.com/Haeata-Ash/hanky/issues"
39
+
40
+ [tool]
41
+
42
+ [tool.hatch.version]
43
+ path = "src/hanky/__about__.py"
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ exclude = [
47
+ "/.github",
48
+ "/docs",
49
+ "/tests",
50
+ "/.venv",
51
+ "/.mypy_cache",
52
+ "/demo.py",
53
+ "/.*"
54
+ ]
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["src/hanky"]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1 @@
1
+ from .hanky import Hanky as Hanky
@@ -0,0 +1,5 @@
1
+ from hanky import Hanky
2
+
3
+ if __name__ == "__main__":
4
+ hanky = Hanky()
5
+ hanky.run()
@@ -0,0 +1,135 @@
1
+ import argparse
2
+
3
+
4
+ # def make_parser():
5
+ # parser = argparse.ArgumentParser(
6
+ # "hanky",
7
+ # description="Simple program to allow programatic management of anki cards",
8
+ # )
9
+ # parser.add_argument(
10
+ # "--config",
11
+ # dest="config",
12
+ # nargs=1,
13
+ # help="Path to hanky json configuration file",
14
+ # )
15
+
16
+
17
+ # object_subparsers = parser.add_subparsers(
18
+ # dest = "object",
19
+ # help="Type of anki object to manage",
20
+ # required = True
21
+ # )
22
+
23
+
24
+ # card_obj = object_subparsers.add_parser(
25
+ # "card", help="Perform an operation on anki cards."
26
+ # )
27
+
28
+ # card_cmds = card_obj.add_subparsers(
29
+ # dest="action",
30
+ # required=True,
31
+ # help="Action to perform against anki cards."
32
+ # )
33
+
34
+ # # add anki cards to a deck using model
35
+ # add_card = card_cmds.add_parser("add", help="Add card(s) to an anki deck.")
36
+ # add_card.add_argument("-i", "--input-file", required=True, help="Source file to add the cards from.")
37
+ # add_card.add_argument("-m", "--model", required=True, help="Anki model to create the card from.")
38
+ # add_card.add_argument("-d", "--deck", required=True, help="Anki deck add the card to")
39
+ # add_card.add_argument("--model-args", help="Arguments to pass to the model.")
40
+ # # list anki cards in a deck
41
+ # list_card = card_cmds.add_parser("list", help="List card(s) in an anki deck.")
42
+ # list_card.add_argument("-d", "--deck", required=True, help="List anki cards from deck")
43
+
44
+
45
+ # deck_obj = object_subparsers.add_parser(
46
+ # "deck", help="Perform an operation on an anki deck."
47
+ # )
48
+
49
+
50
+ # deck_cmds = deck_obj.add_subparsers(
51
+ # dest="action",
52
+ # required=True,
53
+ # help="Action to perform against an anki deck."
54
+ # )
55
+
56
+
57
+ # add_deck = deck_cmds.add_parser("add", help="Add deck to the anki collection.")
58
+
59
+ # # add anki deck
60
+ # add_deck.add_argument("name", help="Full name of the anki deck, including '::' for nesting.")
61
+ # add_deck.add_argument("-p", "--parent", help="Optionally specify parent deck to avoid manually adding '::' for nesting.")
62
+
63
+ # # list anki decks
64
+ # add_deck = deck_cmds.add_parser("list", help="Add deck to the anki collection.")
65
+
66
+
67
+ # model_obj = object_subparsers.add_parser(
68
+ # "model", help="Perform an operation on an anki model."
69
+ # )
70
+
71
+ # model_cmds = model_obj.add_subparsers(
72
+ # dest="action",
73
+ # required=True,
74
+ # help="Action to perform against an anki model."
75
+ # )
76
+
77
+ # # list anki cards in a deck
78
+ # add_card = model_cmds.add_parser("list", help="List models in anki collection.")
79
+
80
+ # return parser
81
+
82
+
83
+ class KeyValueArg(argparse.Action):
84
+ def __call__(self, parser, namespace, values, option_string=None):
85
+ setattr(namespace, self.dest, dict())
86
+
87
+ for v in values:
88
+ print(v)
89
+ key, value = v.split("=")
90
+ getattr(namespace, self.dest)[key] = value
91
+
92
+ def make_parser():
93
+ parser = argparse.ArgumentParser(
94
+ "hanky",
95
+ description="Simple program to allow programatic management of anki cards",
96
+ )
97
+ parser.add_argument(
98
+ "--config",
99
+ dest="config",
100
+ help="Path to hanky json configuration file",
101
+ )
102
+
103
+ op_parser = parser.add_subparsers(
104
+ dest = "operation",
105
+ help="Type of operation to perform",
106
+ required = True
107
+ )
108
+ load_file = op_parser.add_parser(
109
+ "load-deck",
110
+ help="Load cards into an anki deck from a file"
111
+ )
112
+ load_file.add_argument(
113
+ "model",
114
+ help="Name of the anki model to create cards with."
115
+ )
116
+ load_file.add_argument("file", help="Path of the file to load from")
117
+
118
+
119
+ load_file.add_argument("-d", "--deck", dest="deck", default=None, help="Name of the deck to load cards into. If not specified, defaults to the filename without the extension.")
120
+ load_file.add_argument("--args", dest="args", default={}, nargs="*", action=KeyValueArg, help="Key value arguments to pass to registered transformers.")
121
+
122
+ load_dir = op_parser.add_parser(
123
+ "load",
124
+ help="Load cards into anki deck(s) from files in a directory, using the filenames as deck names."
125
+ )
126
+ load_dir.add_argument("-r", "--recursive", dest="is_rec", action="store_true", default=False, help="If loading files from a directory, recursively load from files in sub directories as well.")
127
+ load_dir.add_argument(
128
+ "model",
129
+ help="Name of the anki model to create cards with."
130
+ )
131
+ load_dir.add_argument("dir", help="Path of the file to load from")
132
+ load_dir.add_argument("pattern", help="Glob pattern used to decide which files to load. For example, '*.csv'")
133
+
134
+ load_dir.add_argument("--args", dest="args", default={}, nargs="*", action=KeyValueArg, help="Key value arguments to pass to registered transformers.")
135
+ return parser
@@ -0,0 +1,56 @@
1
+ import platform
2
+ from pathlib import Path
3
+ from typing import Callable, Union
4
+
5
+ def _get_default_anki_db_path() -> str:
6
+ """Choose a default path for the anki sqlite collection database based on
7
+ the OS.
8
+
9
+ Defaults:
10
+ Linux: "~/.local/share/Anki2/User 1/collection.anki2"
11
+ MacOS: "~/Library/Application Support/Anki2/User 1/collection.anki2"
12
+ """
13
+ if platform.system() == "Linux":
14
+ return "~/.local/share/Anki2/User 1/collection.anki2"
15
+ elif platform.system() == "Darwin":
16
+ return "~/Library/Application Support/Anki2/User 1/collection.anki2"
17
+ else:
18
+ return ""
19
+
20
+
21
+ ANKI_DB_PATH = "anki_database"
22
+ DO_SAFET_CHECK = "database_safety_check"
23
+ ALLOW_DUPLICATES = "allow_duplicates"
24
+
25
+ DEFAULT_CONFIG = {
26
+ ANKI_DB_PATH: _get_default_anki_db_path(),
27
+ DO_SAFET_CHECK: True,
28
+ ALLOW_DUPLICATES: False
29
+ }
30
+
31
+ class Config(dict):
32
+ """Configuration object"""
33
+
34
+
35
+ def __init__(self, **kwargs):
36
+ self._config = None
37
+ self.default_path = Path("~/.config/hanky/hanky.toml").expanduser()
38
+ super().__init__(kwargs)
39
+
40
+ def from_file(
41
+ self,
42
+ file: Union[Path, str],
43
+ loader: Callable[[Union[str, Path], dict], dict],
44
+ text=False,
45
+ **kwargs,
46
+ ):
47
+ with open(file, "r" if text else "rb") as f:
48
+ cfg = loader(f, **kwargs)
49
+ if not isinstance(cfg, dict):
50
+ raise TypeError(
51
+ f"Received type '{type(cfg)}' but expected '{type(dict)}' from loader function."
52
+ )
53
+
54
+ for k, v in cfg.items():
55
+ self[k] = v
56
+
@@ -0,0 +1,35 @@
1
+ from typing import Generator, Iterator, Union, TextIO, Callable
2
+ from pathlib import Path
3
+ import json
4
+ import csv
5
+ import psutil
6
+
7
+
8
+ def read_file(
9
+ path: str, loader: Callable[[TextIO], Union[Iterator, Generator]]
10
+ ) -> Union[Iterator, Generator]:
11
+ path = Path(path)
12
+ if not path.is_file():
13
+ raise IOError(f"{path} is not a file.")
14
+ with open(path, "r") as f:
15
+ for item in loader(f):
16
+ yield item
17
+
18
+
19
+ DEFAULT_LOADERS = {".json": json.load, ".csv": csv.DictReader}
20
+
21
+
22
+
23
+
24
+ def has_handle(fpath):
25
+ fpath = Path(fpath).expanduser().absolute()
26
+ for proc in psutil.process_iter():
27
+ try:
28
+ for item in proc.open_files():
29
+ if str(fpath) == str(item.path):
30
+ return True
31
+ except psutil.Error:
32
+ pass
33
+
34
+ return False
35
+
@@ -0,0 +1,351 @@
1
+ import functools
2
+ import hashlib
3
+ from pathlib import Path
4
+ from typing import Callable, Generator, Iterator, List, Union
5
+
6
+ from anki.collection import Collection, SearchNode
7
+ from tomllib import load as toml_load
8
+
9
+ from hanky.cli import make_parser
10
+ from hanky.config import (
11
+ ALLOW_DUPLICATES,
12
+ ANKI_DB_PATH,
13
+ DEFAULT_CONFIG,
14
+ DO_SAFET_CHECK,
15
+ Config,
16
+ )
17
+ from hanky.fs import DEFAULT_LOADERS, has_handle, read_file
18
+ from hanky.media import is_audio_ext, make_anki_sound_ref
19
+
20
+
21
+ class ModelProcessor:
22
+ def __init__(self, model_name: str, func, expected_args, required_fields):
23
+ self.f = func
24
+ self.model = model_name
25
+ self.expected_args = expected_args
26
+ self.required_fields = required_fields
27
+
28
+ if not isinstance(self.expected_args, list):
29
+ raise TypeError("'expected_args' must be a list of strings")
30
+ if not isinstance(self.required_fields, list):
31
+ raise TypeError("'required_fields' must be a list of strings")
32
+
33
+ def __call__(self, card: dict, **kwargs) -> dict:
34
+ for k in self.required_fields:
35
+ if k not in card:
36
+ raise KeyError(
37
+ f"Processor requires '{k}' to be present in card. \n {str(card)}"
38
+ )
39
+
40
+ for k in self.expected_args:
41
+ if k not in kwargs:
42
+ raise KeyError(
43
+ f"Processor for {self.model} expects key word argument '{k}'. Ensure it is passed in via the --model-args option"
44
+ )
45
+
46
+ ret = self.f(card, **kwargs)
47
+ if not isinstance(ret, dict):
48
+ raise TypeError(
49
+ f"Processor function did not return a dictionary like object, returned type {type(ret)}"
50
+ )
51
+
52
+ return ret
53
+
54
+
55
+ class Hanky:
56
+ def __init__(self, **kwargs):
57
+ # set default config and then overwrite with config object provided via constructor
58
+ # ensures default keys are present
59
+ self.config: Config = Config(**DEFAULT_CONFIG)
60
+ if kwargs:
61
+ self.config.update(kwargs)
62
+
63
+ self._col: Collection = None
64
+
65
+ self.processors = dict()
66
+ self.loaders = dict(DEFAULT_LOADERS)
67
+
68
+ def run(self):
69
+ parser = make_parser()
70
+ args = parser.parse_args()
71
+
72
+ if args.config:
73
+ self.config.from_file(args.config, toml_load)
74
+
75
+ if args.operation == "load-deck":
76
+ self.load_deck(
77
+ args.file,
78
+ args.model,
79
+ deck_name=args.deck,
80
+ **(args.args) if args.args else {},
81
+ )
82
+
83
+ elif args.operation == "load":
84
+ self.load_dir(
85
+ args.model,
86
+ args.dir,
87
+ args.pattern,
88
+ recursive=args.is_rec,
89
+ *(args.args) if args.args else {},
90
+ )
91
+
92
+ @property
93
+ def col(self):
94
+ if not self._col:
95
+ if not self.config[ANKI_DB_PATH]:
96
+ raise RuntimeError(
97
+ """Path to anki sqlite collection database was
98
+ not provided in config and no suitable default known."""
99
+ )
100
+ db_path = Path(self.config[ANKI_DB_PATH]).expanduser().absolute()
101
+
102
+ if not db_path.exists() or not db_path.is_file():
103
+ raise FileNotFoundError(
104
+ f"'{db_path}' either does not exist or is not a file. Please check the provided path to the anki collection."
105
+ )
106
+
107
+ if self.config[DO_SAFET_CHECK]:
108
+ if has_handle(self.config[ANKI_DB_PATH]):
109
+ raise RuntimeError(
110
+ """At least one other process is using the anki database. Ensure the Anki application is closed before using Hanky to avoid possible corruption."""
111
+ )
112
+
113
+ self._col = Collection(db_path)
114
+ return self._col
115
+
116
+ def add_card(
117
+ self,
118
+ deck_name,
119
+ model_name,
120
+ filter_query: str = None,
121
+ allow_duplicates=False,
122
+ **fields,
123
+ ) -> bool:
124
+ model = self.col.models.by_name(model_name)
125
+ if not model:
126
+ raise ValueError(
127
+ f"Model '{model_name}' does not exist in your anki collection. Ensure it has been added before using it with hanky."
128
+ )
129
+ deck_id = self.col.decks.id(deck_name, create=False)
130
+ if not deck_id:
131
+ ValueError(
132
+ f"Deck '{deck_name}' does not exist in your anki collection. Ensure it has been created before using it with hanky."
133
+ )
134
+ expected_fields = self.col.models.field_names(model)
135
+ for k in expected_fields:
136
+ if k not in fields:
137
+ raise KeyError(f"Expected field '{k}' is missing.")
138
+
139
+ new_card = self.col.new_note(model)
140
+
141
+ for k, v in fields.items():
142
+ new_card[k] = str(v)
143
+
144
+ if filter_query:
145
+ matches = self.col.find_cards(filter_query)
146
+ if len(matches):
147
+ return False
148
+
149
+ allow_duplicates = allow_duplicates if allow_duplicates else self.config[ALLOW_DUPLICATES]
150
+
151
+ if not allow_duplicates:
152
+ rets = [True]
153
+
154
+ for field in fields:
155
+ if self.col.find_notes(
156
+ self.col.build_search_string(
157
+ new_card[field], SearchNode(field_name=field)
158
+ )
159
+ ):
160
+ rets.append(True)
161
+ else:
162
+ rets.append(False)
163
+
164
+ is_dupe = functools.reduce(lambda x, y: x and y, rets)
165
+
166
+ if is_dupe:
167
+ return False
168
+
169
+ self.col.add_note(new_card, deck_id)
170
+
171
+ return True
172
+
173
+ def add_deck(self, deck_name) -> bool:
174
+ self.col.decks.id(deck_name)
175
+ return True
176
+
177
+ def register_loader(
178
+ self, file_ext: str, loader: Callable[[str], Union[Iterator, Generator]]
179
+ ):
180
+ self.loaders[file_ext] = loader
181
+
182
+ def register_card_processor(
183
+ self,
184
+ model_name: str,
185
+ handler: Callable[[dict], dict],
186
+ expected_args: List[str] = [],
187
+ expected_fields: List[str] = [],
188
+ ):
189
+ if model_name not in self.processors:
190
+ self.processors[model_name] = []
191
+ self.processors[model_name].append(
192
+ ModelProcessor(model_name, handler, expected_args, expected_fields)
193
+ )
194
+
195
+ def card_processor(
196
+ self, model: str, expected_args: List[str], expected_fields: List[str]
197
+ ):
198
+ def decorator(func):
199
+ self.register_card_processor(model, func, expected_args, expected_fields)
200
+ return func
201
+
202
+ return decorator
203
+
204
+ def get_model_processors(self, model_name: str) -> List[ModelProcessor]:
205
+ if model_name in self.processors:
206
+ return self.processors[model_name]
207
+
208
+ return []
209
+
210
+ def get_loader(self, suffix) -> Callable:
211
+ return self.loaders[suffix]
212
+
213
+ def load_deck(
214
+ self,
215
+ fpath: str,
216
+ model_name: str,
217
+ deck_name: str = None,
218
+ loader=None,
219
+ parent_deck="",
220
+ **model_args,
221
+ ):
222
+ print(f"Loading into deck {deck_name}")
223
+ fpath = Path(fpath).absolute()
224
+
225
+ transformers = self.get_model_processors(model_name)
226
+ loader = loader if loader else self.get_loader(fpath.suffix)
227
+
228
+ model = self.col.models.by_name(model_name)
229
+ if not model:
230
+ raise KeyError(
231
+ f"Model '{model_name}' does not exist in your anki collection. Ensure it has been added before using it with hanky."
232
+ )
233
+
234
+ # deck is the specified name or filename without extension
235
+ deck_name = deck_name if deck_name else fpath.stem
236
+
237
+ self.add_deck(deck_name)
238
+
239
+ count = 0
240
+ total = 0
241
+ for item in read_file(fpath, loader):
242
+ card = dict(item)
243
+ for t in transformers:
244
+ card = t(card, **model_args)
245
+
246
+ ret = self.add_card(
247
+ deck_name,
248
+ model_name,
249
+ **card,
250
+ )
251
+ total += 1
252
+ if ret:
253
+ count += 1
254
+
255
+ print(f"Added {count} out of {total} cards.")
256
+
257
+ def add_media(
258
+ self, data, anki_media_filename: str = None, file_ext: str = None
259
+ ) -> str:
260
+ ext = None
261
+ if anki_media_filename:
262
+ path = Path(anki_media_filename)
263
+ ext = path.suffix
264
+ elif file_ext:
265
+ ext = file_ext
266
+ else:
267
+ raise ValueError(
268
+ "If argument 'anki_media_filename' is not provided then 'file_ext' must be present"
269
+ )
270
+
271
+ if isinstance(data, str):
272
+ data = data.encode()
273
+
274
+ desired_name = anki_media_filename
275
+
276
+ # no filename given, use hash of the data plus file_ext
277
+ if not desired_name:
278
+ m = hashlib.sha256()
279
+ m.update(data)
280
+ desired_name = m.hexdigest() + ext
281
+
282
+ # write media to anki database
283
+ actual_name = self.col.media.write_data(
284
+ desired_name,
285
+ data,
286
+ )
287
+
288
+ anki_ref = self.col.media.escape_media_filenames(actual_name)
289
+
290
+ if is_audio_ext(actual_name):
291
+ anki_ref = make_anki_sound_ref(anki_ref)
292
+
293
+ return anki_ref
294
+
295
+ def add_media_file(self, local_path) -> str:
296
+ anki_ref = self.col.media.escape_media_filenames(
297
+ self.col.media.add_file(local_path)
298
+ )
299
+ if is_audio_ext(anki_ref):
300
+ anki_ref = make_anki_sound_ref(anki_ref)
301
+
302
+ return anki_ref
303
+
304
+ def load_dir(
305
+ self,
306
+ model: str,
307
+ root_dir: str,
308
+ glob_pattern: str,
309
+ recursive=False,
310
+ parent_deck: str = "",
311
+ loader=None,
312
+ **model_args,
313
+ ):
314
+ parent_deck = ""
315
+
316
+ root = Path(root_dir).expanduser()
317
+
318
+ root_deck = parent_deck if parent_deck else root.name
319
+
320
+ def _glob(root, pattern, recursive):
321
+ if recursive:
322
+ for path in root.rglob(pattern):
323
+ yield path
324
+ else:
325
+ for path in root.glob(pattern):
326
+ yield path
327
+
328
+ for path in _glob(root, glob_pattern, recursive):
329
+ if path.is_file():
330
+ path = path.relative_to(root)
331
+ abs_path = root.joinpath(path)
332
+ parents = [p.name for p in reversed(path.parents)]
333
+
334
+ # don't need the first empty entry for the current directory
335
+ parents.pop(0)
336
+ deck_list = [root_deck]
337
+
338
+ i = 0
339
+ while i < len(parents):
340
+ deck_list.append(parents[i])
341
+ i += 1
342
+ deck_list.append(path.stem)
343
+ full_deck = "::".join(deck_list)
344
+
345
+ self.load_deck(
346
+ abs_path,
347
+ model,
348
+ deck_name=full_deck,
349
+ loader=loader,
350
+ **model_args,
351
+ )
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def is_audio_ext(filename: str) -> bool:
5
+ AUDIO_EXT = set(
6
+ ".mp3",
7
+ ".oga",
8
+ ".opus",
9
+ ".wav",
10
+ ".weba",
11
+ ".aac",
12
+ )
13
+
14
+ if Path(filename).suffix in AUDIO_EXT:
15
+ return True
16
+
17
+ return False
18
+
19
+
20
+ def make_anki_sound_ref(media_ref: str) -> str:
21
+ return f"[sound:{media_ref}]"