├── images
└── logo.png
├── Dockerfile
├── LICENSE.md
├── .gitignore
├── README.md
└── main.py
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EelcovanVeldhuizen/remarkable-obsidian-sync/HEAD/images/logo.png
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11.4
2 |
3 | ADD main.py .
4 | RUN apt-get update && apt-get -y install poppler-utils && apt-get clean
5 | RUN pip install pdf2image
6 | RUN pip install "git+https://github.com/EelcovanVeldhuizen/rmc.git@Excalidraw"
7 | RUN mkdir -p /app/remarkables
8 | RUN mkdir -p /app/vault
9 | CMD ["python", "./main.py"]
10 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2023 Eelco van Veldhuizen
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Remarkable files
2 | .rm
3 |
4 | # Byte-compiled / optimized / DLL files
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 |
9 | # C extensions
10 | *.so
11 |
12 | # Distribution / packaging
13 | .Python
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | share/python-wheels/
27 | *.egg-info/
28 | .installed.cfg
29 | *.egg
30 | MANIFEST
31 |
32 | # PyInstaller
33 | # Usually these files are written by a python script from a template
34 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
35 | *.manifest
36 | *.spec
37 |
38 | # Installer logs
39 | pip-log.txt
40 | pip-delete-this-directory.txt
41 |
42 | # Unit test / coverage reports
43 | htmlcov/
44 | .tox/
45 | .nox/
46 | .coverage
47 | .coverage.*
48 | .cache
49 | nosetests.xml
50 | coverage.xml
51 | *.cover
52 | *.py,cover
53 | .hypothesis/
54 | .pytest_cache/
55 | cover/
56 |
57 | # Translations
58 | *.mo
59 | *.pot
60 |
61 | # Django stuff:
62 | *.log
63 | local_settings.py
64 | db.sqlite3
65 | db.sqlite3-journal
66 |
67 | # Flask stuff:
68 | instance/
69 | .webassets-cache
70 |
71 | # Scrapy stuff:
72 | .scrapy
73 |
74 | # Sphinx documentation
75 | docs/_build/
76 |
77 | # PyBuilder
78 | .pybuilder/
79 | target/
80 |
81 | # Jupyter Notebook
82 | .ipynb_checkpoints
83 |
84 | # IPython
85 | profile_default/
86 | ipython_config.py
87 |
88 | # pyenv
89 | # For a library or package, you might want to ignore these files since the code is
90 | # intended to run in multiple environments; otherwise, check them in:
91 | # .python-version
92 |
93 | # pipenv
94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
97 | # install all needed dependencies.
98 | #Pipfile.lock
99 |
100 | # poetry
101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
102 | # This is especially recommended for binary packages to ensure reproducibility, and is more
103 | # commonly ignored for libraries.
104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
105 | #poetry.lock
106 |
107 | # pdm
108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
109 | #pdm.lock
110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
111 | # in version control.
112 | # https://pdm.fming.dev/#use-with-ide
113 | .pdm.toml
114 |
115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116 | __pypackages__/
117 |
118 | # Celery stuff
119 | celerybeat-schedule
120 | celerybeat.pid
121 |
122 | # SageMath parsed files
123 | *.sage.py
124 |
125 | # Environments
126 | .env
127 | .venv
128 | env/
129 | venv/
130 | ENV/
131 | env.bak/
132 | venv.bak/
133 |
134 | # Spyder project settings
135 | .spyderproject
136 | .spyproject
137 |
138 | # Rope project settings
139 | .ropeproject
140 |
141 | # mkdocs documentation
142 | /site
143 |
144 | # mypy
145 | .mypy_cache/
146 | .dmypy.json
147 | dmypy.json
148 |
149 | # Pyre type checker
150 | .pyre/
151 |
152 | # pytype static type analyzer
153 | .pytype/
154 |
155 | # Cython debug symbols
156 | cython_debug/
157 |
158 | # PyCharm
159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161 | # and can be added to the global gitignore or merged into this file. For a more nuclear
162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163 | #.idea/
164 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
22 |
23 |
24 |
25 | Table of Contents
26 |
27 | -
28 | About The Project
29 |
32 |
33 | -
34 | Getting Started
35 |
39 |
40 | - Usage
41 | - Contributing
42 | - License
43 | - Contact
44 | - Acknowledgments
45 |
46 |
47 |
48 |
49 |
50 | ## About The Project
51 |
52 |
53 |
54 |
55 | (back to top)
56 |
57 |
58 | ### Built With
59 |
60 | * 
61 | * 
62 |
63 |
64 | (back to top)
65 |
66 |
67 |
68 | ## Getting Started
69 |
70 | The usage is pretty straight forward. There are some dependencies, see the Dockerfile if you want to know more.
71 |
72 |
73 | ### Prerequisites
74 |
75 | You need to have Docker if you want use this script the same way as I do. How to get Docker is explained [on the Docker website](https://docs.docker.com/get-docker/).
76 |
77 | ### Installation
78 |
79 | 1. Make sure you have something to copy the files from the remarkable to your machine. I use Rsync which comes with MacOs and almost every Linux distribution.
80 |
81 | 2. Clone the repo
82 | ```sh
83 | git clone git@github.com:EelcovanVeldhuizen/remarkable-obsidian-sync.git
84 | ```
85 |
86 | (back to top)
87 |
88 |
89 | ## Usage
90 |
91 | Sync your remarkables with RSYNC (or any other tool). This works the best if you have set-up passwordless login with the help of SSH keys. The best explenation how to do this I found this [Reddit Post](https://www.reddit.com/r/RemarkableTablet/comments/78u90n/passwordless_ssh_setup_for_remarkable_tablet/).
92 |
93 |
94 | ```sh
95 | rsync -av root@10.11.99.1:/home/root/.local/share/remarkable/xochitl/
96 | ```
97 |
98 | I use Docker to create keep the python installtion on my machine clean.
99 |
100 | To build build the container (which will be named ros):
101 |
102 | ```sh
103 | docker build -t ros .
104 | ```
105 |
106 | Run the container with the paths to your remarkable files and where you want the markdown files to end up.
107 |
108 | ```sh
109 | docker run -v :/app/remarkables:ro -v :/app/vault ros
110 | ```
111 |
112 | (back to top)
113 |
114 |
115 | ## Contributing
116 |
117 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
118 |
119 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
120 | Don't forget to give the project a star! Thanks again!
121 |
122 | 1. Fork the Project
123 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
124 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
125 | 4. Push to the Branch (`git push origin feature/AmazingFeature`)
126 | 5. Open a Pull Request
127 |
128 | (back to top)
129 |
130 |
131 | ## License
132 |
133 | Distributed under the MIT License. See `LICENSE.txt` for more information.
134 |
135 | (back to top)
136 |
137 |
138 | ## Contact
139 | You can reach me on Github!
140 |
141 | Project Link: [https://github.com/EelcovanVeldhuizen/remarkable-obsidian-sync](https://github.com/EelcovanVeldhuizen/remarkable-obsidian-sync)
142 |
143 |
144 | (back to top)
145 |
146 |
147 | ## Acknowledgments
148 |
149 | * [RMScene by Ricklupton](https://github.com/ricklupton/rmscene)
150 | * [RMC by Ricklupton](https://github.com/ricklupton/rmc)
151 | * [Best README Template by Othneildrew](https://github.com/othneildrew/Best-README-Template)
152 |
153 | (back to top)
154 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import re
4 | import base64
5 | import argparse
6 | from io import BytesIO
7 | from PIL.PngImagePlugin import PngImageFile
8 | import dataclasses
9 |
10 | # pip install pdf2image
11 | import pdf2image
12 |
13 | # pip install rmscene
14 | from rmscene.scene_stream import read_blocks
15 |
16 | # pip install rmc
17 | from rmc.exporters.excalidraw import blocks_to_excalidraw
18 | from rmc.exporters.excalidraw import ExcalidrawImageElement, ExcalidrawFile
19 | from rmc.exporters.obsidian import excalidraw_to_obsidian
20 |
21 | @dataclasses.dataclass()
22 | class RemarkableTablet:
23 | SCREEN_WIDTH = 1404
24 | SCREEN_HEIGHT = 1872
25 |
26 | def convert_image_to_base64_image(image: PngImageFile) -> str:
27 | buff = BytesIO()
28 | image.save(buff, format="PNG")
29 | img_base64_str = base64.b64encode(buff.getvalue()).decode('ascii')
30 |
31 | return "data:image/png;base64,{image}".format(image=img_base64_str)
32 |
33 | def convert_to_obsidian(remarkable_file:dict, excalidrawFileForBackground:ExcalidrawFile) -> str:
34 | with open(remarkable_file['remarkable'], 'rb') as f:
35 | blocks = read_blocks(f)
36 | excalidrawDocument = blocks_to_excalidraw(blocks)
37 |
38 | if excalidrawFileForBackground:
39 | # Add the file to document
40 | excalidrawDocument.addFile(excalidrawFileForBackground)
41 |
42 | # Set the dimensions and positions
43 | imageElement = ExcalidrawImageElement()
44 | imageElement.y = 60
45 | imageElement.x = -750
46 | imageElement.width = RemarkableTablet.SCREEN_WIDTH
47 | imageElement.height = RemarkableTablet.SCREEN_HEIGHT
48 | imageElement.fileId = excalidrawFileForBackground.id
49 |
50 | # Add the image to list of elements.
51 | # Background Elements need to be first in the elements list
52 | excalidrawDocument.elements.insert(0, imageElement)
53 |
54 | return excalidraw_to_obsidian(excalidrawDocument)
55 |
56 | def clean_up_file_content(content: str) -> str:
57 | content = content.strip() # Pagedata sometimes have a lot of blank lines
58 | if content == "Blank": content = "" # Pagedata sometimes have the word Blank in them
59 | return content
60 |
61 | def read_contents(filepath: str) -> dict:
62 | content = json.loads("{}")
63 |
64 | if os.path.isfile(filepath):
65 | f = clean_up_file_content(open(filepath, 'r').read())
66 | if f: content = json.loads(f)
67 | return content
68 |
69 | def find_remarkables_files(uuid: str, remarkables_directory) -> list[str]:
70 | path = "{remarkables_directory}/{uuid}".format(remarkables_directory=remarkables_directory, uuid=uuid)
71 | files = [os.path.join(path, file) for file in os.listdir(path)]
72 |
73 | remarkable_files = filter(lambda d: d.endswith(".rm"), files)
74 |
75 | return list(remarkable_files)
76 |
77 | def connect_name_to_remarkable_drawing(metadata: dict) -> str:
78 | return metadata["visibleName"]
79 |
80 | def pagenumber_of_remarkable_drawing(content: dict, drawing: str) -> int:
81 | if "cPages" in content.keys():
82 | pages = [p['id'] for p in content['cPages']['pages']]
83 | else:
84 | pages = content["pages"]
85 | try:
86 | pagenumber = pages.index(os.path.basename(drawing).split(".")[0]) + 1
87 | except ValueError:
88 | print("Couldn't connect a name to remarkable file. Remarkable file: "+ drawing)
89 | pagenumber = None
90 | return pagenumber
91 |
92 | def remarkable_has_background(content: dict) -> bool:
93 | return "pdf" in content['fileType']
94 |
95 | def get_background_for_remarkable(remarkable: dict, directory: str, fileType="pdf") -> PngImageFile:
96 |
97 |
98 | path_to_pdf = "{remarkables_directory}/{filename}.{filetype}".format(remarkables_directory=directory,
99 | filename=remarkable['parent_uuid'],
100 | filetype=fileType)
101 | page_number = remarkable["pagenumber"]
102 |
103 | return pdf2image.convert_from_path(path_to_pdf, fmt="png",
104 | first_page=page_number,
105 | last_page=page_number,
106 | size=(RemarkableTablet.SCREEN_WIDTH, RemarkableTablet.SCREEN_HEIGHT))[0]
107 |
108 |
109 |
110 | def remarkables_to_convert(uuids: list[str], remarkables_directory: str) -> list[dict]:
111 | remarkables_to_convert = []
112 |
113 | for uuid in uuids:
114 | content = read_contents("{remarkables_directory}/{filename}.content".format(remarkables_directory=remarkables_directory, filename=uuid))
115 | metadata = read_contents("{remarkables_directory}/{filename}.metadata".format(remarkables_directory=remarkables_directory, filename=uuid))
116 | #pagedata = read_contents("{filename}.pagedata".format(filename=uuid)) # Seems to be useless files (for now)
117 |
118 | remarkable_drawings = find_remarkables_files(uuid, remarkables_directory)
119 |
120 | for drawing in remarkable_drawings:
121 | remarkable = dict()
122 |
123 | remarkable['filename'] = connect_name_to_remarkable_drawing(metadata)
124 | remarkable['pagenumber'] = pagenumber_of_remarkable_drawing(content, drawing)
125 | remarkable['background'] = remarkable_has_background(content)
126 | remarkable['remarkable'] = os.path.abspath(drawing)
127 | remarkable['parent_uuid'] = uuid
128 |
129 | remarkables_to_convert.append(remarkable)
130 |
131 | return remarkables_to_convert
132 |
133 | def get_uuids_to_process(directory:str = "." ) -> list:
134 | # Get a list of directories
135 | directories = filter(lambda d: not d.endswith(".thumbnails"), [x[0] for x in os.walk(directory)])
136 | # Get the uuids and drop the "." at the beginning of the list
137 | uuids = [os.path.basename(x) for x in directories][1:]
138 | # Filter out any possible invalid files / directories that are not a uuid
139 | uuids = filter(lambda d: re.search(r'^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$', d), uuids)
140 |
141 | return list(uuids)
142 |
143 | def get_ExcalidrawFile_to_use_as_background(remarkable: dict, remarkables_directory: str) -> ExcalidrawFile:
144 | background_image = get_background_for_remarkable(remarkable, remarkables_directory)
145 | return ExcalidrawFile(mimeType="image/png", dataURL= convert_image_to_base64_image(background_image))
146 |
147 | def app(remarkables_directory, vault_directory) -> None:
148 | uuids = get_uuids_to_process(remarkables_directory)
149 |
150 | remarkables = remarkables_to_convert(uuids, remarkables_directory)
151 |
152 | for remarkable in remarkables:
153 | ExcalidrawFileAsBackground = None
154 |
155 | print("reading: "+remarkable['remarkable'])
156 |
157 | if remarkable["background"] and remarkable["pagenumber"]:
158 | ExcalidrawFileAsBackground = get_ExcalidrawFile_to_use_as_background(remarkable, remarkables_directory)
159 |
160 | obsidian_markdown = convert_to_obsidian(remarkable, ExcalidrawFileAsBackground)
161 |
162 | file_path = "{vault_directory}/{filename}-page-{pagenumber}.md".format(vault_directory = vault_directory,
163 | filename=remarkable["filename"],
164 | pagenumber=remarkable["pagenumber"])
165 | with open(file_path, 'w') as f:
166 | print("writing: "+file_path)
167 | f.write(obsidian_markdown)
168 |
169 | if __name__ == "__main__":
170 | parser = argparse.ArgumentParser(prog='Remarkable To Obsidian Synchronization',
171 | description='Creates Excalidraw Markdown files to be used in Obsidian')
172 | parser.add_argument('-i', '--input-dir', type=str, default="/app/remarkables", dest='input_dir')
173 | parser.add_argument('-o', '--output-dir', type=str, default="/app/vault", dest='output_dir')
174 | args = parser.parse_args()
175 |
176 | app(args.input_dir, args.output_dir)
177 |
178 |
--------------------------------------------------------------------------------