├── tests
├── __init__.py
├── data
│ ├── version-5
│ │ ├── 07a07495-09b1-47f9-bb88-370aadc4395b.pagedata
│ │ ├── Sample Pens.pdf
│ │ ├── Sample Pens Generated.pdf
│ │ ├── 07a07495-09b1-47f9-bb88-370aadc4395b
│ │ │ ├── 5048d361-272a-4ca4-9e9b-d6f635d17650-metadata.json
│ │ │ ├── 56f7d738-8728-46f2-a799-036cd40d2c19-metadata.json
│ │ │ ├── 5048d361-272a-4ca4-9e9b-d6f635d17650.rm
│ │ │ ├── 56f7d738-8728-46f2-a799-036cd40d2c19.rm
│ │ │ ├── 7fb6da1a-2826-4ff2-93eb-0e38a76f91bb.rm
│ │ │ ├── b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3.rm
│ │ │ ├── 7fb6da1a-2826-4ff2-93eb-0e38a76f91bb-metadata.json
│ │ │ └── b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3-metadata.json
│ │ ├── 07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails
│ │ │ ├── 5048d361-272a-4ca4-9e9b-d6f635d17650.jpg
│ │ │ ├── 56f7d738-8728-46f2-a799-036cd40d2c19.jpg
│ │ │ ├── 7fb6da1a-2826-4ff2-93eb-0e38a76f91bb.jpg
│ │ │ └── b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3.jpg
│ │ ├── 07a07495-09b1-47f9-bb88-370aadc4395b.metadata
│ │ └── 07a07495-09b1-47f9-bb88-370aadc4395b.content
│ └── templates
│ │ ├── Blank.svg
│ │ └── Isometric.svg
└── test_convert_rm.py
├── .flake8
├── requirements.txt
├── remarkable_cli
├── __main__.py
├── pens
│ ├── __init__.py
│ └── pen.py
├── __init__.py
├── convert_rm.py
└── client.py
├── Makefile
├── .vscode
├── launch.json
└── settings.json
├── setup.py
├── README.md
├── .gitignore
└── LICENSE
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | extend-ignore = E203, W503
4 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.pagedata:
--------------------------------------------------------------------------------
1 | P Dots S
2 | Isometric
3 | Blank
4 | Blank
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | black==20.8b1
2 | flake8==3.8.4
3 | paramiko==2.7.2
4 | reportlab==3.5.63
5 | requests==2.25.1
6 | svglib==1.0.1
7 |
--------------------------------------------------------------------------------
/tests/data/version-5/Sample Pens.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/Sample Pens.pdf
--------------------------------------------------------------------------------
/tests/data/version-5/Sample Pens Generated.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/Sample Pens Generated.pdf
--------------------------------------------------------------------------------
/remarkable_cli/__main__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | from . import main
5 |
6 | if __name__ == "__main__":
7 | main()
8 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/5048d361-272a-4ca4-9e9b-d6f635d17650-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "layers": [
3 | {
4 | "name": "Layer 1"
5 | }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/56f7d738-8728-46f2-a799-036cd40d2c19-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "layers": [
3 | {
4 | "name": "Layer 1"
5 | }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | build:
2 | python3 setup.py sdist bdist_wheel
3 |
4 | clean:
5 | rm -rf build dist remarkable_cli.egg-info
6 |
7 | upload:
8 | twine upload dist/*
9 |
10 | test:
11 | python3 -m unittest discover
12 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/5048d361-272a-4ca4-9e9b-d6f635d17650.rm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/5048d361-272a-4ca4-9e9b-d6f635d17650.rm
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/56f7d738-8728-46f2-a799-036cd40d2c19.rm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/56f7d738-8728-46f2-a799-036cd40d2c19.rm
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/7fb6da1a-2826-4ff2-93eb-0e38a76f91bb.rm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/7fb6da1a-2826-4ff2-93eb-0e38a76f91bb.rm
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3.rm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3.rm
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/5048d361-272a-4ca4-9e9b-d6f635d17650.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/5048d361-272a-4ca4-9e9b-d6f635d17650.jpg
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/56f7d738-8728-46f2-a799-036cd40d2c19.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/56f7d738-8728-46f2-a799-036cd40d2c19.jpg
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/7fb6da1a-2826-4ff2-93eb-0e38a76f91bb.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/7fb6da1a-2826-4ff2-93eb-0e38a76f91bb.jpg
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awwong1/remarkable-cli/HEAD/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.thumbnails/b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3.jpg
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/7fb6da1a-2826-4ff2-93eb-0e38a76f91bb-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "layers": [
3 | {
4 | "name": "Layer 2"
5 | },
6 | {
7 | "name": "Layer 1"
8 | }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b/b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "layers": [
3 | {
4 | "name": "Layer 1"
5 | },
6 | {
7 | "name": "Layer 2"
8 | },
9 | {
10 | "name": "Layer 3"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.metadata:
--------------------------------------------------------------------------------
1 | {
2 | "deleted": false,
3 | "lastModified": "1615242059597",
4 | "lastOpenedPage": 0,
5 | "metadatamodified": false,
6 | "modified": false,
7 | "parent": "",
8 | "pinned": false,
9 | "synced": true,
10 | "type": "DocumentType",
11 | "version": 20,
12 | "visibleName": "Sample Pens"
13 | }
14 |
--------------------------------------------------------------------------------
/tests/data/templates/Blank.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Debug Unit Test",
9 | "type": "python",
10 | "request": "test",
11 | "justMyCode": false,
12 | },
13 | ]
14 | }
--------------------------------------------------------------------------------
/remarkable_cli/pens/__init__.py:
--------------------------------------------------------------------------------
1 | from .pen import (
2 | Ballpoint,
3 | Brush,
4 | Calligraphy,
5 | EraseArea,
6 | Eraser,
7 | Fineliner,
8 | Highlighter,
9 | Marker,
10 | MechanicalPencil,
11 | Pen,
12 | Pencil,
13 | )
14 |
15 | __all__ = [
16 | "Ballpoint",
17 | "Brush",
18 | "Calligraphy",
19 | "EraseArea",
20 | "Eraser",
21 | "Fineliner",
22 | "Highlighter",
23 | "Marker",
24 | "MechanicalPencil",
25 | "Pen",
26 | "Pencil",
27 | ]
28 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.linting.pylintEnabled": false,
3 | "python.linting.flake8Enabled": true,
4 | "python.linting.enabled": true,
5 | "python.formatting.provider": "black",
6 | "cSpell.words": [
7 | "xochitl"
8 | ],
9 | "python.testing.unittestArgs": [
10 | "-v",
11 | "-s",
12 | "./tests",
13 | "-p",
14 | "test_*.py"
15 | ],
16 | "python.testing.pytestEnabled": false,
17 | "python.testing.nosetestsEnabled": false,
18 | "python.testing.unittestEnabled": true
19 | }
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 | from remarkable_cli import __version__
3 |
4 |
5 | setup(
6 | name="remarkable-cli",
7 | version=__version__,
8 | author="Alexander Wong",
9 | author_email="alex@udia.ca",
10 | description="An unofficial CLI for interacting with the Remarkable tablet.",
11 | long_description=open("README.md", "r", encoding="utf-8").read(),
12 | long_description_content_type="text/markdown",
13 | url="https://github.com/awwong1/remarkable-cli",
14 | license="Apache-2.0",
15 | packages=find_packages(),
16 | classifiers=[
17 | "License :: OSI Approved :: Apache Software License",
18 | "Environment :: Console",
19 | "Natural Language :: English",
20 | "Operating System :: POSIX :: Linux",
21 | "Topic :: Utilities",
22 | "Programming Language :: Python :: 3",
23 | "Programming Language :: Python :: 3 :: Only",
24 | ],
25 | entry_points={"console_scripts": ["remarkable-cli=remarkable_cli:main"]},
26 | install_requires=["paramiko", "requests", "svglib", "reportlab"],
27 | )
28 |
--------------------------------------------------------------------------------
/tests/test_convert_rm.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import unittest
4 |
5 | from remarkable_cli.convert_rm import ConvertRM
6 |
7 | DIR_PATH = os.path.dirname(os.path.realpath(__file__))
8 |
9 |
10 | class TestConvertRM(unittest.TestCase):
11 | @classmethod
12 | def setUpClass(cls) -> None:
13 | logging.disable(logging.CRITICAL)
14 | return super().setUpClass()
15 |
16 | @classmethod
17 | def tearDownClass(cls) -> None:
18 | logging.disable(logging.NOTSET)
19 | return super().tearDownClass()
20 |
21 | def setUp(self):
22 | self.converter = ConvertRM(
23 | os.path.join(
24 | DIR_PATH, "data", "version-5", "07a07495-09b1-47f9-bb88-370aadc4395b"
25 | ),
26 | os.path.join(DIR_PATH, "data", "templates"),
27 | )
28 |
29 | def test_initialization(self):
30 | self.assertRaises(
31 | FileNotFoundError,
32 | ConvertRM,
33 | os.path.join(
34 | DIR_PATH, "data", "version-5", "00000000-0000-0000-0000-000000000000"
35 | ),
36 | os.path.join(DIR_PATH, "data", "templates"),
37 | )
38 | self.assertIsInstance(self.converter, ConvertRM)
39 |
40 | def test_convert_document(self):
41 | pdf_output_path = os.path.join(
42 | DIR_PATH, "data", "version-5", "Sample Pens Generated.pdf"
43 | )
44 | self.converter.convert_document(pdf_output_path)
45 | self.assertTrue(True)
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # remarkable-cli
2 |
3 | 
4 |
5 | An unofficial command line interface (CLI) for interacting with the Remarkable paper tablet written in pure Python.
6 |
7 | ## Features
8 |
9 | * pull raw reMarkable `xochitl` files directly to the local machine
10 | * convert raw `.rm` payloads into readable `.pdf`
11 | * pull reMarkable web-interface `pdf` documents directly to the local machine
12 |
13 | ### In the works
14 |
15 | * push/pull `pdf` files to reMarkable for annotation & reading
16 | * push/pull `epub` files to reMarkable for annotation & reading
17 | * live-share reMarkable screen to local machine
18 | * ... and more!
19 |
20 | ## Getting Started
21 |
22 | ```bash
23 | pip install remarkable-cli
24 |
25 | # Pull raw xochitl files and render them into readable pdf
26 | remarkable-cli -a pull
27 |
28 | # with DEBUG logging, clean the local backup directory before pulling all the raw xochitl files and rendering pdf
29 | remarkable-cli -vvvv -a clean-local -a pull
30 |
31 | # show the CLI usage/help
32 | remarkable-cli -h
33 | ```
34 |
35 | ## License
36 |
37 | [Apache-2.0](./LICENSE)
38 |
39 | ```text
40 | Copyright 2021, Alexander Wong
41 |
42 | Licensed under the Apache License, Version 2.0 (the "License");
43 | you may not use this file except in compliance with the License.
44 | You may obtain a copy of the License at
45 | http://www.apache.org/licenses/LICENSE-2.0
46 | Unless required by applicable law or agreed to in writing, software
47 | distributed under the License is distributed on an "AS IS" BASIS,
48 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
49 | See the License for the specific language governing permissions and
50 | limitations under the License.
51 | ```
52 |
--------------------------------------------------------------------------------
/tests/data/version-5/07a07495-09b1-47f9-bb88-370aadc4395b.content:
--------------------------------------------------------------------------------
1 | {
2 | "coverPageNumber": -1,
3 | "dummyDocument": false,
4 | "extraMetadata": {
5 | "LastBallpointColor": "Black",
6 | "LastBallpointSize": "2",
7 | "LastBallpointv2Color": "Black",
8 | "LastBallpointv2Size": "3",
9 | "LastCalligraphyColor": "White",
10 | "LastCalligraphySize": "2",
11 | "LastClearPageColor": "Black",
12 | "LastClearPageSize": "2",
13 | "LastEraseSectionColor": "Black",
14 | "LastEraseSectionSize": "2",
15 | "LastEraserColor": "Black",
16 | "LastEraserSize": "2",
17 | "LastEraserTool": "EraseSection",
18 | "LastFinelinerColor": "Black",
19 | "LastFinelinerSize": "2",
20 | "LastFinelinerv2Color": "White",
21 | "LastFinelinerv2Size": "2",
22 | "LastHighlighterColor": "Black",
23 | "LastHighlighterSize": "2",
24 | "LastHighlighterv2Color": "Black",
25 | "LastHighlighterv2Size": "2",
26 | "LastMarkerColor": "Black",
27 | "LastMarkerSize": "2",
28 | "LastMarkerv2Color": "White",
29 | "LastMarkerv2Size": "2",
30 | "LastPaintbrushColor": "Black",
31 | "LastPaintbrushSize": "2",
32 | "LastPaintbrushv2Color": "White",
33 | "LastPaintbrushv2Size": "2",
34 | "LastPen": "Ballpointv2",
35 | "LastPencilColor": "Black",
36 | "LastPencilSize": "2",
37 | "LastPencilv2Color": "Black",
38 | "LastPencilv2Size": "2",
39 | "LastReservedPenColor": "Black",
40 | "LastReservedPenSize": "2",
41 | "LastSelectionToolColor": "Black",
42 | "LastSelectionToolSize": "2",
43 | "LastSharpPencilColor": "Black",
44 | "LastSharpPencilSize": "2",
45 | "LastSharpPencilv2Color": "Black",
46 | "LastSharpPencilv2Size": "2",
47 | "LastSolidPenColor": "Black",
48 | "LastSolidPenSize": "2",
49 | "LastTool": "Ballpointv2",
50 | "LastUndefinedColor": "Black",
51 | "LastUndefinedSize": "1",
52 | "LastZoomToolColor": "Black",
53 | "LastZoomToolSize": "2"
54 | },
55 | "fileType": "notebook",
56 | "fontName": "",
57 | "lineHeight": -1,
58 | "margins": 100,
59 | "orientation": "portrait",
60 | "pageCount": 4,
61 | "pages": [
62 | "7fb6da1a-2826-4ff2-93eb-0e38a76f91bb",
63 | "56f7d738-8728-46f2-a799-036cd40d2c19",
64 | "b32ce4c2-df8a-44c7-b46a-294ffa8cb3f3",
65 | "5048d361-272a-4ca4-9e9b-d6f635d17650"
66 | ],
67 | "textAlignment": "left",
68 | "textScale": 1,
69 | "transform": {
70 | "m11": 1,
71 | "m12": 0,
72 | "m13": 0,
73 | "m21": 0,
74 | "m22": 1,
75 | "m23": 0,
76 | "m31": 0,
77 | "m32": 0,
78 | "m33": 1
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/remarkable_cli/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
3 | from os import path
4 | from .client import Client
5 |
6 | name = "remarkable-cli"
7 | __version__ = "0.3.2"
8 | __all__ = ["main"]
9 |
10 |
11 | def main():
12 | parser = ArgumentParser(
13 | "remarkable-cli",
14 | description="A CLI for interacting with the Remarkable paper tablet.",
15 | formatter_class=ArgumentDefaultsHelpFormatter,
16 | )
17 |
18 | parser.add_argument(
19 | "--version", action="version", version="%(prog)s " + __version__
20 | )
21 | parser.add_argument(
22 | "-v",
23 | "--verbose",
24 | dest="log_level",
25 | action="count",
26 | help="logging verbosity level",
27 | default=None,
28 | )
29 | parser.add_argument(
30 | "-a",
31 | "--action",
32 | help="backup actions to perform on reMarkable tablet",
33 | action="append",
34 | type=str,
35 | choices=["push", "pull", "pull-raw", "pull-web", "convert-raw", "clean-local"],
36 | )
37 |
38 | device_group = parser.add_argument_group("reMarkable device")
39 | device_group.add_argument(
40 | "-d",
41 | "--destination",
42 | help="reMarkable tablet network destination hostname",
43 | type=str,
44 | default="10.11.99.1",
45 | )
46 | device_group.add_argument(
47 | "-p",
48 | "--port",
49 | help="reMarkable tablet network destination port",
50 | type=int,
51 | default=22
52 | )
53 | device_group.add_argument(
54 | "-u",
55 | "--username",
56 | help="reMarkable tablet ssh user",
57 | type=str,
58 | default="root",
59 | )
60 | device_group.add_argument(
61 | "--password",
62 | help="reMarkable ssh connection password",
63 | type=str,
64 | default=None
65 | )
66 | device_group.add_argument(
67 | "-f",
68 | "--file-path",
69 | type=str,
70 | help="reMarkable directory containing xochitl files",
71 | default="/home/root/.local/share/remarkable/xochitl/"
72 | )
73 | device_group.add_argument(
74 | "-t",
75 | "--templates-path",
76 | type=str,
77 | help="reMarkable directory containing templates",
78 | default="/usr/share/remarkable/templates/"
79 | )
80 |
81 | local_group = parser.add_argument_group("local")
82 | local_group.add_argument(
83 | "-b",
84 | "--backup-dir",
85 | help="local machine backup directory",
86 | type=str,
87 | default=path.join(path.expanduser("~"), "reMarkable"),
88 | )
89 |
90 | args = parser.parse_args()
91 |
92 | if not args.action:
93 | # no action specified, display the help message
94 | parser.print_help()
95 | return
96 |
97 | c = Client(args)
98 | c.run_actions()
99 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # ---> Python
2 | # Byte-compiled / optimized / DLL files
3 | __pycache__/
4 | *.py[cod]
5 | *$py.class
6 |
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | pip-wheel-metadata/
25 | share/python-wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 | MANIFEST
30 |
31 | # PyInstaller
32 | # Usually these files are written by a python script from a template
33 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
34 | *.manifest
35 | *.spec
36 |
37 | # Installer logs
38 | pip-log.txt
39 | pip-delete-this-directory.txt
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .nox/
45 | .coverage
46 | .coverage.*
47 | .cache
48 | nosetests.xml
49 | coverage.xml
50 | *.cover
51 | *.py,cover
52 | .hypothesis/
53 | .pytest_cache/
54 |
55 | # Translations
56 | *.mo
57 | *.pot
58 |
59 | # Django stuff:
60 | *.log
61 | local_settings.py
62 | db.sqlite3
63 | db.sqlite3-journal
64 |
65 | # Flask stuff:
66 | instance/
67 | .webassets-cache
68 |
69 | # Scrapy stuff:
70 | .scrapy
71 |
72 | # Sphinx documentation
73 | docs/_build/
74 |
75 | # PyBuilder
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | .python-version
87 |
88 | # pipenv
89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
92 | # install all needed dependencies.
93 | #Pipfile.lock
94 |
95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96 | __pypackages__/
97 |
98 | # Celery stuff
99 | celerybeat-schedule
100 | celerybeat.pid
101 |
102 | # SageMath parsed files
103 | *.sage.py
104 |
105 | # Environments
106 | .env
107 | .venv
108 | env/
109 | venv/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
132 | # ---> VisualStudioCode
133 | .vscode/*
134 | !.vscode/settings.json
135 | !.vscode/tasks.json
136 | !.vscode/launch.json
137 | !.vscode/extensions.json
138 | *.code-workspace
139 |
140 | # ---> Linux
141 | *~
142 |
143 | # temporary files which can be created if a process still has a handle open of a deleted file
144 | .fuse_hidden*
145 |
146 | # KDE directory preferences
147 | .directory
148 |
149 | # Linux trash folder which might appear on any partition or disk
150 | .Trash-*
151 |
152 | # .nfs files are created when an open file is removed but is still being accessed
153 | .nfs*
154 |
155 | # ---> Windows
156 | # Windows thumbnail cache files
157 | Thumbs.db
158 | Thumbs.db:encryptable
159 | ehthumbs.db
160 | ehthumbs_vista.db
161 |
162 | # Dump file
163 | *.stackdump
164 |
165 | # Folder config file
166 | [Dd]esktop.ini
167 |
168 | # Recycle Bin used on file shares
169 | $RECYCLE.BIN/
170 |
171 | # Windows Installer files
172 | *.cab
173 | *.msi
174 | *.msix
175 | *.msm
176 | *.msp
177 |
178 | # Windows shortcuts
179 | *.lnk
180 |
181 | # ---> macOS
182 | # General
183 | .DS_Store
184 | .AppleDouble
185 | .LSOverride
186 |
187 | # Icon must end with two \r
188 | Icon
189 |
190 | # Thumbnails
191 | ._*
192 |
193 | # Files that might appear in the root of a volume
194 | .DocumentRevisions-V100
195 | .fseventsd
196 | .Spotlight-V100
197 | .TemporaryItems
198 | .Trashes
199 | .VolumeIcon.icns
200 | .com.apple.timemachine.donotpresent
201 |
202 | # Directories potentially created on remote AFP share
203 | .AppleDB
204 | .AppleDesktop
205 | Network Trash Folder
206 | Temporary Items
207 | .apdisk
208 |
209 |
--------------------------------------------------------------------------------
/remarkable_cli/pens/pen.py:
--------------------------------------------------------------------------------
1 | class Pen:
2 | def __init__(
3 | self,
4 | name="Basic Pen",
5 | base_width=2.0,
6 | stroke_color="black",
7 | segment_length=-1,
8 | opacity=1.0,
9 | stroke_cap="round",
10 | stroke_join="round",
11 | ):
12 | self.name = name
13 | self.color = stroke_color
14 | self.base_width = base_width # Small: 1.875; Medium 2.0; Large 2.125
15 | self.opacity = opacity
16 | self.segment_length = segment_length
17 | self.stroke_cap = stroke_cap
18 | self.stroke_join = stroke_join
19 |
20 | def get_polyline_attributes(self, _speed, _tilt, width, _pressure):
21 | segment_width = (self.base_width * width) / 2.0
22 | return {
23 | "fill": "none",
24 | "stroke-width": f"{segment_width:.3f}",
25 | "stroke": self.color,
26 | "stroke-opacity": f"{self.opacity:.3f}",
27 | "stroke-linecap": self.stroke_cap,
28 | "stroke-linejoin": self.stroke_join,
29 | }
30 |
31 |
32 | class Ballpoint(Pen):
33 | def __init__(self, base_width, stroke_color):
34 | super().__init__(
35 | name="Ballpoint",
36 | base_width=base_width,
37 | stroke_color=stroke_color,
38 | segment_length=5,
39 | )
40 |
41 | def get_polyline_attributes(self, speed, tilt, width, pressure):
42 | attrs = super().get_polyline_attributes(speed, tilt, width, pressure)
43 | segment_width = (0.5 + pressure) + (1 * width) - 0.5 * (speed / 50)
44 | attrs.update(
45 | {
46 | "stroke-width": f"{segment_width:.3f}",
47 | }
48 | )
49 | return attrs
50 |
51 |
52 | class Fineliner(Pen):
53 | def __init__(self, base_width, stroke_color):
54 | super().__init__(
55 | name="Fineliner",
56 | stroke_color=stroke_color,
57 | )
58 |
59 | def get_polyline_attributes(self, speed, tilt, width, pressure):
60 | attrs = super().get_polyline_attributes(speed, tilt, width, pressure)
61 | segment_width = width
62 | attrs.update(
63 | {
64 | "stroke-width": f"{segment_width:.3f}",
65 | }
66 | )
67 | return attrs
68 |
69 |
70 | class Marker(Pen):
71 | def __init__(self, base_width, stroke_color):
72 | super().__init__(
73 | name="Marker",
74 | base_width=base_width,
75 | segment_length=3,
76 | stroke_color=stroke_color,
77 | )
78 |
79 | def get_polyline_attributes(self, speed, tilt, width, pressure):
80 | attrs = super().get_polyline_attributes(speed, tilt, width, pressure)
81 | segment_width = (width * self.base_width) / 2.7
82 | attrs.update(
83 | {
84 | "stroke-width": f"{segment_width:.3f}",
85 | }
86 | )
87 | return attrs
88 |
89 |
90 | class Pencil(Pen):
91 | def __init__(self, stroke_width):
92 | super().__init__(
93 | name="Pencil",
94 | base_width=stroke_width,
95 | segment_length=2,
96 | # stroke_join="bevel",
97 | )
98 |
99 | def get_polyline_attributes(self, speed, tilt, width, pressure):
100 | attrs = super().get_polyline_attributes(speed, tilt, width, pressure)
101 | segment_width = (width * self.base_width) / 3.5
102 |
103 | segment_opacity = (0.1 * -(speed / 35)) + (1 * pressure)
104 | segment_opacity = min(max(0.0, segment_opacity), 1.0) - 0.1
105 |
106 | attrs.update(
107 | {
108 | "stroke-width": f"{segment_width:.3f}",
109 | "stroke-opacity": f"{segment_opacity:.3f}",
110 | }
111 | )
112 | return attrs
113 |
114 |
115 | class MechanicalPencil(Pen):
116 | def __init__(self, stroke_width):
117 | super().__init__(name="Mechanical Pencil", base_width=stroke_width)
118 |
119 | def get_polyline_attributes(self, speed, tilt, width, pressure):
120 | attrs = super().get_polyline_attributes(speed, tilt, width, pressure)
121 | segment_width = (width * self.base_width) / 3.5
122 |
123 | attrs.update(
124 | {
125 | "stroke-width": f"{segment_width:.3f}",
126 | }
127 | )
128 | return attrs
129 |
130 |
131 | class Brush(Pen):
132 | def __init__(self, base_width, stroke_color):
133 | super().__init__(
134 | name="Brush",
135 | base_width=base_width,
136 | stroke_color=stroke_color,
137 | segment_length=4,
138 | )
139 |
140 | def get_polyline_attributes(self, speed, tilt, width, pressure):
141 | attrs = super().get_polyline_attributes(speed, tilt, width, pressure)
142 | segment_width = (width * self.base_width) / 2.7
143 |
144 | intensity = (pressure ** 1.5 - 0.2 * (speed / 50)) * 1.5
145 | intensity = min(max(0.0, intensity), 1.0)
146 | attrs.update(
147 | {
148 | "stroke-width": f"{segment_width:.3f}",
149 | "stroke-opacity": f"{intensity:.3f}",
150 | }
151 | )
152 | return attrs
153 |
154 |
155 | class Highlighter(Pen):
156 | def __init__(self):
157 | super().__init__(
158 | name="Highlighter", opacity=0.1, stroke_cap="square", stroke_color="yellow"
159 | )
160 |
161 |
162 | class Eraser(Pen):
163 | def __init__(self, base_width):
164 | super().__init__(
165 | name="Eraser",
166 | base_width=base_width,
167 | stroke_color="white",
168 | opacity=0.0,
169 | )
170 |
171 |
172 | class EraseArea(Pen):
173 | def __init__(self):
174 | super().__init__(
175 | name="Erase Area",
176 | opacity=0.0,
177 | stroke_color="white",
178 | )
179 |
180 |
181 | class Calligraphy(Pen):
182 | def __init__(self, base_width, stroke_color):
183 | super().__init__(
184 | name="Calligraphy",
185 | base_width=base_width,
186 | stroke_color=stroke_color,
187 | segment_length=2,
188 | )
189 |
--------------------------------------------------------------------------------
/remarkable_cli/convert_rm.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # inspired by the original https://github.com/reHackable/maxio utility
3 | # https://github.com/reHackable/maxio/blob/a0a9d8291bd034a0114919bbf334973bbdd6a218/tools/rM2svg#L1
4 | import json
5 | import logging
6 | import os
7 | import re
8 | import xml.etree.ElementTree as ET
9 | from io import BufferedReader
10 | from struct import calcsize, unpack_from
11 | from tempfile import TemporaryFile
12 |
13 | from reportlab.graphics import renderPDF
14 | from reportlab.pdfgen.canvas import Canvas
15 | from svglib.svglib import svg2rlg
16 |
17 | from .pens import (
18 | Ballpoint,
19 | Brush,
20 | Calligraphy,
21 | EraseArea,
22 | Eraser,
23 | Fineliner,
24 | Highlighter,
25 | Marker,
26 | MechanicalPencil,
27 | Pen,
28 | Pencil,
29 | )
30 |
31 |
32 | class ConvertRM:
33 | """Partial support for version 2.5.0.27 generated lines files."""
34 |
35 | X_SIZE = 1404
36 | Y_SIZE = 1872
37 |
38 | STROKE_COLOUR = {
39 | 0: "#000000",
40 | 1: "#c7c7c7",
41 | 2: "#ffffff",
42 | }
43 |
44 | @staticmethod
45 | def _blank_template():
46 | svg_root = ET.Element(
47 | "svg",
48 | {
49 | "xmlns": "http://www.w3.org/2000/svg",
50 | "xmlns:xlink": "http://www.w3.org/1999/xlink",
51 | "version": "1.1",
52 | "x": "0px",
53 | "y": "0px",
54 | "viewBox": f"0 0 {ConvertRM.X_SIZE} {ConvertRM.Y_SIZE}",
55 | },
56 | )
57 | blank_layer = ET.Element("g", {"id": "Background"})
58 | blank_layer.append(
59 | ET.Element(
60 | "rect",
61 | {
62 | "x": "0",
63 | "y": "0",
64 | "width": f"{ConvertRM.X_SIZE}",
65 | "height": f"{ConvertRM.Y_SIZE}",
66 | "fill": "#FFFFFF",
67 | },
68 | )
69 | )
70 | svg_root.append(blank_layer)
71 | return ET.ElementTree(svg_root)
72 |
73 | def __init__(
74 | self,
75 | entity_path: os.PathLike,
76 | local_templates_path: str,
77 | logger: logging.Logger = None,
78 | ):
79 | """
80 | entity_path should be:
81 | - path to {uuid}.(content|metadata), without extension.
82 | - path to directory containing pages (.rm) files, without trailing slash
83 | """
84 | self._log = logger
85 | if logger is None:
86 | log_format = "%(asctime)s [%(levelname)s]: %(message)s"
87 | logging.basicConfig(format=log_format, level=logging.INFO)
88 | self._log = logging.getLogger(__name__)
89 |
90 | if not os.path.isdir(entity_path):
91 | self._log.error("not found: %s", entity_path)
92 | raise FileNotFoundError(entity_path)
93 | self.pages_fp = entity_path
94 | self.templates_fp = local_templates_path
95 |
96 | # Document info
97 | self.content_fp = f"{entity_path}{os.extsep}content"
98 | self.metadata_fp = f"{entity_path}{os.extsep}metadata"
99 | self.pagedata_fp = f"{entity_path}{os.extsep}pagedata"
100 | with open(self.content_fp, "r") as fh:
101 | self.content = json.load(fh)
102 | with open(self.metadata_fp, "r") as fh:
103 | self.metadata = json.load(fh)
104 | with open(self.pagedata_fp, "r") as fh:
105 | self.pagedata = [pg_dat.rstrip() for pg_dat in fh.readlines()]
106 |
107 | # Page info
108 | self.page_ids = self.content.get("pages", [])
109 | self.pages_metadata = {}
110 | for page_id in self.page_ids:
111 | pg_meta_fp = os.path.join(entity_path, f"{page_id}-metadata{os.extsep}json")
112 | if os.path.isfile(pg_meta_fp):
113 | with open(pg_meta_fp, "r") as fh:
114 | self.pages_metadata[page_id] = json.load(fh)
115 |
116 | def _convert_rm_to_svg(self, fh: BufferedReader, template_tree: ET.ElementTree):
117 | raw_header_template = "reMarkable .lines file, version=# "
118 | fmt = f"<{len(raw_header_template)}sI"
119 | header, num_layers = unpack_from(fmt, fh.read(calcsize(fmt)))
120 |
121 | # Verify header with byte regular expression
122 | header_regex = rb"^reMarkable .lines file, version=(?P\d) $"
123 | obtained_header = re.search(header_regex, header)
124 | version = obtained_header.groupdict().get("version")
125 | if not version:
126 | raise RuntimeError("invalid lines header provided")
127 |
128 | # determine stroke format using version number
129 | stroke_fmt = "
2 |
3 |
155 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 |
3 | Version 2.0, January 2004
4 |
5 | http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION,
6 | AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 |
11 |
12 | "License" shall mean the terms and conditions for use, reproduction, and distribution
13 | as defined by Sections 1 through 9 of this document.
14 |
15 |
16 |
17 | "Licensor" shall mean the copyright owner or entity authorized by the copyright
18 | owner that is granting the License.
19 |
20 |
21 |
22 | "Legal Entity" shall mean the union of the acting entity and all other entities
23 | that control, are controlled by, or are under common control with that entity.
24 | For the purposes of this definition, "control" means (i) the power, direct
25 | or indirect, to cause the direction or management of such entity, whether
26 | by contract or otherwise, or (ii) ownership of fifty percent (50%) or more
27 | of the outstanding shares, or (iii) beneficial ownership of such entity.
28 |
29 |
30 |
31 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions
32 | granted by this License.
33 |
34 |
35 |
36 | "Source" form shall mean the preferred form for making modifications, including
37 | but not limited to software source code, documentation source, and configuration
38 | files.
39 |
40 |
41 |
42 | "Object" form shall mean any form resulting from mechanical transformation
43 | or translation of a Source form, including but not limited to compiled object
44 | code, generated documentation, and conversions to other media types.
45 |
46 |
47 |
48 | "Work" shall mean the work of authorship, whether in Source or Object form,
49 | made available under the License, as indicated by a copyright notice that
50 | is included in or attached to the work (an example is provided in the Appendix
51 | below).
52 |
53 |
54 |
55 | "Derivative Works" shall mean any work, whether in Source or Object form,
56 | that is based on (or derived from) the Work and for which the editorial revisions,
57 | annotations, elaborations, or other modifications represent, as a whole, an
58 | original work of authorship. For the purposes of this License, Derivative
59 | Works shall not include works that remain separable from, or merely link (or
60 | bind by name) to the interfaces of, the Work and Derivative Works thereof.
61 |
62 |
63 |
64 | "Contribution" shall mean any work of authorship, including the original version
65 | of the Work and any modifications or additions to that Work or Derivative
66 | Works thereof, that is intentionally submitted to Licensor for inclusion in
67 | the Work by the copyright owner or by an individual or Legal Entity authorized
68 | to submit on behalf of the copyright owner. For the purposes of this definition,
69 | "submitted" means any form of electronic, verbal, or written communication
70 | sent to the Licensor or its representatives, including but not limited to
71 | communication on electronic mailing lists, source code control systems, and
72 | issue tracking systems that are managed by, or on behalf of, the Licensor
73 | for the purpose of discussing and improving the Work, but excluding communication
74 | that is conspicuously marked or otherwise designated in writing by the copyright
75 | owner as "Not a Contribution."
76 |
77 |
78 |
79 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf
80 | of whom a Contribution has been received by Licensor and subsequently incorporated
81 | within the Work.
82 |
83 | 2. Grant of Copyright License. Subject to the terms and conditions of this
84 | License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
85 | no-charge, royalty-free, irrevocable copyright license to reproduce, prepare
86 | Derivative Works of, publicly display, publicly perform, sublicense, and distribute
87 | the Work and such Derivative Works in Source or Object form.
88 |
89 | 3. Grant of Patent License. Subject to the terms and conditions of this License,
90 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
91 | no-charge, royalty-free, irrevocable (except as stated in this section) patent
92 | license to make, have made, use, offer to sell, sell, import, and otherwise
93 | transfer the Work, where such license applies only to those patent claims
94 | licensable by such Contributor that are necessarily infringed by their Contribution(s)
95 | alone or by combination of their Contribution(s) with the Work to which such
96 | Contribution(s) was submitted. If You institute patent litigation against
97 | any entity (including a cross-claim or counterclaim in a lawsuit) alleging
98 | that the Work or a Contribution incorporated within the Work constitutes direct
99 | or contributory patent infringement, then any patent licenses granted to You
100 | under this License for that Work shall terminate as of the date such litigation
101 | is filed.
102 |
103 | 4. Redistribution. You may reproduce and distribute copies of the Work or
104 | Derivative Works thereof in any medium, with or without modifications, and
105 | in Source or Object form, provided that You meet the following conditions:
106 |
107 | (a) You must give any other recipients of the Work or Derivative Works a copy
108 | of this License; and
109 |
110 | (b) You must cause any modified files to carry prominent notices stating that
111 | You changed the files; and
112 |
113 | (c) You must retain, in the Source form of any Derivative Works that You distribute,
114 | all copyright, patent, trademark, and attribution notices from the Source
115 | form of the Work, excluding those notices that do not pertain to any part
116 | of the Derivative Works; and
117 |
118 | (d) If the Work includes a "NOTICE" text file as part of its distribution,
119 | then any Derivative Works that You distribute must include a readable copy
120 | of the attribution notices contained within such NOTICE file, excluding those
121 | notices that do not pertain to any part of the Derivative Works, in at least
122 | one of the following places: within a NOTICE text file distributed as part
123 | of the Derivative Works; within the Source form or documentation, if provided
124 | along with the Derivative Works; or, within a display generated by the Derivative
125 | Works, if and wherever such third-party notices normally appear. The contents
126 | of the NOTICE file are for informational purposes only and do not modify the
127 | License. You may add Your own attribution notices within Derivative Works
128 | that You distribute, alongside or as an addendum to the NOTICE text from the
129 | Work, provided that such additional attribution notices cannot be construed
130 | as modifying the License.
131 |
132 | You may add Your own copyright statement to Your modifications and may provide
133 | additional or different license terms and conditions for use, reproduction,
134 | or distribution of Your modifications, or for any such Derivative Works as
135 | a whole, provided Your use, reproduction, and distribution of the Work otherwise
136 | complies with the conditions stated in this License.
137 |
138 | 5. Submission of Contributions. Unless You explicitly state otherwise, any
139 | Contribution intentionally submitted for inclusion in the Work by You to the
140 | Licensor shall be under the terms and conditions of this License, without
141 | any additional terms or conditions. Notwithstanding the above, nothing herein
142 | shall supersede or modify the terms of any separate license agreement you
143 | may have executed with Licensor regarding such Contributions.
144 |
145 | 6. Trademarks. This License does not grant permission to use the trade names,
146 | trademarks, service marks, or product names of the Licensor, except as required
147 | for reasonable and customary use in describing the origin of the Work and
148 | reproducing the content of the NOTICE file.
149 |
150 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to
151 | in writing, Licensor provides the Work (and each Contributor provides its
152 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
153 | KIND, either express or implied, including, without limitation, any warranties
154 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR
155 | A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness
156 | of using or redistributing the Work and assume any risks associated with Your
157 | exercise of permissions under this License.
158 |
159 | 8. Limitation of Liability. In no event and under no legal theory, whether
160 | in tort (including negligence), contract, or otherwise, unless required by
161 | applicable law (such as deliberate and grossly negligent acts) or agreed to
162 | in writing, shall any Contributor be liable to You for damages, including
163 | any direct, indirect, special, incidental, or consequential damages of any
164 | character arising as a result of this License or out of the use or inability
165 | to use the Work (including but not limited to damages for loss of goodwill,
166 | work stoppage, computer failure or malfunction, or any and all other commercial
167 | damages or losses), even if such Contributor has been advised of the possibility
168 | of such damages.
169 |
170 | 9. Accepting Warranty or Additional Liability. While redistributing the Work
171 | or Derivative Works thereof, You may choose to offer, and charge a fee for,
172 | acceptance of support, warranty, indemnity, or other liability obligations
173 | and/or rights consistent with this License. However, in accepting such obligations,
174 | You may act only on Your own behalf and on Your sole responsibility, not on
175 | behalf of any other Contributor, and only if You agree to indemnify, defend,
176 | and hold each Contributor harmless for any liability incurred by, or claims
177 | asserted against, such Contributor by reason of your accepting any such warranty
178 | or additional liability. END OF TERMS AND CONDITIONS
179 |
180 | APPENDIX: How to apply the Apache License to your work.
181 |
182 | To apply the Apache License to your work, attach the following boilerplate
183 | notice, with the fields enclosed by brackets "[]" replaced with your own identifying
184 | information. (Don't include the brackets!) The text should be enclosed in
185 | the appropriate comment syntax for the file format. We also recommend that
186 | a file or class name and description of purpose be included on the same "printed
187 | page" as the copyright notice for easier identification within third-party
188 | archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 |
194 | you may not use this file except in compliance with the License.
195 |
196 | You may obtain a copy of the License at
197 |
198 | http://www.apache.org/licenses/LICENSE-2.0
199 |
200 | Unless required by applicable law or agreed to in writing, software
201 |
202 | distributed under the License is distributed on an "AS IS" BASIS,
203 |
204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
205 |
206 | See the License for the specific language governing permissions and
207 |
208 | limitations under the License.
209 |
--------------------------------------------------------------------------------
/remarkable_cli/client.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import json
3 | import logging
4 | import os
5 | from argparse import Namespace
6 | from collections import deque
7 | from glob import glob
8 | from shutil import rmtree
9 | from stat import S_ISDIR, S_ISREG
10 |
11 | import paramiko
12 | from requests import Request, Session, adapters
13 |
14 | from .convert_rm import ConvertRM
15 |
16 |
17 | class Client:
18 | LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"]
19 |
20 | def __init__(self, args: Namespace):
21 | log_format = "%(asctime)s [%(levelname)s]: %(message)s"
22 | if args.log_level is None:
23 | args.log_level = 3
24 | log_index = min(max(0, args.log_level), len(Client.LOG_LEVELS) - 1)
25 | log_level = Client.LOG_LEVELS[log_index]
26 | logging.basicConfig(format=log_format, level=logging.getLevelName(log_level))
27 |
28 | self._log = logging.getLogger(__name__)
29 | self.args = args
30 | self._log.debug(args)
31 | self.ssh_client = None
32 |
33 | # create the backup directory if not exists
34 | os.makedirs(self.args.backup_dir, exist_ok=True)
35 | self.backup_dir = self.args.backup_dir
36 | self.raw_backup_dir = os.path.join(self.args.backup_dir, ".raw")
37 | self.templates_dir = os.path.join(self.args.backup_dir, "templates")
38 | self.pdf_backup_dir = os.path.join(self.args.backup_dir, "My files")
39 | self.trash_backup_dir = os.path.join(self.args.backup_dir, "Trash")
40 |
41 | @staticmethod
42 | def sftp_walk(ftp_client, remote_path, sub_dirs=()):
43 | """Walk through the remote reMarkable directory structure"""
44 | for file_attr in ftp_client.listdir_attr(os.path.join(remote_path, *sub_dirs)):
45 | if S_ISDIR(file_attr.st_mode):
46 | # directory, recurse into the folder
47 | nested_sub_dirs = list(sub_dirs)
48 | nested_sub_dirs.append(file_attr.filename)
49 | yield from Client.sftp_walk(
50 | ftp_client, remote_path, sub_dirs=nested_sub_dirs
51 | )
52 | elif S_ISREG(file_attr.st_mode):
53 | # file, yield it out
54 | yield file_attr, os.path.join(*sub_dirs, file_attr.filename)
55 | else:
56 | # unsupported file type
57 | continue
58 |
59 | @staticmethod
60 | def _get_path(meta_id, metadata, dir_hierarchy: deque = []):
61 | """Get the entity relative path"""
62 | meta = metadata.get(meta_id)
63 | if meta is None:
64 | raise RuntimeError(f"meta_id: {meta_id} does not exist")
65 | visible_name = meta.get("visibleName")
66 |
67 | parent_id = meta.get("parent")
68 | is_trash = False
69 | if parent_id == "trash":
70 | is_trash = True
71 | elif parent_id:
72 | nested_dir_hierarchy = deque(dir_hierarchy)
73 | nested_dir_hierarchy.appendleft(visible_name)
74 | return Client._get_path(
75 | parent_id, metadata, dir_hierarchy=nested_dir_hierarchy
76 | )
77 |
78 | basename = os.path.join(visible_name, *dir_hierarchy)
79 | return basename, is_trash
80 |
81 | def run_actions(self):
82 | """Run all of the specified actions (push, pull)"""
83 | # dedupe the list of actions
84 | actions = deque([])
85 | for action in self.args.action:
86 | if action not in actions:
87 | actions.append(action)
88 |
89 | while actions:
90 | action = actions.popleft()
91 | self._log.debug("running action: %s", action)
92 |
93 | if action == "push":
94 | self.connect()
95 | self._log.warning("not implemented")
96 | elif action == "pull":
97 | self.connect()
98 | self.pull_template_files()
99 | self.pull_xochitl_files()
100 | self.convert_xochitl_files()
101 | elif action == "pull-raw":
102 | self.connect()
103 | self.pull_xochitl_files()
104 | elif action == "pull-web":
105 | self.pull_pdf_files()
106 | elif action == "convert-raw":
107 | self.convert_xochitl_files()
108 | elif action == "clean-local":
109 | self.clean_local()
110 | else:
111 | self._log.warning("unknown action: %s", action)
112 |
113 | self.close()
114 | self._log.info("actions completed, see %s", self.backup_dir)
115 |
116 | def connect(self):
117 | """Connect to the reMarkable tablet using Paramiko SSH"""
118 | if self.ssh_client is None:
119 | username = self.args.username
120 | hostname = self.args.destination
121 | port = self.args.port
122 | password = self.args.password
123 | self._log.info("Connecting to %s@%s:%d", username, hostname, port)
124 |
125 | try:
126 | ssh_client = paramiko.SSHClient()
127 | ssh_client.load_system_host_keys()
128 | ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
129 | ssh_client.connect(
130 | hostname=hostname,
131 | username=username,
132 | port=port,
133 | password=password,
134 | timeout=5.0,
135 | )
136 | self.ssh_client = ssh_client
137 | except Exception:
138 | self._log.error("could not connect to reMarkable tablet")
139 | raise
140 | return self.ssh_client
141 |
142 | def close(self):
143 | """Close the SSH session if it exists"""
144 | if self.ssh_client:
145 | self.ssh_client.close()
146 | self.ssh_client = None
147 |
148 | def clean_local(self):
149 | """Remove the local xochitl and pdf backup directories if exists."""
150 | backup_dirs = [
151 | self.raw_backup_dir,
152 | self.templates_dir,
153 | self.pdf_backup_dir,
154 | self.trash_backup_dir,
155 | ]
156 | for backup_dir in backup_dirs:
157 | if os.path.exists(backup_dir) and os.path.isdir(backup_dir):
158 | self._log.info("removing local directory %s", backup_dir)
159 | rmtree(backup_dir)
160 |
161 | def _pull_sftp_files(self, remote_path, local_path):
162 | ftp_client = None
163 | try:
164 | counter = 0
165 | ftp_client = self.ssh_client.open_sftp()
166 | for pf_attr, pull_file in Client.sftp_walk(ftp_client, remote_path):
167 | counter += 1
168 | remote_fp = os.path.join(remote_path, pull_file)
169 | local_fp = os.path.join(local_path, pull_file)
170 | local_dir = os.path.dirname(local_fp)
171 | os.makedirs(local_dir, exist_ok=True)
172 |
173 | if os.path.isfile(local_fp):
174 | local_stat = os.stat(local_fp)
175 | if local_stat.st_mtime >= pf_attr.st_mtime:
176 | self._log.debug("skipping file %s", pull_file)
177 | continue
178 |
179 | self._log.info("copying file %s", pull_file)
180 | self._log.debug(pf_attr)
181 | self._log.debug("remote stat access time: %d", pf_attr.st_atime)
182 | self._log.debug("remote stat modified time: %d", pf_attr.st_mtime)
183 | self._log.debug("remote_fp: %s", remote_fp)
184 | self._log.debug("local_fp: %s", local_fp)
185 | self._log.debug("local_dir: %s", local_dir)
186 |
187 | ftp_client.get(remote_fp, local_fp)
188 | os.utime(local_fp, (pf_attr.st_atime, pf_attr.st_mtime))
189 | self._log.info("pulled %d files to %s", counter, local_path)
190 | except Exception:
191 | self._log.error("could not pull files")
192 | raise
193 | finally:
194 | if ftp_client:
195 | ftp_client.close()
196 |
197 | def pull_xochitl_files(self):
198 | """Copy files from remote xochitl directory to local raw backup directory.
199 | Keep the access and modified times of the file specified.
200 | """
201 | os.makedirs(self.raw_backup_dir, exist_ok=True)
202 | self._pull_sftp_files(self.args.file_path, self.raw_backup_dir)
203 |
204 | def pull_template_files(self):
205 | """Copy files from remote templates directory to local templates directory."""
206 | os.makedirs(self.templates_dir, exist_ok=True)
207 | self._pull_sftp_files(self.args.templates_path, self.templates_dir)
208 |
209 | def _derive_metadata(self):
210 | metadata = {}
211 | # get the xochitl metadata into memory from disk
212 | meta_fps = glob(os.path.join(self.raw_backup_dir, "*.metadata"))
213 | for meta_fp in meta_fps:
214 | with open(meta_fp, "r") as fh:
215 | meta = json.load(fh)
216 | meta_id = os.path.splitext(os.path.basename(meta_fp))[0]
217 | metadata[meta_id] = meta
218 | return metadata
219 |
220 | def _request_file_entity(self, session: Session, url: str, timeout=(9.03, 30.03)):
221 | headers = {
222 | "Host": self.args.destination,
223 | "Accept": (
224 | "text/html,application/xhtml+xml,"
225 | + "application/xml;q=0.9,image/webp,*/*;q=0.8"
226 | ),
227 | "Accept-Language": "en-CA,en-US;q=0.7,en;q=0.3",
228 | "Accept-Encoding": "gzip, deflate",
229 | "DNT": "1",
230 | "Connection": "keep-alive",
231 | "Referer": f"http://{self.args.destination}/",
232 | "Upgrade-Insecure-Requests": "1",
233 | "Sec-GPC": "1",
234 | }
235 | try:
236 | req = Request("GET", url, headers=headers)
237 | prepped = session.prepare_request(req)
238 | self._log.debug("GET %s", url)
239 | res = session.send(prepped, timeout=timeout)
240 |
241 | if res.status_code != 200:
242 | raise ConnectionError(f"failed to GET {url}")
243 | return res
244 | except Exception:
245 | self._log.warning("failed to GET %s", url)
246 | raise
247 |
248 | def pull_pdf_files(self):
249 | """Use the web interface to download pdfs.
250 | This should really be a conversion of the local xochitl files instead."""
251 | os.makedirs(self.pdf_backup_dir, exist_ok=True)
252 | os.makedirs(self.trash_backup_dir, exist_ok=True)
253 | metadata = self._derive_metadata()
254 |
255 | counter_ok = 0
256 | counter_total = 0
257 |
258 | with Session() as session:
259 | adapter = adapters.HTTPAdapter(max_retries=0)
260 | session.mount("http://", adapter)
261 |
262 | for meta_id, meta in metadata.items():
263 | path, is_trash = Client._get_path(meta_id, metadata)
264 | self._log.debug(path)
265 | self._log.debug(meta)
266 |
267 | local_dir = self.trash_backup_dir if is_trash else self.pdf_backup_dir
268 | meta_type = meta.get("type")
269 | meta_deleted = meta.get("deleted", False)
270 |
271 | if meta_deleted:
272 | continue
273 |
274 | if meta_type == "DocumentType":
275 | # is file, download as a PDF
276 | rel_fp = f"{path}{os.path.extsep}pdf"
277 | path = os.path.join(local_dir, rel_fp)
278 | os.makedirs(os.path.dirname(path), exist_ok=True)
279 |
280 | # if local file exists and has up-to-date modified time, ignore
281 | last_modified = int(meta.get("lastModified", "0")) / 1000
282 | if os.path.isfile(path):
283 | local_stat = os.stat(path)
284 | if local_stat.st_mtime >= last_modified:
285 | self._log.debug("skipping %s", rel_fp)
286 | continue
287 |
288 | self._log.info("retrieving %s", rel_fp)
289 | url = (
290 | f"http://{self.args.destination}/download/{meta_id}/placeholder"
291 | )
292 | try:
293 | res = self._request_file_entity(session, url)
294 | with open(path, "wb") as fh:
295 | fh.write(res.content)
296 | os.utime(path, (last_modified, last_modified))
297 | counter_ok += 1
298 | except Exception:
299 | self._log.warning("skipping %s", rel_fp)
300 | continue
301 | finally:
302 | counter_total += 1
303 |
304 | elif meta_type == "CollectionType":
305 | # is a folder, ensure exists and continue
306 | os.makedirs(os.path.join(local_dir, path), exist_ok=True)
307 | else:
308 | self._log.warning(
309 | "entity %s has unsupported type: %s", meta_id, meta_type
310 | )
311 | continue
312 |
313 | self._log.info(
314 | "pulled %d/%d pdf files to %s",
315 | counter_ok,
316 | counter_total,
317 | self.args.backup_dir,
318 | )
319 |
320 | def convert_xochitl_files(self):
321 | os.makedirs(self.pdf_backup_dir, exist_ok=True)
322 | os.makedirs(self.trash_backup_dir, exist_ok=True)
323 |
324 | metadata = self._derive_metadata()
325 | meta_fps = glob(os.path.join(self.raw_backup_dir, "*.metadata"))
326 | for meta_fp in meta_fps:
327 | uuid_fp = os.path.splitext(meta_fp)[0]
328 | if not os.path.isdir(uuid_fp):
329 | self._log.debug("skipping %s", meta_fp)
330 | continue
331 | meta_id = os.path.basename(uuid_fp)
332 | meta = metadata[meta_id]
333 |
334 | path, is_trash = Client._get_path(meta_id, metadata)
335 | local_dir = self.trash_backup_dir if is_trash else self.pdf_backup_dir
336 | rel_fp = f"{path}{os.path.extsep}pdf"
337 | path = os.path.join(local_dir, rel_fp)
338 | os.makedirs(os.path.dirname(path), exist_ok=True)
339 |
340 | # if local file exists and has up-to-date modified time, ignore
341 | last_modified = int(meta.get("lastModified", "0")) / 1000
342 | if os.path.isfile(path):
343 | local_stat = os.stat(path)
344 | if local_stat.st_mtime >= last_modified:
345 | self._log.debug("skipping %s", rel_fp)
346 | continue
347 |
348 | converter = ConvertRM(uuid_fp, self.templates_dir, logger=self._log)
349 |
350 | disp_fp = (
351 | os.path.join("trash", rel_fp)
352 | if is_trash
353 | else os.path.join("My files", rel_fp)
354 | )
355 | self._log.info("rendering %s", disp_fp)
356 | converter.convert_document(path)
357 | os.utime(path, (last_modified, last_modified))
358 |
--------------------------------------------------------------------------------