├── 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 |
8 | 9 | Logo 10 | 11 | 12 |

Remarkable to Obsidian Sync

13 | 14 |

15 | This project syncs but most important converts your remarkable drawings to Excalidraw / markdown files that can be used in combination with the Obsidian Excalidraw Plugin. 16 |
17 |
18 | Report Bug 19 | Request Feature 20 |

21 |
22 | 23 | 24 |
25 | Table of Contents 26 |
    27 |
  1. 28 | About The Project 29 | 32 |
  2. 33 |
  3. 34 | Getting Started 35 | 39 |
  4. 40 |
  5. Usage
  6. 41 |
  7. Contributing
  8. 42 |
  9. License
  10. 43 |
  11. Contact
  12. 44 |
  13. Acknowledgments
  14. 45 |
46 |
47 | 48 | 49 | 50 | ## About The Project 51 | 52 | 53 | 54 | 55 |

(back to top)

56 | 57 | 58 | ### Built With 59 | 60 | * ![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) 61 | * ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) 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 | --------------------------------------------------------------------------------