├── .github └── workflows │ ├── codeql-analysis.yml │ ├── deploy-pypi.yml │ └── python-app.yml ├── .gitignore ├── LICENSE ├── PLAN.md ├── README.md ├── pykindler ├── __init__.py ├── cli.py ├── constants.py ├── convertor.py ├── tests │ ├── test_bash_utils.py │ ├── test_convert_utils.py │ ├── test_cron_utils.py │ ├── test_email_utils.py │ ├── test_nlp_utils.py │ └── test_os_utils.py └── utils │ ├── __init__.py │ ├── bash_utils.py │ ├── convert_utils.py │ ├── cron_utils.py │ ├── email_utils.py │ ├── nlp_utils.py │ └── os_utils.py ├── requirements.txt └── setup.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '21 4 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pypi.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install m2r 26 | pip install setuptools wheel twine 27 | - name: Build and publish 28 | env: 29 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 30 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 31 | run: | 32 | python setup.py sdist bdist_wheel 33 | twine upload dist/* 34 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: pykindler 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.9 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest m2r 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Run black --check . 29 | run: black --check . 30 | - name: If needed, commit black changes to the pull request 31 | if: failure() 32 | run: | 33 | black . 34 | git config --global user.name 'autoblack' 35 | git config --global user.email 'cclauss@users.noreply.github.com' 36 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 37 | git checkout $GITHUB_HEAD_REF 38 | git commit -am "fixup: Format Python code with Black" 39 | git push 40 | - name: Test with unittest 41 | run: | 42 | python -m unittest discover -s ./pykindler/tests -v 43 | - name: Code Coverage Report 44 | uses: romeovs/lcov-reporter-action@v0.2.11 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 VivekBits2210 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. 22 | -------------------------------------------------------------------------------- /PLAN.md: -------------------------------------------------------------------------------- 1 | # pykindler 2 | Command line tool that automatically detects and converts downloaded e-books to mobi format (and auto-uploads to Kindle, on a schedule) 3 | 4 | ## Work Left 5 | I'll be updating the plan [here](https://docs.google.com/document/d/1ZjnNMVRCZE592LtXDs4G56BMRfQ9DBM9hKjtFrYV2w8/edit?usp=sharing) instead 6 | 7 | ## License 8 | [MIT](LICENSE) 9 | 10 | ## Contributing to pykindler 11 | 12 | All contributions, bug reports, bug fixes, documentation improvements, enhancements, and ideas are welcome. 13 | Please feel free to mail ideas to the maintainer: [viveknayak2210@gmail.com] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pykindler 2 | Command line tool that automatically detects and converts downloaded e-books to mobi format (and auto-uploads to Kindle, on a schedule) 3 | 4 | ## Where to get it 5 | The source code is currently hosted on GitHub at: 6 | https://github.com/VivekBits2210/pykindler 7 | 8 | Package is deployed at [Python Package Index (PyPI)](https://pypi.org/project/pykindler/) 9 | 10 | ```sh 11 | pip3 install pykindler 12 | ``` 13 | 14 | Ensure Calibre is installed ([Guide](https://calibre-ebook.com/download_linux)) 15 | ```sh 16 | sudo -v && wget -nv -O- https://download.calibre-ebook.com/linux-installer.sh | sudo sh /dev/stdin 17 | ``` 18 | 19 | Know your Kindle's e-mail address [here](https://www.amazon.com/gp/sendtokindle/email) 20 | 21 | ## Dependencies 22 | - UNIX based system needed (Linux, MacOS) 23 | - Calibre 24 | 25 | ## Which packages are used under the hood? 26 | - [pyenchant - Offers an English Dictionary, helpful in detecting if a file is a book](https://pypi.org/project/pyenchant/) 27 | - [python-crontab - Allows scheduling of pykindler jobs that auto-convert and email your books in the background](https://pypi.org/project/python-crontab/) 28 | - [pgi - Auto-detects your downloads folder](https://pypi.org/project/pgi/) 29 | - [black - Code formatter](https://pypi.org/project/black/) 30 | - [argparse - Neater interface with command line arguments](https://pypi.org/project/argparse/) 31 | - [keyring - Safe storage of email credentials](https://pypi.org/project/keyring/) 32 | 33 | ## Usage 34 | - In default mode, pykindler will auto-detect your downloads folder and populate 'Converted_Books' and 'Processed_Books' folders 35 | ```sh 36 | pykindler-run 37 | ``` 38 | - In custom mode, specify your downloads folder and setup a twice-a-day conversion job 39 | ```sh 40 | pykindler-run --folder /home/some-user/Desktop --job 41 | ``` 42 | 43 | - If you just want to convert one file, specify it after --file 44 | ```sh 45 | pykindler-run --file /home/some-user/Desktop/my-book.epub 46 | ``` 47 | - If you want to not convert to mobi, choose your own extension with --ext 48 | ```sh 49 | pykindler-run --file /home/some-user/Desktop/my-book.mobi --ext epub 50 | ``` 51 | 52 | - For more help 53 | ```sh 54 | pykindler-run -h 55 | ``` 56 | 57 | ## License 58 | [MIT](LICENSE) 59 | 60 | ## Getting Help 61 | 62 | For usage questions, the best place to go to is [StackOverflow](https://stackoverflow.com/questions/). 63 | 64 | ## Contributing to pykindler 65 | 66 | All contributions, bug reports, bug fixes, documentation improvements, enhancements, and ideas are welcome. 67 | Here is what I [plan to do next](https://docs.google.com/document/d/1ZjnNMVRCZE592LtXDs4G56BMRfQ9DBM9hKjtFrYV2w8/edit?usp=sharing) 68 | Please feel free to mail ideas to the maintainer: [viveknayak2210@gmail.com] 69 | -------------------------------------------------------------------------------- /pykindler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VivekBits2210/pykindler/44ad38b36f1785b31757c459dcc5b649eedd8112/pykindler/__init__.py -------------------------------------------------------------------------------- /pykindler/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pykindler.utils.os_utils import make_required_inodes 3 | import sys 4 | from pykindler.utils.bash_utils import ( 5 | get_commandline_args, 6 | check_commandline_args, 7 | process_commandline_args, 8 | ) 9 | from .utils.cron_utils import setup_cron_job 10 | from .utils.email_utils import * 11 | from .convertor import process_and_convert_books 12 | from os import path, rename 13 | 14 | 15 | def client(): 16 | args = get_commandline_args(sys.argv[1:]) 17 | error_message = check_commandline_args(args) 18 | if error_message is not None: 19 | print(error_message) 20 | sys.exit(2) 21 | 22 | file_list, download_dir = process_commandline_args(args) 23 | if file_list is None and download_dir is None: 24 | print("Error: Unable to detect downloads folder, please specify --folder") 25 | sys.exit(2) 26 | 27 | if args.job is True: 28 | setup_cron_job(args) 29 | 30 | if args.ext is None: 31 | args.ext = "mobi" 32 | 33 | if bool(args.email is None) != bool(args.kindle is None): 34 | print("Error: To auto-email, please provide both --email and --kindle") 35 | sys.exit(2) 36 | 37 | if args.email is not None: 38 | try: 39 | session_object = GmailSession(args.email, args.askcred) 40 | print("Credentials are valid! Books will be e-mailed after conversion...\n") 41 | except OSError: 42 | print("Error: Please enter valid e-mail credentials!") 43 | sys.exit(2) 44 | 45 | converted_file_list = process_and_convert_books(file_list, download_dir, args) 46 | if args.email is not None: 47 | print("\n Auto-emailing books to Kindle...") 48 | emailed_file_list = send_a_bunch_of_files_to_kindle( 49 | session_object, converted_file_list, args.email, args.kindle 50 | ) 51 | for file_path in emailed_file_list: 52 | emailed_path_dir = path.join(path.split(file_path)[0], "Emailed_Books") 53 | make_required_inodes([emailed_path_dir], []) 54 | rename( 55 | file_path, 56 | path.join(emailed_path_dir, path.basename(file_path)), 57 | ) 58 | 59 | print("Exiting...") 60 | sys.exit() 61 | -------------------------------------------------------------------------------- /pykindler/constants.py: -------------------------------------------------------------------------------- 1 | argument_dict = { 2 | "folder": ("folder to run conversion on (defaults to downloads if not specified)",), 3 | "file": ("absolute file path for conversion, if you want a single file converted",), 4 | "kindle": ("your Kindle email-id, converted books are emailed to here",), 5 | "email": ("your personal email-id, converted books are emailed from here",), 6 | "ext": ("extension to convert to (defaults to mobi if not specified)",), 7 | "job": ("sets up a twice-a-day conversion job for specified folder", "store_true"), 8 | "force": ("only perform extensions check before converting", "store_true"), 9 | "askcred": ("re-enter your email password, override the stored one", "store_true"), 10 | } 11 | conversion_threshold_in_mb = 10 12 | gmail_attachment_threshold_mb = 25 13 | valid_extensions_for_conversion = ["pdf", "djvu", "azw3", "epub", "mobi"] 14 | extension_list = ["." + ext for ext in ["pdf", "epub", "djvu", "azw", "azw3", "mobi"]] 15 | metadata_command = lambda x: ["fetch-ebook-metadata", "--opf", "--title", x] 16 | file_appended_hash = "30234f2413d43e5c" 17 | bad_tokens = ["ltd", "libgen", "org", "www", "com", "co"] 18 | -------------------------------------------------------------------------------- /pykindler/convertor.py: -------------------------------------------------------------------------------- 1 | from os import path, rename 2 | from subprocess import CalledProcessError, check_output 3 | from .constants import * 4 | from .utils.convert_utils import trigger_conversion 5 | from .utils.os_utils import ( 6 | make_required_inodes, 7 | name_required_inodes, 8 | convert_file_to_list, 9 | ) 10 | from .utils.nlp_utils import clean_file_name 11 | 12 | 13 | def process_and_convert_books(file_list, folder, args): 14 | print(f"Processing on folder: {folder}") 15 | not_books_file, processed_dir, convert_dir = name_required_inodes(folder) 16 | make_required_inodes([convert_dir, processed_dir], [not_books_file]) 17 | 18 | not_book_list = convert_file_to_list(not_books_file) 19 | not_book_writer = open(not_books_file, "a") 20 | 21 | converted_files = [] 22 | 23 | for filename in file_list: 24 | absolute_file_path = path.join(folder, filename) 25 | 26 | if args.force and True in set( 27 | filename.endswith(extension) for extension in extension_list 28 | ): 29 | converted_file_path = trigger_conversion( 30 | absolute_file_path, processed_dir, convert_dir, args.ext 31 | ) 32 | converted_files.append(converted_file_path) 33 | continue 34 | 35 | # Move specified extension files as is 36 | if filename.endswith("." + args.ext): 37 | rename(absolute_file_path, path.join(convert_dir, filename)) 38 | converted_files.append(path.join(convert_dir, filename)) 39 | continue 40 | 41 | # If we looked at this file on last run, ignore 42 | if filename in not_book_list: 43 | print(f"File: {filename} ignored, as it was ignored on last run") 44 | continue 45 | 46 | # Ignore hidden files 47 | if filename.startswith("."): 48 | continue 49 | 50 | # Don't convert files which are too big 51 | file_size_in_mb = path.getsize(absolute_file_path) / 1e6 52 | if file_size_in_mb > conversion_threshold_in_mb: 53 | print( 54 | f"File: {filename} ignored, violates size threshold: {file_size_in_mb} MB > {conversion_threshold_in_mb} MB" 55 | ) 56 | continue 57 | 58 | if True in set(filename.endswith(extension) for extension in extension_list): 59 | print(f"Looking at: {filename}") 60 | cleaned_file_name, ext = clean_file_name(filename) 61 | 62 | # Don't convert small word names, metadata search messes up these 63 | if len(cleaned_file_name.split()) <= 2: 64 | print("File name has less than 3 words, ignoring...") 65 | continue 66 | 67 | # Filter out non-books using calibre's metadata bash calls 68 | try: 69 | # run(metadata_command(cleaned_file_name), check=True) 70 | check_output(metadata_command(cleaned_file_name)) 71 | converted_file_path = trigger_conversion( 72 | absolute_file_path, processed_dir, convert_dir, args.ext 73 | ) 74 | converted_files.append(converted_file_path) 75 | except CalledProcessError: 76 | print(f"Not a book: {filename}") 77 | not_book_writer.write(filename + "\n") 78 | continue 79 | 80 | # Cleanup 81 | not_book_writer.close() 82 | 83 | print( 84 | f"Finished processing folder: {folder}.\n Please check folders {processed_dir} and {convert_dir} for your books! " 85 | ) 86 | 87 | return converted_files 88 | -------------------------------------------------------------------------------- /pykindler/tests/test_bash_utils.py: -------------------------------------------------------------------------------- 1 | import random, math 2 | from itertools import chain 3 | from unittest import TestCase, mock 4 | from argparse import ArgumentParser 5 | from pykindler.utils import bash_utils 6 | from pykindler.constants import argument_dict, valid_extensions_for_conversion 7 | 8 | 9 | class TestBashUtils(TestCase): 10 | def setUp(self) -> None: 11 | self.parser = bash_utils.construct_parser() 12 | 13 | def test_construct_parser(self): 14 | self.parser = bash_utils.construct_parser() 15 | self.assertIsInstance( 16 | self.parser, ArgumentParser, "parser object not the right instance!" 17 | ) 18 | 19 | def test_load_arguments(self): 20 | self.args = bash_utils.load_arguments( 21 | self.parser, argument_dict 22 | ) # Should run without complaints 23 | self.assertEqual(True, True) 24 | 25 | def test_get_command_line_args(self): 26 | one_tenth_of_possibilities = math.ceil( 27 | (pow(2, len(argument_dict.keys()) - 1) / 10) 28 | ) 29 | arg_list = [] 30 | for key in argument_dict: 31 | option = "--" + key 32 | if len(argument_dict[key]) == 1: 33 | arg_list.append((option, "some_string")) 34 | else: 35 | arg_list.append((option,)) 36 | 37 | for i in range(one_tenth_of_possibilities): 38 | arbitrary_size = random.randint(1, len(arg_list)) 39 | arbitrary_choice_of_arguments = random.sample(arg_list, arbitrary_size) 40 | flat_argument_list = list(chain(*arbitrary_choice_of_arguments)) 41 | bash_utils.get_commandline_args( 42 | flat_argument_list 43 | ) # Should run without complaints 44 | self.assertEqual(True, True) 45 | 46 | def test_check_commandline_args(self): 47 | arg_list_cases = { 48 | ("--folder", "some_string"): False, 49 | ("--folder", "some_string", "pykindler.utils.bash_utils.path.isdir"): False, 50 | ("--kindle", "valid@kindle.com"): True, 51 | ("--kindle", "notvalid@kindle.c"): False, 52 | ("--file", "some_string"): False, 53 | ("--file", "some_string", "pykindler.utils.bash_utils.path.isfile"): False, 54 | ("--ext", None): True, 55 | ("--ext", random.choice(valid_extensions_for_conversion)): True, 56 | ("--ext", "some_string"): False, 57 | } 58 | 59 | for arg_list_tuple in arg_list_cases: 60 | arg_list = list(arg_list_tuple)[:2] 61 | if len(arg_list_tuple) == 2: 62 | output = bash_utils.check_commandline_args( 63 | bash_utils.get_commandline_args(arg_list) 64 | ) 65 | else: 66 | with mock.patch(arg_list_tuple[2]) as mp: 67 | mp.return_value = False 68 | output = bash_utils.check_commandline_args( 69 | bash_utils.get_commandline_args(arg_list) 70 | ) 71 | if arg_list_cases[arg_list_tuple]: 72 | self.assertEqual(output, None) 73 | else: 74 | self.assertIn("Error", output) 75 | 76 | @mock.patch("pykindler.utils.bash_utils.listdir", return_value=["f1", "f2", "f3"]) 77 | def test_process_commandline_args(self, mock1): 78 | arg_dict = { 79 | (None, None, None): (None, None), 80 | ("/path/some_file", "/path/some_folder", None): ( 81 | ["some_file"], 82 | "/path", 83 | ), 84 | ("/path/some_file", "/path/some_folder", "/path/to/downloads"): ( 85 | ["some_file"], 86 | "/path", 87 | ), 88 | ("/path/some_file", "/path/some_folder", "/path/to/downloads"): ( 89 | ["some_file"], 90 | "/path", 91 | ), 92 | (None, "/path/some_folder", "/path/to/downloads"): ( 93 | ["f1", "f2", "f3"], 94 | "/path/some_folder", 95 | ), 96 | (None, None, "/path/to/downloads"): ( 97 | ["f1", "f2", "f3"], 98 | "/path/to/downloads", 99 | ), 100 | } 101 | 102 | for arg_tuple in arg_dict: 103 | args = mock.MagicMock() 104 | args.file = arg_tuple[0] 105 | args.folder = arg_tuple[1] 106 | with mock.patch( 107 | "pykindler.utils.bash_utils.get_downloads_folder_location", 108 | return_value=arg_tuple[2], 109 | ): 110 | output = bash_utils.process_commandline_args(args) 111 | self.assertTupleEqual(arg_dict[arg_tuple], output) 112 | -------------------------------------------------------------------------------- /pykindler/tests/test_convert_utils.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VivekBits2210/pykindler/44ad38b36f1785b31757c459dcc5b649eedd8112/pykindler/tests/test_convert_utils.py -------------------------------------------------------------------------------- /pykindler/tests/test_cron_utils.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VivekBits2210/pykindler/44ad38b36f1785b31757c459dcc5b649eedd8112/pykindler/tests/test_cron_utils.py -------------------------------------------------------------------------------- /pykindler/tests/test_email_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.case import skip 3 | from pykindler.utils import email_utils 4 | 5 | 6 | class TestEmailUtils(unittest.TestCase): 7 | def test_email_skeleton(self): 8 | pass 9 | -------------------------------------------------------------------------------- /pykindler/tests/test_nlp_utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | import unittest 4 | from unittest import mock 5 | from unittest.case import skip 6 | from pykindler.utils import nlp_utils 7 | 8 | 9 | class TestNlpUtils(unittest.TestCase): 10 | @mock.patch("pykindler.utils.nlp_utils.enchant") 11 | def test_is_word_english(self, mp): 12 | mp.Dict("en_US").check = lambda x: True 13 | self.assertEqual(nlp_utils.is_word_english("some_word"), True) 14 | 15 | def test_is_token_good(self): 16 | from pykindler.constants import bad_tokens 17 | 18 | arg_dict = { 19 | "longword": True, 20 | "": False, 21 | "N": False, 22 | "he": (False, False), 23 | "he": (True, True), 24 | } 25 | 26 | for i in range(math.ceil(len(bad_tokens) / 2)): 27 | arg_dict[random.choice(bad_tokens)] = False 28 | 29 | for arg_token in arg_dict: 30 | with mock.patch("pykindler.utils.nlp_utils.is_word_english") as mp: 31 | dict_output = arg_dict[arg_token] 32 | expected_output = ( 33 | dict_output if type(dict_output) is not tuple else dict_output[0] 34 | ) 35 | mp.return_value = ( 36 | True if type(dict_output) is not tuple else dict_output[1] 37 | ) 38 | output = nlp_utils.is_token_good(arg_token) 39 | self.assertEqual(output, expected_output) 40 | 41 | def test_clean_file_name(self): 42 | arg_dict = { 43 | "439021ujref+32-4rei23er.txt": ("ujref er", "txt"), 44 | "hidfsc #()@there.pdf": ("hidfsc there", "pdf"), 45 | } 46 | 47 | for arg_filename in arg_dict: 48 | output = nlp_utils.clean_file_name(arg_filename) 49 | expected_output = arg_dict[arg_filename] 50 | self.assertTupleEqual(output, expected_output) 51 | -------------------------------------------------------------------------------- /pykindler/tests/test_os_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | from unittest.case import skip 3 | from pykindler.utils import os_utils 4 | 5 | 6 | class TestOsUtils(TestCase): 7 | def test_get_downloads_folder_location(self): 8 | none_type = type(None) 9 | output = os_utils.get_downloads_folder_location() 10 | output_type = type(output) 11 | self.assertIn(output_type, [none_type, str]) 12 | if output_type is not none_type: 13 | self.assertIn("/", output, "Output is not a valid unix path!") 14 | 15 | @skip("Pointless: Too many mocks required to test this fn") 16 | def test_make_required_inodes(self): 17 | self.assertEqual(True, True) 18 | 19 | def test_name_required_inodes(self): 20 | from pykindler.constants import file_appended_hash 21 | 22 | arg_dict = { 23 | "some_folder_name": ( 24 | f"some_folder_name/not_books_{file_appended_hash}.txt", 25 | f"some_folder_name/Processed_Books_{file_appended_hash}", 26 | f"some_folder_name/Converted_Books_{file_appended_hash}", 27 | ), 28 | "some_other_folder_name": ( 29 | f"some_other_folder_name/not_books_{file_appended_hash}.txt", 30 | f"some_other_folder_name/Processed_Books_{file_appended_hash}", 31 | f"some_other_folder_name/Converted_Books_{file_appended_hash}", 32 | ), 33 | } 34 | 35 | for arg_folder in arg_dict: 36 | expected_output = arg_dict[arg_folder] 37 | output = os_utils.name_required_inodes(arg_folder) 38 | self.assertTupleEqual(output, expected_output) 39 | 40 | @skip("Pointless: Too many mocks required to test this fn") 41 | def test_convert_file_to_lists(self): 42 | self.assertEqual(True, True) 43 | # arg_dict = {"filename": True, "someotherfilename": True} 44 | # for arg_filename in arg_dict: 45 | # open_name = f"{arg_filename}.open" 46 | # with mock.patch(open_name, create=True) as mock_open: 47 | # mock_open.return_value = mock.MagicMock(spec=file) 48 | -------------------------------------------------------------------------------- /pykindler/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VivekBits2210/pykindler/44ad38b36f1785b31757c459dcc5b649eedd8112/pykindler/utils/__init__.py -------------------------------------------------------------------------------- /pykindler/utils/bash_utils.py: -------------------------------------------------------------------------------- 1 | from os import path, listdir 2 | from .os_utils import get_downloads_folder_location 3 | 4 | 5 | def construct_parser(): 6 | from argparse import ArgumentParser 7 | 8 | parser = ArgumentParser() 9 | return parser 10 | 11 | 12 | def load_arguments(parser, argument_dict): 13 | for argument, info in argument_dict.items(): 14 | help = info[0] 15 | action = info[1] if len(info) > 1 else None 16 | if action is None: 17 | parser.add_argument("--" + argument, help=help) 18 | else: 19 | parser.add_argument("--" + argument, help=help, action=action) 20 | return parser 21 | 22 | 23 | def get_commandline_args(args): 24 | from ..constants import argument_dict 25 | 26 | parser = construct_parser() 27 | parser = load_arguments(parser, argument_dict) 28 | args = parser.parse_args(args) 29 | return args 30 | 31 | 32 | def check_commandline_args(args): 33 | from ..constants import valid_extensions_for_conversion 34 | import re 35 | 36 | is_dir = path.isdir(args.folder) if args.folder is not None else True 37 | is_kindle = ( 38 | False 39 | if args.kindle is not None and not args.kindle.endswith("@kindle.com") 40 | else True 41 | ) 42 | is_file = path.isfile(args.file) if args.file is not None else True 43 | is_extension = ( 44 | False 45 | if args.ext is not None and args.ext not in valid_extensions_for_conversion 46 | else True 47 | ) 48 | 49 | if not is_dir: 50 | return f"Error: {args.folder} is not an existing directory!" 51 | if not is_kindle: 52 | return f"Error: {args.kindle} is not a valid Kindle e-mail address!" 53 | if not is_file: 54 | return f"Error: {args.file} does not exist or is not a valid file!" 55 | if not is_extension: 56 | return f"Error: {args.ext} extension not present in {valid_extensions_for_conversion}" 57 | 58 | return None 59 | 60 | 61 | def process_commandline_args(args): 62 | if args.file is not None: 63 | file_list = [path.basename(args.file)] 64 | download_dir = path.split(args.file)[0] 65 | return file_list, download_dir 66 | if args.folder is not None: 67 | file_list = listdir(args.folder) 68 | return file_list, args.folder 69 | else: 70 | download_dir = get_downloads_folder_location() 71 | if download_dir is None: 72 | return None, None 73 | file_list = listdir(download_dir) 74 | return file_list, download_dir 75 | -------------------------------------------------------------------------------- /pykindler/utils/convert_utils.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | 4 | def trigger_conversion(absolute_file_path, processed_dir, convert_dir, extension): 5 | from subprocess import check_output 6 | from os import rename 7 | 8 | filename = path.basename(absolute_file_path) 9 | print(f"Beginning conversion: {filename}") 10 | converted_file_name = filename[: filename.rfind(".")] + "." + extension 11 | converted_file_path = path.join(convert_dir, converted_file_name) 12 | check_output(["ebook-convert", absolute_file_path, converted_file_path]) 13 | rename(absolute_file_path, path.join(processed_dir, filename)) 14 | print(f"Completed Conversion: {converted_file_name}") 15 | return converted_file_path 16 | -------------------------------------------------------------------------------- /pykindler/utils/cron_utils.py: -------------------------------------------------------------------------------- 1 | # Clean out all previous pykinldler cron jobs? 2 | def remove_pykindler_cron_jobs(cron): 3 | for job in cron: 4 | if "pykindler-run" in str(job): 5 | cron.remove(job) 6 | print(f"Removing existing pykindler-run job: {str(job)}") 7 | cron.write() 8 | 9 | 10 | # Make new job 11 | def create_pykindler_cron_job(cron, args): 12 | 13 | command = "pykindler-run" 14 | command = command + f" --folder {args.folder}" if args.folder is not None else "" 15 | command = command + f" --email {args.email}" if args.email is not None else "" 16 | command = command + f" --ext {args.ext}" if args.ext is not None else "" 17 | command = command + f" --email {args.email}" if args.email is not None else "" 18 | command = command + f" --kindle {args.kindle}" if args.kindle is not None else "" 19 | command = command + f" --force" if args.force else "" 20 | print(f"Creating scheduled job {command}...") 21 | job = cron.new(command=command) 22 | job.hour.every(12) 23 | cron.write() 24 | 25 | 26 | # Create the job, plus housekeeping 27 | def setup_cron_job(args): 28 | from crontab import CronTab 29 | 30 | cron = CronTab(user=True) 31 | remove_pykindler_cron_jobs(cron) 32 | create_pykindler_cron_job(cron, args) 33 | print("Scheduled job created!") 34 | -------------------------------------------------------------------------------- /pykindler/utils/email_utils.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import smtplib 3 | 4 | 5 | class KindleEmailClient: 6 | def __init__(self, session, sender, receiver, abs_attachment_path=""): 7 | self.session = session 8 | self.sender = sender 9 | self.receiver = receiver 10 | self.abs_attachment_path = abs_attachment_path 11 | self.message = self.construct_empty_message() 12 | 13 | def send_email_with_book(self): 14 | self.attach_file_to_message() 15 | self.send_mail() 16 | 17 | def construct_empty_message(self): 18 | from email.mime.text import MIMEText 19 | from email.mime.multipart import MIMEMultipart 20 | 21 | message = MIMEMultipart() 22 | message["From"] = self.sender 23 | message["To"] = self.receiver 24 | message["Subject"] = "Book" 25 | message.attach(MIMEText("", "plain")) 26 | return message 27 | 28 | def attach_file_to_message(self): 29 | from email.mime.application import MIMEApplication 30 | 31 | filename = path.basename(self.abs_attachment_path) 32 | with open(self.abs_attachment_path, "rb") as file_reader: 33 | part = MIMEApplication(file_reader.read(), Name=filename) 34 | part["Content-Disposition"] = f'attachment; filename="{filename}"' 35 | self.message.attach(part) 36 | 37 | def send_mail(self): 38 | text = self.message.as_string() 39 | self.session.sendmail(self.sender, self.receiver, text) 40 | print("Mail Sent! \n") 41 | 42 | 43 | def send_a_bunch_of_files_to_kindle(session_object, file_list, from_file, to_file): 44 | emailed_book_list = [] 45 | for abs_file_path in file_list: 46 | print(f"Attempting to e-mail {path.basename(abs_file_path)}") 47 | if not is_file_attachable(abs_file_path): 48 | print(f"Attachment too big: Skipping {abs_file_path}..") 49 | continue 50 | 51 | KindleEmailClient( 52 | session_object.session, from_file, to_file, abs_file_path 53 | ).send_email_with_book() 54 | emailed_book_list.append(abs_file_path) 55 | 56 | session_object.session.quit() 57 | return emailed_book_list 58 | 59 | 60 | def is_file_attachable(abs_file_path): 61 | from ..constants import gmail_attachment_threshold_mb 62 | 63 | file_size_in_mb = path.getsize(abs_file_path) / 1e6 64 | return file_size_in_mb < (gmail_attachment_threshold_mb - 2) # 2 MB Buffer 65 | 66 | 67 | class GmailSession: 68 | def __init__(self, sender, askcred): 69 | from smtplib import SMTPAuthenticationError 70 | from keyring import delete_password 71 | from keyring.errors import PasswordDeleteError 72 | 73 | self.sender = sender 74 | self.session = smtplib.SMTP("smtp.gmail.com", 587) 75 | self.session.starttls() 76 | 77 | try: 78 | passwd = self.get_password(askcred) 79 | self.session.login(self.sender, self.get_password()) 80 | except SMTPAuthenticationError: 81 | try: 82 | print("Invalid credentials, deleting from keyring....") 83 | delete_password("system", self.sender) 84 | except PasswordDeleteError: 85 | print("ERROR: Keyring corrupted!") 86 | raise OSError 87 | 88 | def get_password(self, askcred): 89 | import keyring 90 | from getpass import getpass 91 | 92 | password = keyring.get_password("system", self.sender) 93 | if password is None or askcred is True: 94 | password = getpass(prompt=f"Enter e-mail password for {self.sender}: ") 95 | keyring.set_password("system", self.sender, password) 96 | return password 97 | -------------------------------------------------------------------------------- /pykindler/utils/nlp_utils.py: -------------------------------------------------------------------------------- 1 | import enchant 2 | 3 | # Check if word is english 4 | def is_word_english(word): 5 | return enchant.Dict("en_US").check(word) 6 | 7 | 8 | # Check if token is helpful to find metadatas 9 | def is_token_good(token): 10 | from ..constants import bad_tokens 11 | 12 | if len(token) <= 3 and not is_word_english(token): 13 | return False 14 | if len(token) <= 1: 15 | return False 16 | if token in bad_tokens: 17 | return False 18 | return True 19 | 20 | 21 | # Clean file names to help find metadata better 22 | def clean_file_name(filename): 23 | import re 24 | 25 | extension = filename[filename.rfind(".") + 1 :] 26 | clean_name = filename[: filename.rfind(".")] 27 | clean_name = re.sub(r"[^A-Za-z\' ]+", " ", clean_name) 28 | clean_name = re.sub(r" +", " ", clean_name) 29 | clean_name = clean_name.strip().lower() 30 | clean_name = " ".join([word for word in clean_name.split() if is_token_good(word)]) 31 | return clean_name, extension 32 | -------------------------------------------------------------------------------- /pykindler/utils/os_utils.py: -------------------------------------------------------------------------------- 1 | # Finds your downloads location 2 | from os import makedirs, path 3 | from ..constants import file_appended_hash 4 | 5 | 6 | def get_downloads_folder_location(): 7 | try: # GTK2 8 | import glib 9 | 10 | downloads_dir = glib.get_user_special_dir(glib.USER_DIRECTORY_DOWNLOAD) 11 | except (ModuleNotFoundError, AttributeError) as e: # GTK3 12 | try: 13 | from pgi.repository import GLib 14 | 15 | downloads_dir = GLib.get_user_special_dir( 16 | GLib.UserDirectory.DIRECTORY_DOWNLOAD 17 | ) 18 | except (ModuleNotFoundError, AttributeError) as e: # GTK3 19 | downloads_dir = None 20 | return downloads_dir 21 | 22 | 23 | def make_required_inodes(dir_list, file_list): 24 | for directory in dir_list: 25 | if not path.exists(directory): 26 | makedirs(directory) 27 | for file in file_list: 28 | if not path.exists(file): 29 | open(file, "a").close() 30 | 31 | 32 | def name_required_inodes(folder): 33 | not_books_file = path.join(folder, f"not_books_{file_appended_hash}.txt") 34 | processed_dir = path.join(folder, f"Processed_Books_{file_appended_hash}") 35 | convert_dir = path.join(folder, f"Converted_Books_{file_appended_hash}") 36 | return not_books_file, processed_dir, convert_dir 37 | 38 | 39 | def convert_file_to_list(file): 40 | try: 41 | line_list = open(file, "r").read().splitlines() 42 | except FileNotFoundError: 43 | line_list = [] 44 | return line_list 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black==21.6b0 2 | cffi==1.14.5 3 | click==8.0.1 4 | cryptography==3.4.7 5 | importlib-metadata==4.6.1 6 | jeepney==0.6.0 7 | keyring==23.0.1 8 | mypy-extensions==0.4.3 9 | pathspec==0.8.1 10 | pgi==0.0.11.2 11 | pycparser==2.20 12 | pyenchant==3.2.1 13 | python-crontab==2.5.1 14 | python-dateutil==2.8.1 15 | regex==2021.7.1 16 | SecretStorage==3.3.1 17 | six==1.16.0 18 | toml==0.10.2 19 | zipp==3.5.0 20 | m2r 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vivek Nayak" 2 | 3 | import setuptools 4 | from os import path 5 | from pykindler import cli 6 | 7 | try: 8 | # Used to convert md to rst for pypi, otherwise not needed 9 | import m2r 10 | except ImportError: 11 | m2r = None 12 | 13 | thelibFolder = path.dirname(path.realpath(__file__)) 14 | requirementPath = thelibFolder + "/requirements.txt" 15 | install_requires = [] 16 | if path.isfile(requirementPath): 17 | with open(requirementPath) as f: 18 | install_requires = f.read().splitlines() 19 | 20 | description = "Schedules conversion of e-books to mobi, mails it to your kindle" 21 | if m2r is None: 22 | long_description = description 23 | else: 24 | # convert markdown to rst 25 | long_description = m2r.convert(open("README.md").read()) 26 | 27 | setuptools.setup( 28 | name="pykindler", 29 | install_requires=install_requires, 30 | version="0.3.2", 31 | description=description, 32 | long_description=long_description, 33 | license="MIT", 34 | author="Vivek Nayak", 35 | author_email="viveknayak2210@gmail.com", 36 | url="https://github.com/VivekBits2210/pykindler", 37 | entry_points={"console_scripts": ["pykindler-run=pykindler.cli:client"]}, 38 | packages=setuptools.find_packages(), 39 | classifiers=[ 40 | "Development Status :: 4 - Beta", 41 | "Programming Language :: Python :: 3.6", 42 | "Programming Language :: Python :: 3.7", 43 | "Programming Language :: Python :: 3.8", 44 | "Programming Language :: Python :: 3.9", 45 | "Intended Audience :: End Users/Desktop", 46 | "Environment :: Console", 47 | "Natural Language :: English", 48 | "Operating System :: POSIX :: Linux", 49 | "License :: OSI Approved :: MIT License", 50 | "Topic :: Utilities", 51 | ], 52 | ) 53 | --------------------------------------------------------------------------------