├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ ├── code_quality.yaml │ └── semantic_release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTION.md ├── LICENSE ├── Notion2md.jpg ├── README.md ├── notion2md ├── __init__.py ├── __main__.py ├── config.py ├── console │ ├── __init__.py │ ├── application.py │ ├── commands │ │ └── export_block.py │ ├── formatter.py │ └── ui │ │ ├── __init__.py │ │ └── indicator.py ├── convertor │ ├── __init__.py │ ├── block.py │ └── richtext.py ├── exceptions.py ├── exporter │ ├── __init__.py │ └── block.py ├── notion_api.py └── util.py ├── notion2md_options.png ├── pyproject.toml └── tests ├── test_numbered_list.py └── test_richtext_convertor.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, F401, W503 3 | max-line-length = 160 4 | extend-ignore = E203 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: echo724 4 | -------------------------------------------------------------------------------- /.github/workflows/code_quality.yaml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | - uses: pre-commit/action@v2.0.0 16 | -------------------------------------------------------------------------------- /.github/workflows/semantic_release.yaml: -------------------------------------------------------------------------------- 1 | name: Semantic Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | - name: Install poetry 14 | run: pip install poetry 15 | - uses: bjoluc/semantic-release-config-poetry@v2 16 | with: 17 | pypi_token: ${{ secrets.PYPI_TOKEN }} 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sh 2 | setup.cfg 3 | __pycache__ 4 | *.pyc 5 | 6 | mphyspy.egg-info 7 | 8 | # Jupyter Notebook 9 | *.ipynb 10 | 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # celery beat schedule file 97 | celerybeat-schedule 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | .venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | .sh 126 | 127 | .DS_Store 128 | Makefile 129 | build.sh 130 | 131 | bin/ 132 | 133 | Test/config.py 134 | output.md 135 | 136 | notion2md-output/ 137 | Makefile 138 | 139 | poetry.lock 140 | 141 | tmp/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pycqa/isort 3 | rev: '' 4 | hooks: 5 | - id: isort 6 | 7 | - repo: https://github.com/psf/black 8 | rev: stable 9 | hooks: 10 | - id: black 11 | 12 | - repo: https://github.com/pycqa/flake8 13 | rev: '' 14 | hooks: 15 | - id: flake8 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See 4 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [2.9.0](https://github.com/echo724/notion2md/compare/v2.8.4...v2.9.0) (2024-01-10) 7 | 8 | 9 | ### Features 10 | 11 | * add notion token parameter to `Exporter` ([#60](https://github.com/echo724/notion2md/issues/60)) ([a93cf51](https://github.com/echo724/notion2md/commit/a93cf515bc0793e936d8b080475e459b98ede5fd)) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * fix encoded filename is too long case ([#59](https://github.com/echo724/notion2md/issues/59)) ([073994a](https://github.com/echo724/notion2md/commit/073994a32246dbd2bf957040fe9a454b52961a8e)) 17 | 18 | ## [2.8.4](https://github.com/echo724/notion2md/compare/v2.8.3...v2.8.4) (2023-12-28) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * fix numbered list format according to commonmark format ([#58](https://github.com/echo724/notion2md/issues/58)) ([305ec68](https://github.com/echo724/notion2md/commit/305ec6881aeb4a22ccce9b1d2ee7948a69765801)) 24 | 25 | ### [2.8.3](https://github.com/echo724/notion2md/compare/v2.8.2...v2.8.3) (2023-06-29) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * constructor in README.md ([03e42c4](https://github.com/echo724/notion2md/commit/03e42c4a8ba2b29a91df243b588a3cbcc6caffdf)) 31 | 32 | ### [2.8.2](https://github.com/echo724/notion2md/compare/v2.8.1...v2.8.2) (2023-02-26) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * fix retrieving over 100 blocks [#46](https://github.com/echo724/notion2md/issues/46) ([a89ce45](https://github.com/echo724/notion2md/commit/a89ce454d5ec53e7721ae326030c603016d7ab57)) 38 | 39 | ### [2.8.1](https://github.com/echo724/notion2md/compare/v2.8.0...v2.8.1) (2022-11-07) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * **pyproject:** fix dependencies' version requirement ([a7f2b04](https://github.com/echo724/notion2md/commit/a7f2b0432d0b2746c381c0c98c20116a727253fb)), closes [#44](https://github.com/echo724/notion2md/issues/44) 45 | 46 | ## [2.8.0](https://github.com/echo724/notion2md/compare/v2.7.6...v2.8.0) (2022-10-14) 47 | 48 | 49 | ### Features 50 | 51 | * Make downloaded filenames consistent between runs ([#38](https://github.com/echo724/notion2md/issues/38)) ([30e8f0b](https://github.com/echo724/notion2md/commit/30e8f0b9ccc9c103cbb7b35746c8ec9bc27ad76b)) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * Fixed [#40](https://github.com/echo724/notion2md/issues/40) due to the update of Notion API ([#42](https://github.com/echo724/notion2md/issues/42)) ([1a63351](https://github.com/echo724/notion2md/commit/1a633515ed835e9cda26cd0b5951773f2076a069)) 57 | 58 | ### [2.7.6](https://github.com/echo724/notion2md/compare/v2.7.5...v2.7.6) (2022-03-22) 59 | 60 | 61 | ### Features 62 | 63 | * Replace methods to Exporter Classes ([2b35818](https://github.com/echo724/notion2md/commit/2b3581801dfb0e08c7db39053013a8e80a29efdf)) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * Change the way of calling get_children function ([4563790](https://github.com/echo724/notion2md/commit/4563790cff4f39e1625de2c748fe85bd9e0725c8)) 69 | * Implemented Singletone pattern in NotionClient class ([b8443ba](https://github.com/echo724/notion2md/commit/b8443ba963267a4ede3df9e782d07d3001b0b8d6)) 70 | 71 | ### [2.7.5](https://github.com/echo724/notion2md/compare/v2.7.4...v2.7.5) (2022-03-01) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * Remove Singleton pattern in Config class ([#31](https://github.com/echo724/notion2md/issues/31)) ([2de75e3](https://github.com/echo724/notion2md/commit/2de75e367e1a4cea0bf8c4c0bd31b05f805e8f8b)) 77 | 78 | 79 | 80 | ## v2.7.4 (2022-02-21) 81 | ### Fix 82 | * Remove exclamation mark in front of square brackets in bookmark convertor ([`4c37b0d`](https://github.com/echo724/notion2md/commit/4c37b0dc2564c0b55f57e65bb1b576f8e70e47bd)) 83 | 84 | ## v2.7.3 (2022-02-20) 85 | ### Fix 86 | * Fix exporter methods passing positional arguments to Config class to dict ([`3c66ca3`](https://github.com/echo724/notion2md/commit/3c66ca3b094f7511b0107c62cb5e80f9408a3a30)) 87 | 88 | ## v2.7.2 (2022-02-20) 89 | ### Fix 90 | * Change importing method names typo ([`f14ad98`](https://github.com/echo724/notion2md/commit/f14ad98d3f48ffeb874594fab77af2c47136e51c)) 91 | 92 | ## v2.7.1 (2022-02-19) 93 | ### Fix 94 | * Indicate parameters explicitly in Config Object ([`7ea65a4`](https://github.com/echo724/notion2md/commit/7ea65a432cc860bdc294984344bd5f43c7d6b885)) 95 | 96 | ## v2.7.0 (2022-02-18) 97 | ### Feature 98 | * Add a case if there is no extension, notion2md won't download the file ([`a8965a8`](https://github.com/echo724/notion2md/commit/a8965a833856fe713128dbedb5146b06c5ddc724)) 99 | 100 | ## v2.6.3 (2022-02-17) 101 | ### Fix 102 | * Fix block_string_exporter not downloading files ([`f46f31c`](https://github.com/echo724/notion2md/commit/f46f31cc4629b8e976028fb5fe25c9ef9d11e5d2)) 103 | 104 | ## v2.6.2 (2022-02-14) 105 | ### Fix 106 | * Add time indicator while retrieving blocks ([`48532fd`](https://github.com/echo724/notion2md/commit/48532fd8f9a25b1cd2b88f5e271d67b4e94ea8da)) 107 | 108 | ## v2.6.1 (2022-02-13) 109 | ### Fix 110 | * Fix methods' name in exporter __init__ ([`4cada7d`](https://github.com/echo724/notion2md/commit/4cada7da7f86a030284d70612fbfbfc04f13cd5e)) 111 | * Fix output for the case if io is not given ([`457a048`](https://github.com/echo724/notion2md/commit/457a0481fb019ca67447fbd987d321ec6e160796)) 112 | 113 | ### Documentation 114 | * Add unzipped option explanation ([`00df4d6`](https://github.com/echo724/notion2md/commit/00df4d64b08b2eca97ee331a9fe29f35b2c1543e)) 115 | 116 | ## v2.6.0 (2022-02-11) 117 | ### Feature 118 | * Exporting Notion block files to a single zip ([`d32afde`](https://github.com/echo724/notion2md/commit/d32afde66903c9c7191a2a87b1d2de4b19388909)) 119 | 120 | ## v2.5.2 (2022-02-11) 121 | ### Fix 122 | * Include config.py to the package ([`73078d7`](https://github.com/echo724/notion2md/commit/73078d7a9ff8dc420a4b2d1771325dc2c4275353)) 123 | 124 | ## v2.5.1 (2022-02-11) 125 | ### Fix 126 | * Fix notion2md.config module import error ([`baafa44`](https://github.com/echo724/notion2md/commit/baafa442f608c64dae800038a334bdfe45ee052a)) 127 | * Fix config module not found error ([`7a1f18c`](https://github.com/echo724/notion2md/commit/7a1f18c3234c5c6582737e013dc40683847fa963)) 128 | 129 | ## v2.5.0 (2022-02-11) 130 | ### Feature 131 | * Change block object convert methods to class ([`0f38d9a`](https://github.com/echo724/notion2md/commit/0f38d9adbc532e16cd04091d3cb8768f01432070)) 132 | * Implemented Cleo for managing cli application ([`305f533`](https://github.com/echo724/notion2md/commit/305f533e770d9d3bfd4104cc38df4a7096bd4f6f)) 133 | 134 | ## v2.4.1 (2022-02-04) 135 | ### Fix 136 | * Fix typo ([`31bda67`](https://github.com/echo724/notion2md/commit/31bda67e083c499dad3c42ed5f520eeeceff9670)) 137 | 138 | ## v2.4.0 (2022-02-03) 139 | ### Feature 140 | * Add block to string exporter ([`26981f5`](https://github.com/echo724/notion2md/commit/26981f5cf1cfdd7e263a64758ba09e8f83cffcad)) 141 | * Add "download" option so that user can choose whether to download files or not ([`bddcc40`](https://github.com/echo724/notion2md/commit/bddcc4071a7b2ef434b8782eff177c1a27757fb3)) 142 | 143 | ### Documentation 144 | * Add python usage description ([`e10687e`](https://github.com/echo724/notion2md/commit/e10687e2e9a986f9d261bd8007038222d2a8447b)) 145 | * Checked Synced Block ([`791f779`](https://github.com/echo724/notion2md/commit/791f779a728b0f6ac17d6c807f573cb500ca2a06)) 146 | * Update notion2md options image ([`7d1b263`](https://github.com/echo724/notion2md/commit/7d1b26322b9ec2c8c2ec1631cf84e6a601b57427)) 147 | * Add download option description ([`004202b`](https://github.com/echo724/notion2md/commit/004202bd69f304864a5e3c938f183ec116b11f18)) 148 | 149 | ## v2.3.3 (2022-02-03) 150 | ### Fix 151 | * Synced Block commented out #26 ([`c623a98`](https://github.com/echo724/notion2md/commit/c623a98f1da556c4acdfbef0b7688ea66351e6ef)) 152 | * Change the api of block_exporter without creating config file from user ([`2cd8bb8`](https://github.com/echo724/notion2md/commit/2cd8bb82d9c92823f568163c22f69ee1a3bcfed6)) 153 | * Change the way of saving a file name using uuid #27 ([`52dd12a`](https://github.com/echo724/notion2md/commit/52dd12af851fcb324507d45395e5d5b6d12356d6)) 154 | * Update os.getenv ([`29d5167`](https://github.com/echo724/notion2md/commit/29d5167da47b268bd8574ce129825fa6c9fcb8ed)) 155 | * Update ([`8a8193c`](https://github.com/echo724/notion2md/commit/8a8193c66ab441e104f790c0b0eb53fe1c6945dd)) 156 | 157 | ### Documentation 158 | * Add a donation button on README.md ([`51d84cc`](https://github.com/echo724/notion2md/commit/51d84cc275612c2e388d66257562890b6d8e612e)) 159 | 160 | ## v2.3.2 (2022-01-22) 161 | ### Fix 162 | * Merge error ([`a4244c0`](https://github.com/echo724/notion2md/commit/a4244c0064db5deb797ad64953f8ab07e5fb5d6b)) 163 | * Change directory of downloaded files ([`452cc69`](https://github.com/echo724/notion2md/commit/452cc69f56972ee15b721ffda19a3c39ac404bd8)) 164 | 165 | ### Documentation 166 | * Change Contribution guide in README.md ([`2c2c1da`](https://github.com/echo724/notion2md/commit/2c2c1daef50127995384dde4df57b21d789ca653)) 167 | * Add CONTRIBUTE.md ([`18be264`](https://github.com/echo724/notion2md/commit/18be264b4ed41eaee9c76f59f200714e47772bac)) 168 | 169 | ## v2.3.1 (2022-01-17) 170 | ### Fix 171 | * Key error ([`af224a6`](https://github.com/echo724/notion2md/commit/af224a62580551df3e2c49aa361c359175dd9eea)) 172 | 173 | ## v2.3.0 (2022-01-17) 174 | ### Feature 175 | * Add file(image) downloader #20 ([`d42a1a0`](https://github.com/echo724/notion2md/commit/d42a1a02975181b05d9290f12adc3f7bc2b87c51)) 176 | 177 | ## v2.2.8 (2022-01-11) 178 | ### Fix 179 | * Fix name of image in block converter ([`757e253`](https://github.com/echo724/notion2md/commit/757e2534a55eafa9041d0f24b340504f09f92892)) 180 | 181 | ## v2.2.7 (2022-01-11) 182 | ### Fix 183 | * Add handling 'mention' object to fix #18 ([`291b2c1`](https://github.com/echo724/notion2md/commit/291b2c1867d766e996168c6157dabf8ba25f9c03)) 184 | * Change ERROR color to RED ([`1ac9c80`](https://github.com/echo724/notion2md/commit/1ac9c8013c247b132743795ed1260027d4067c63)) 185 | 186 | ## v2.2.6 (2022-01-11) 187 | ### Documentation 188 | * Update README.md ([`ff115cb`](https://github.com/echo724/notion2md/commit/ff115cb091520843eaa18cf691e147db076e8fc0)) 189 | 190 | ## v2.2.5 (2021-12-21) 191 | ### Fix 192 | * Unpacking config dick error ([`6d0b105`](https://github.com/echo724/notion2md/commit/6d0b105c05ab12671d930dd61e72f7014985bb09)) 193 | 194 | ## v2.2.4 (2021-12-21) 195 | ### Fix 196 | * Format console output ([`bf27ced`](https://github.com/echo724/notion2md/commit/bf27ced9eed5dfa173331a53aa76c9dd52f05499)) 197 | * Refactorize exporter ([`661e3f8`](https://github.com/echo724/notion2md/commit/661e3f85e432674adbf2009fd0165af4e02cb543)) 198 | * Add checking version ([`2c9a6c1`](https://github.com/echo724/notion2md/commit/2c9a6c19ff96bfd8ba3073e49f87e5c04fa8edb6)) 199 | * Change file name as id because title can cause dir error ([`4db6e0f`](https://github.com/echo724/notion2md/commit/4db6e0f37421d092099929910d5bcbaa7831d332)) 200 | * Halt program after token error catched ([`0c356b4`](https://github.com/echo724/notion2md/commit/0c356b4c3e772cb059e3a9fb7321878a89ed0b60)) 201 | 202 | ## v2.2.3 (2021-12-18) 203 | ### Fix 204 | * Showing error message when client didn't get client ([`3e07428`](https://github.com/echo724/notion2md/commit/3e07428c96adc37a70c8fcd9cda70dae59873656)) 205 | 206 | ## v2.2.2 (2021-12-16) 207 | ### Fix 208 | * Fix script not working ([`e001e75`](https://github.com/echo724/notion2md/commit/e001e75eb8cd7c19f702510008e872cec9e7a331)) 209 | 210 | ## v2.2.1 (2021-12-16) 211 | ### Fix 212 | * Fix module names ([`e323911`](https://github.com/echo724/notion2md/commit/e3239110f8a72fd337b58ba2221b0df136f8c6a1)) 213 | 214 | # Update v2.2 215 | 216 | - Stylized terminal output 217 | 218 | # Update v2.1 219 | 220 | - Improved exporting speed by using MultiThreading 221 | 222 | # v2.0 223 | 224 | - Notion Markdown Exporter(notion2md) now use **official notion api** by [notion-sdk-py](https://github.com/ramnes/notion-sdk-py) 225 | 226 | - Rewrite the structure of the program to use the api and improve the speed and usability of API 227 | 228 | ## v1.2.2.1 229 | 230 | - Supports **Inline Math Code** in the `text block`, `bulleted list`, and `numbered list`. It will Be denoted as `$$$$` 231 | 232 | - Supports Call `export_cli()` with `token_v2`, `url`, and `bmode` 233 | 234 | # v1.2.0 235 | 236 | - Now Supports Exporting the **inline table block** 237 | 238 | - Even the block that has its own page in the table will be exported as **subpage** 239 | 240 | - You can choose wheather you will export notion page as `a blog post` or not 241 | 242 | - Blog post format includes frontmatter and Date in Post's name. 243 | 244 | # v1.1.0 245 | 246 | - Change the output folder name `Notion_Exporter_Output/` to `notion_output/` 247 | 248 | - Save token_v2 in `notion_output/notion_token.json` and read it when you use the exporter again. 249 | 250 | - Fix the error that `block.icon` is not defined if the block has no icon in the title. 251 | 252 | # v1.0.0 253 | 254 | - Changed the structure of the exporter to support exporting sub pages and sub files. 255 | 256 | - Used Object named '`PageBlockExporter`' to connect supporting functions and attributes. 257 | 258 | - Each Exporter has its client(`NotionClient`), page(`notion.Block`), and sub pages' exporter list (`[PageBlockExporter]`) 259 | -------------------------------------------------------------------------------- /CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Notion2md Contribution Guide 2 | 3 | Thank you for having an interest in contributing **Notion2md** 4 | 5 | Here are some guides for you to how to contribute 6 | 7 | ## To-do 8 | 9 | - [x] Download file object(image and files) 10 | - [x] Table blocks 11 | - [ ] Page Exporter 12 | - [ ] Database Exporter 13 | - [ ] Child page 14 | - [ ] Column List and Column Blocks 15 | - [ ] Synced Block 16 | 17 | 18 | The list above is the features or blocks needed to be exported in notion2md. You may choose one of these to contribute, or you can also contribute to implementing new feature not in the list. 19 | 20 | ## How to make PR 21 | 22 | Pull requests are welcome. 23 | 1. fork this repo into yours 24 | 2. make changes and push to your repo 25 | 3. send pull request from your **develop** branch to this develop branch 26 | 27 | **This is only way to give pull request to this repo. Thank you** 28 | 29 | Please make sure that you do the following before submitting your code: 30 | 1. Run the tests: `poetry run python -m unittest discover tests` 31 | 2. Format the code `poetry run black .` 32 | 3. Use isort the code `poetry run isort .` 33 | 4. Lint the code `poetry run flake8 .` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 echo724 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Notion2md.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/echo724/notion2md/43047aba8dbf6cf71bc82d31b7c8b47331a6019e/Notion2md.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Notion2Md logo - an arrow pointing from "N" to "MD"](Notion2md.jpg) 2 | 3 |
4 | 5 | ## About Notion2Md 6 | 7 | [![Downloads](https://static.pepy.tech/badge/notion2md)](https://pepy.tech/project/notion2md) 8 | [![PyPI version](https://badge.fury.io/py/notion2md.svg)](https://badge.fury.io/py/notion2md) 9 | [![Code Quality](https://github.com/echo724/notion2md/actions/workflows/code_quality.yaml/badge.svg)](https://github.com/echo724/notion2md/actions/workflows/code_quality.yaml) 10 | 11 | 12 | - Notion Markdown Exporter using **official notion api** by [notion-sdk-py](https://github.com/ramnes/notion-sdk-py) 13 | 14 | ### Notion2Medium 15 | 16 | - Check out [Notion2Medium](https://github.com/echo724/notion2medium) that publishes a **Medium** post from **Notion** using Notion2Md. 17 | 18 | ## API Key(Token) 19 | 20 | - Before getting started, create [an integration and find the token](https://www.notion.so/my-integrations). → [Learn more about authorization](https://developers.notion.com/docs/authorization). 21 | 22 | - Then save your api key(token) as your os environment variable 23 | 24 | - From version 2.9.0, you can use `--token` or `-t` option to set your token key. 25 | 26 | ```Bash 27 | $ export NOTION_TOKEN="{your integration token key}" 28 | ``` 29 | 30 | ## Install 31 | 32 | ```Bash 33 | $ pip install notion2md 34 | ``` 35 | 36 | ## Usage: Shell Command 37 | 38 | ![Terminal output of the `notion2md -h` command](notion2md_options.png) 39 | 40 | - Notion2md requires either `id` or `url` of the Notion page/block. 41 | 42 | - **download** option will download files/images in the `path` directory. 43 | 44 | - **unzipped** option makes Notion2Md export ***unzipped*** output of Notion block. 45 | 46 | ```Bash 47 | notion2md --download -n post -p ~/MyBlog/content/posts -u https://notion.so/... 48 | ``` 49 | 50 | - This command will generate "**post.zip**" in your '**~/MyBlog/content/posts**' directory. 51 | 52 | ## Usage: Python 53 | 54 | ```Python 55 | from notion2md.exporter.block import MarkdownExporter, StringExporter 56 | 57 | # MarkdownExporter will make markdown file on your output path 58 | MarkdownExporter(block_id='...',output_path='...',download=True).export() 59 | 60 | # StringExporter will return output as String type 61 | md = StringExporter(block_id='...',output_path='...').export() 62 | ``` 63 | 64 | ## To-do 65 | 66 | - [x] Download file object(image and files) 67 | - [x] Table blocks 68 | - [x] Synced Block 69 | - [ ] Page Exporter 70 | - [ ] Child page 71 | - [ ] Column List and Column Blocks 72 | 73 | ## Contribution 74 | 75 | Please read [Contribution Guide](CONTRIBUTION.md) 76 | 77 | ## Donation 78 | 79 | If you think **Notion2Md** is helpful to you, you can support me here: 80 | 81 | Buy Me A Coffee 82 | 83 | ## License 84 | [MIT](https://choosealicense.com/licenses/mit/) 85 | -------------------------------------------------------------------------------- /notion2md/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.9.0" 2 | -------------------------------------------------------------------------------- /notion2md/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | sys.path.append("../notion2md") 5 | 6 | if __name__ == "__main__": 7 | from .console import application 8 | 9 | sys.exit(application.main()) 10 | -------------------------------------------------------------------------------- /notion2md/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from notion_client.helpers import get_id 4 | 5 | from notion2md.exceptions import MissingTargetIDError 6 | 7 | 8 | class Config(object): 9 | __slots__ = ( 10 | "file_name", 11 | "target_id", 12 | "output_path", 13 | "tmp_path", 14 | "download", 15 | "unzipped", 16 | "path_name", 17 | ) 18 | 19 | def __init__( 20 | self, 21 | block_id: str = None, 22 | block_url: str = None, 23 | output_filename: str = None, 24 | output_path: str = None, 25 | download: bool = False, 26 | unzipped: bool = False, 27 | ): 28 | if block_url: 29 | self.target_id = get_id(block_url) 30 | elif block_id: 31 | self.target_id = block_id 32 | else: 33 | raise MissingTargetIDError 34 | 35 | if output_filename: 36 | self.file_name = output_filename 37 | else: 38 | self.file_name = self.target_id 39 | 40 | if output_path: 41 | self.path_name = output_path 42 | self.output_path = os.path.abspath(output_path) 43 | 44 | else: 45 | self.path_name = "notion2md-output" 46 | self.output_path = os.path.join(os.getcwd(), "notion2md-output") 47 | 48 | if download: 49 | self.download = True 50 | else: 51 | self.download = False 52 | 53 | if unzipped: 54 | self.unzipped = True 55 | self.tmp_path = self.output_path 56 | else: 57 | self.unzipped = False 58 | self.tmp_path = os.path.join(os.getcwd(), "tmp") 59 | -------------------------------------------------------------------------------- /notion2md/console/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/echo724/notion2md/43047aba8dbf6cf71bc82d31b7c8b47331a6019e/notion2md/console/__init__.py -------------------------------------------------------------------------------- /notion2md/console/application.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import Optional 3 | 4 | from cleo.application import Application as BaseApplication 5 | from cleo.commands.command import Command 6 | from cleo.commands.completions_command import CompletionsCommand 7 | from cleo.commands.help_command import HelpCommand 8 | from cleo.formatters.style import Style 9 | from cleo.io.inputs.argument import Argument 10 | from cleo.io.inputs.definition import Definition 11 | from cleo.io.inputs.input import Input 12 | from cleo.io.inputs.option import Option 13 | from cleo.io.io import IO 14 | from cleo.io.outputs.output import Output 15 | 16 | from notion2md import __version__ 17 | 18 | from .commands.export_block import ExportBlockCommand 19 | 20 | 21 | class Application(BaseApplication): 22 | def __init__(self): 23 | super(Application, self).__init__("notion2md", __version__) 24 | self._default_command = "block" 25 | self._single_command = True 26 | 27 | @property 28 | def default_commands(self) -> List[Command]: 29 | return [HelpCommand(), ExportBlockCommand(), CompletionsCommand()] 30 | 31 | def create_io( 32 | self, 33 | input: Optional[Input] = None, 34 | output: Optional[Output] = None, 35 | error_output: Optional[Output] = None, 36 | ) -> IO: 37 | io = super().create_io(input, output, error_output) 38 | 39 | formatter = io.output.formatter 40 | formatter.set_style("status", Style("green", options=["bold", "dark"])) 41 | formatter.set_style("success", Style("green", options=["bold"])) 42 | formatter.set_style("error", Style("red", options=["bold"])) 43 | formatter.set_style("code", Style("green", options=["italic"])) 44 | formatter.set_style("highlight", Style("blue", options=["bold"])) 45 | formatter.set_style("dim", Style("default", options=["dark"])) 46 | 47 | io.output.set_formatter(formatter) 48 | io.error_output.set_formatter(formatter) 49 | 50 | return io 51 | 52 | @property 53 | def _default_definition(self) -> Definition: 54 | return Definition( 55 | [ 56 | Argument( 57 | "command", 58 | required=True, 59 | description="The command to execute.", 60 | ), 61 | Option( 62 | "--help", 63 | "-h", 64 | flag=True, 65 | description=( 66 | "Display help for the given command. " 67 | f"When no command is given display help for the {self._default_command} command." 68 | ), 69 | ), 70 | Option( 71 | "--version", 72 | "-V", 73 | flag=True, 74 | description="Display this application version.", 75 | ), 76 | ] 77 | ) 78 | 79 | 80 | def main(): 81 | Application().run() 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /notion2md/console/commands/export_block.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | from cleo.commands.command import Command 5 | from cleo.helpers import option 6 | 7 | from notion2md.console.formatter import error 8 | from notion2md.console.formatter import status 9 | from notion2md.console.formatter import success 10 | from notion2md.console.ui.indicator import progress 11 | from notion2md.exporter.block import CLIExporter 12 | 13 | ARGS_NEW_KEY_MAP = { 14 | "id": "block_id", 15 | "url": "block_url", 16 | "name": "output_filename", 17 | "path": "output_path", 18 | "download": "download", 19 | "unzipped": "unzipped", 20 | "token": "token", 21 | } 22 | 23 | 24 | class ExportBlockCommand(Command): 25 | name = "block" 26 | description = "Export a Notion block object to markdown." 27 | 28 | options = [ 29 | option("token", "t", "The token of your Notion account.", flag=False), 30 | option("url", "u", "The url of Notion block object.", flag=False), 31 | option("id", "i", "The id of Notion block object.", flag=False), 32 | option("name", "n", "The name of Notion block object", flag=False), 33 | option( 34 | "path", "p", "The path to save exported markdown file.", flag=False 35 | ), 36 | option( 37 | "download", 38 | None, 39 | "Download files/images inside of the block object", 40 | flag=True, 41 | ), 42 | option( 43 | "unzipped", 44 | None, 45 | "Download unzipped output files/images", 46 | flag=True, 47 | ), 48 | ] 49 | help = """The block command retrieves and exports Notion block object to markdownfile. 50 | 51 | - By default, the id of the block will be the name of markdown file, 52 | 53 | but if you path a value after --name/-n, the value will be the name of the markdown file. 54 | 55 | 56 | - It will save the exported zip file in the path("notion-output/" or specific path you entered) 57 | 58 | - By default, it won't save files/images in the block object, 59 | 60 | but if you pass --download option, it will download files/images in the path("notion-output/" 61 | 62 | or specific path you entered) 63 | 64 | 65 | - By default, it will make a zip file in the output path, but if you want unzipped files, pass --unzipped option. 66 | """ 67 | 68 | def status(self, st, msg): 69 | self.line(status(st, msg)) 70 | 71 | def success(self, st, msg): 72 | self.line(success(st, msg)) 73 | 74 | def error(self, msg): 75 | self.line_error(error(msg)) 76 | sys.exit(1) 77 | 78 | def _parse_args(self): 79 | args = {} 80 | for k, v in self.io.input.options.items(): 81 | if k in ARGS_NEW_KEY_MAP: 82 | args[ARGS_NEW_KEY_MAP[k]] = v 83 | else: 84 | pass 85 | return args 86 | 87 | def handle(self): 88 | try: 89 | args = self._parse_args() 90 | exporter = CLIExporter(**args) 91 | exporter.io = self.io 92 | exporter.create_directories() 93 | # Get actual blocks 94 | self.line("") 95 | with progress( 96 | self.io, 97 | status("Retrieving", "Notion blocks..."), 98 | success("Retrieved", "Notion blocks..."), 99 | ): 100 | blocks = exporter.get_blocks() 101 | # Write(Export) Markdown file 102 | start_time = time.time() 103 | self.success( 104 | "Converting", f"{str(len(blocks))} blocks..." 105 | ) 106 | exporter.export(blocks) 107 | self.success( 108 | "Converted", 109 | f"{str(len(blocks))} blocks to Markdown{f' ({time.time() - start_time:0.1f}s)' if not self.io.is_debug() else ''}", 110 | ) 111 | 112 | # Compress Output files into a zip file 113 | if not exporter.config.unzipped: 114 | exporter.make_zip() 115 | extension = ".zip" 116 | else: 117 | extension = ".md" 118 | # Result and Time Check 119 | self.success( 120 | "Exported", 121 | f'"{exporter.config.file_name}{extension}" in "./{exporter.config.path_name}/"', 122 | ) 123 | self.line("") 124 | 125 | except Exception as e: 126 | self.error(e) 127 | -------------------------------------------------------------------------------- /notion2md/console/formatter.py: -------------------------------------------------------------------------------- 1 | def error(msg): 2 | return f"\n{'ERROR'+' ':>12}{msg}\n" 3 | 4 | 5 | def status(status, msg): 6 | return f"{status+' ':>12}{msg}" 7 | 8 | 9 | def success(status, msg): 10 | return f"{status+' ':>12}{msg}" 11 | -------------------------------------------------------------------------------- /notion2md/console/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/echo724/notion2md/43047aba8dbf6cf71bc82d31b7c8b47331a6019e/notion2md/console/ui/__init__.py -------------------------------------------------------------------------------- /notion2md/console/ui/indicator.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from contextlib import contextmanager 4 | from typing import Iterator 5 | 6 | from cleo.ui.progress_indicator import ProgressIndicator 7 | 8 | from notion2md.console.formatter import status 9 | 10 | 11 | class Indicator(ProgressIndicator): 12 | def _formatter_elapsed(self): 13 | elapsed = time.time() - self._start_time 14 | return f"{elapsed:0.1f}s" 15 | 16 | 17 | @contextmanager 18 | def progress(io, stmsg, fnmsg) -> Iterator[None]: 19 | if io.is_debug(): 20 | io.write_line(status("Retrieved", "Notion blocks...")) 21 | yield 22 | else: 23 | indicator = Indicator(io, fmt="{message} ({elapsed:2s})") 24 | with indicator.auto( 25 | stmsg, 26 | fnmsg, 27 | ): 28 | yield 29 | -------------------------------------------------------------------------------- /notion2md/convertor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/echo724/notion2md/43047aba8dbf6cf71bc82d31b7c8b47331a6019e/notion2md/convertor/__init__.py -------------------------------------------------------------------------------- /notion2md/convertor/block.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import hashlib 3 | import os 4 | import urllib.request as request 5 | from urllib.parse import urlparse,unquote 6 | 7 | from cleo.io.io import IO 8 | 9 | from notion2md.config import Config 10 | from notion2md.console.formatter import error 11 | from notion2md.console.formatter import status 12 | from notion2md.console.formatter import success 13 | from notion2md.notion_api import NotionClient 14 | from .richtext import richtext_convertor 15 | 16 | 17 | class BlockConvertor: 18 | def __init__(self, config: Config, client: NotionClient, io: IO = None): 19 | self._config = config 20 | self._client = client 21 | self._io = io 22 | 23 | def convert(self, blocks: dict) -> str: 24 | outcome_blocks: str = "" 25 | with concurrent.futures.ThreadPoolExecutor() as executor: 26 | results = executor.map(self.convert_block, blocks) 27 | outcome_blocks = "".join([result for result in results]) 28 | return outcome_blocks 29 | 30 | def convert_block( 31 | self, 32 | block: dict, 33 | depth=0, 34 | ): 35 | outcome_block: str = "" 36 | block_type = block["type"] 37 | # Special Case: Block is blank 38 | if check_block_is_blank(block, block_type): 39 | return blank() + "\n\n" 40 | # Normal Case 41 | try: 42 | if block_type in BLOCK_TYPES: 43 | outcome_block = ( 44 | BLOCK_TYPES[block_type]( 45 | self.collect_info(block[block_type]) 46 | ) 47 | + "\n\n" 48 | ) 49 | else: 50 | outcome_block = f"[//]: # ({block_type} is not supported)\n\n" 51 | # Convert child block 52 | if block["has_children"]: 53 | # create child page 54 | if block_type == "child_page": 55 | # call make_child_function 56 | pass 57 | # create table block 58 | elif block_type == "table": 59 | depth += 1 60 | child_blocks = self._client.get_children(block["id"]) 61 | outcome_block = self.create_table(cell_blocks=child_blocks) 62 | # create indent block 63 | else: 64 | depth += 1 65 | child_blocks = self._client.get_children(block["id"]) 66 | for block in child_blocks: 67 | converted_block = self.convert_block( 68 | block, 69 | depth, 70 | ) 71 | outcome_block += "\t" * depth + converted_block 72 | except Exception as e: 73 | if self._io: 74 | self._io.write_line( 75 | error(f"{e}: Error occured block_type:{block_type}") 76 | ) 77 | return outcome_block 78 | 79 | def create_table(self, cell_blocks: dict): 80 | table_list = [] 81 | for cell_block in cell_blocks: 82 | cell_block_type = cell_block["type"] 83 | table_list.append( 84 | BLOCK_TYPES[cell_block_type]( 85 | self.collect_info(cell_block[cell_block_type]) 86 | ) 87 | ) 88 | # convert to markdown table 89 | for index, value in enumerate(table_list): 90 | if index == 0: 91 | table = " | " + " | ".join(value) + " | " + "\n" 92 | table += ( 93 | " | " + " | ".join(["----"] * len(value)) + " | " + "\n" 94 | ) 95 | continue 96 | table += " | " + " | ".join(value) + " | " + "\n" 97 | table += "\n" 98 | return table 99 | 100 | def collect_info(self, payload: dict) -> dict: 101 | info = dict() 102 | if "rich_text" in payload: 103 | info["text"] = richtext_convertor(payload["rich_text"]) 104 | if "icon" in payload: 105 | info["icon"] = payload["icon"]["emoji"] 106 | if "checked" in payload: 107 | info["checked"] = payload["checked"] 108 | if "expression" in payload: 109 | info["text"] = payload["expression"] 110 | if "url" in payload: 111 | info["url"] = payload["url"] 112 | if "caption" in payload: 113 | info["caption"] = richtext_convertor(payload["caption"]) 114 | if "external" in payload: 115 | info["url"] = payload["external"]["url"] 116 | name, file_path = self.download_file(info["url"]) 117 | info["file_name"] = name 118 | info["file_path"] = file_path 119 | if "language" in payload: 120 | info["language"] = payload["language"] 121 | # interal url 122 | if "file" in payload: 123 | info["url"] = payload["file"]["url"] 124 | name, file_path = self.download_file(info["url"]) 125 | info["file_name"] = name 126 | info["file_path"] = file_path 127 | # table cells 128 | if "cells" in payload: 129 | info["cells"] = payload["cells"] 130 | return info 131 | 132 | def download_file(self, url: str) -> tuple[str, str]: 133 | file_name = os.path.basename(urlparse(url).path) 134 | unquoted_file_name = unquote(file_name) 135 | if self._config.download: 136 | if unquoted_file_name: 137 | name, extension = os.path.splitext(unquoted_file_name) 138 | 139 | if not extension: 140 | return unquoted_file_name, url 141 | 142 | url_hash = hashlib.blake2s( 143 | urlparse(url).path.encode() 144 | ).hexdigest()[:8] 145 | downloaded_file_name = f"{url_hash}_{unquoted_file_name}" 146 | 147 | fullpath = os.path.join( 148 | self._config.tmp_path, downloaded_file_name 149 | ) 150 | 151 | if self._io: 152 | self._io.write_line(status("Downloading", f"{unquoted_file_name}")) 153 | request.urlretrieve(url, fullpath) 154 | self._io.write_line( 155 | success( 156 | "Downloaded", 157 | f'"{unquoted_file_name}" -> "{downloaded_file_name}"', 158 | ) 159 | ) 160 | else: 161 | request.urlretrieve(url, fullpath) 162 | return name, downloaded_file_name 163 | else: 164 | if self._io: 165 | self._io.write_line(error(f"invalid {url}")) 166 | else: 167 | return unquoted_file_name, url 168 | 169 | def to_string(self, blocks: dict) -> str: 170 | return self.convert(blocks) 171 | 172 | 173 | def check_block_is_blank(block, block_type): 174 | return ( 175 | block_type == "paragraph" 176 | and not block["has_children"] 177 | and not block[block_type]["rich_text"] 178 | ) 179 | 180 | 181 | # Converting Methods 182 | def paragraph(info: dict) -> str: 183 | return info["text"] 184 | 185 | 186 | def heading_1(info: dict) -> str: 187 | return f"# {info['text']}" 188 | 189 | 190 | def heading_2(info: dict) -> str: 191 | return f"## {info['text']}" 192 | 193 | 194 | def heading_3(info: dict) -> str: 195 | return f"### {info['text']}" 196 | 197 | 198 | def callout(info: dict) -> str: 199 | return f"{info['icon']} {info['text']}" 200 | 201 | 202 | def quote(info: dict) -> str: 203 | return f"> {info['text']}" 204 | 205 | 206 | # toggle item will be changed as bulleted list item 207 | def bulleted_list_item(info: dict) -> str: 208 | return f"- {info['text']}" 209 | 210 | 211 | # numbering is not supported 212 | def numbered_list_item(info: dict) -> str: 213 | """ 214 | input: item:dict = {"number":int, "text":str} 215 | """ 216 | return f"1. {info['text']}" 217 | 218 | 219 | def to_do(info: dict) -> str: 220 | """ 221 | input: item:dict = {"checked":bool, "test":str} 222 | """ 223 | return f"- {'[x]' if info['checked'] else '[ ]'} {info['text']}" 224 | 225 | 226 | # not yet supported 227 | # child_database will be changed as child page 228 | # def child_page(info:dict) -> str: 229 | # """ 230 | # input: item:dict = {"id":str,"text":str} 231 | # """ 232 | # #make_page(info['id']) 233 | # text = info['text'] 234 | # return f'[{text}]({text})' 235 | 236 | 237 | def code(info: dict) -> str: 238 | """ 239 | input: item:dict = {"language":str,"text":str} 240 | """ 241 | return f"\n```{info['language']}\n{info['text']}\n```" 242 | 243 | 244 | def embed(info: dict) -> str: 245 | """ 246 | input: item:dict ={"url":str,"text":str} 247 | """ 248 | return f"[{info['url']}]({info['url']})" 249 | 250 | 251 | def image(info: dict) -> str: 252 | """ 253 | input: item:dict ={"url":str,"text":str,"caption":str} 254 | """ 255 | # name,file_path = downloader(info['url']) 256 | 257 | if info["caption"]: 258 | return ( 259 | f"![{info['file_name']}]({info['file_path']})\n\n{info['caption']}" 260 | ) 261 | else: 262 | return f"![{info['file_name']}]({info['file_path']})" 263 | 264 | 265 | def file(info: dict) -> str: 266 | # name,file_path = downloader(info['url']) 267 | return f"[{info['file_name']}]({info['file_path']})" 268 | 269 | 270 | def bookmark(info: dict) -> str: 271 | """ 272 | input: item:dict ={"url":str,"text":str,"caption":str} 273 | """ 274 | if info["caption"]: 275 | return f"[{info['url']}]({info['url']})\n\n{info['caption']}" 276 | else: 277 | return f"[{info['url']}]({info['url']})" 278 | 279 | 280 | def equation(info: dict) -> str: 281 | return f"$$ {info['text']} $$" 282 | 283 | 284 | def divider(info: dict) -> str: 285 | return "---" 286 | 287 | 288 | def blank() -> str: 289 | return "
" 290 | 291 | 292 | def table_row(info: list) -> list: 293 | """ 294 | input: item:list = [[richtext],....] 295 | """ 296 | column_list = [] 297 | for column in info["cells"]: 298 | column_list.append(richtext_convertor(column)) 299 | return column_list 300 | 301 | 302 | # Since Synced Block has only child blocks, not name, it will return blank 303 | def synced_block(info: list) -> str: 304 | return "[//]: # (Synced Block)" 305 | 306 | 307 | # Block type map 308 | BLOCK_TYPES = { 309 | "paragraph": paragraph, 310 | "heading_1": heading_1, 311 | "heading_2": heading_2, 312 | "heading_3": heading_3, 313 | "callout": callout, 314 | "toggle": bulleted_list_item, 315 | "quote": quote, 316 | "bulleted_list_item": bulleted_list_item, 317 | "numbered_list_item": numbered_list_item, 318 | "to_do": to_do, 319 | # "child_page": child_page, 320 | "code": code, 321 | "embed": embed, 322 | "image": image, 323 | "bookmark": bookmark, 324 | "equation": equation, 325 | "divider": divider, 326 | "file": file, 327 | "table_row": table_row, 328 | "synced_block": synced_block, 329 | } 330 | -------------------------------------------------------------------------------- /notion2md/convertor/richtext.py: -------------------------------------------------------------------------------- 1 | # Link 2 | def text_link(item: dict): 3 | """ 4 | input: item:dict ={"content":str,"link":str} 5 | """ 6 | return f"[{item['content']}]({item['link']['url']})" 7 | 8 | 9 | # Annotations 10 | def bold(content: str): 11 | return f"**{content}**" 12 | 13 | 14 | def italic(content: str): 15 | return f"*{content}*" 16 | 17 | 18 | def strikethrough(content: str): 19 | return f"~~{content}~~" 20 | 21 | 22 | def underline(content: str): 23 | return f"{content}" 24 | 25 | 26 | def code(content: str): 27 | return f"`{content}`" 28 | 29 | 30 | def color(content: str, color): 31 | return f"{content}" 32 | 33 | 34 | def equation(content: str): 35 | return f"$ {content} $" 36 | 37 | 38 | annotation_map = { 39 | "bold": bold, 40 | "italic": italic, 41 | "strikethrough": strikethrough, 42 | "underline": underline, 43 | "code": code, 44 | } 45 | 46 | 47 | # Mentions 48 | def _mention_link(content, url): 49 | return f"([{content}]({url}])" 50 | 51 | 52 | def user(information: dict): 53 | return f"({information['content']})" 54 | 55 | 56 | def page(information: dict): 57 | return _mention_link(information["content"], information["url"]) 58 | 59 | 60 | def date(information: dict): 61 | return f"({information['content']})" 62 | 63 | 64 | def database(information: dict): 65 | return _mention_link(information["content"], information["url"]) 66 | 67 | 68 | def mention_information(payload: dict): 69 | information = dict() 70 | if payload["href"]: 71 | information["url"] = payload["href"] 72 | if payload["plain_text"] != "Untitled": 73 | information["content"] = payload["plain_text"] 74 | else: 75 | information["content"] = payload["href"] 76 | else: 77 | information["content"] = payload["plain_text"] 78 | 79 | return information 80 | 81 | 82 | mention_map = {"user": user, "page": page, "database": database, "date": date} 83 | 84 | 85 | def richtext_word_converter(richtext: dict) -> str: 86 | outcome_word = "" 87 | plain_text = richtext["plain_text"] 88 | if richtext["type"] == "equation": 89 | outcome_word = equation(plain_text) 90 | elif richtext["type"] == "mention": 91 | mention_type = richtext["mention"]["type"] 92 | if mention_type in mention_map: 93 | outcome_word = mention_map[mention_type]( 94 | mention_information(richtext) 95 | ) 96 | else: 97 | if richtext["href"]: 98 | outcome_word = text_link(richtext["text"]) 99 | else: 100 | outcome_word = plain_text 101 | annot = richtext["annotations"] 102 | for key, transfer in annotation_map.items(): 103 | if richtext["annotations"][key]: 104 | outcome_word = transfer(outcome_word) 105 | if annot["color"] != "default": 106 | outcome_word = color(outcome_word, annot["color"]) 107 | return outcome_word 108 | 109 | 110 | def richtext_convertor(richtext_list: list) -> str: 111 | outcome_sentence = "" 112 | for richtext in richtext_list: 113 | outcome_sentence += richtext_word_converter(richtext) 114 | return outcome_sentence 115 | -------------------------------------------------------------------------------- /notion2md/exceptions.py: -------------------------------------------------------------------------------- 1 | class MissingTokenError(Exception): 2 | """Exception for missing notion token error. 3 | 4 | Notion2Md requires Notion Integration token key to export Notion page to Markdown. 5 | The token should be saved as envronmental variable. If it is not, this exception 6 | is raised. 7 | """ 8 | 9 | def __init__(self) -> None: 10 | super().__init__("Envrionment Variable 'NOTION_TOKEN' is not found") 11 | 12 | 13 | class MissingTargetIDError(Exception): 14 | """Exception for missing Notion page's id error. 15 | 16 | Notion2Md requires url or id of Notion's page. This exception is raised if there 17 | is no target id from user input. 18 | """ 19 | 20 | def __init__(self) -> None: 21 | super().__init__("Notion Page's id or url is not given as argument") 22 | -------------------------------------------------------------------------------- /notion2md/exporter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/echo724/notion2md/43047aba8dbf6cf71bc82d31b7c8b47331a6019e/notion2md/exporter/__init__.py -------------------------------------------------------------------------------- /notion2md/exporter/block.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from notion2md.config import Config 5 | from notion2md.convertor.block import BlockConvertor 6 | from notion2md.notion_api import NotionClient 7 | from notion2md.util import zip_dir 8 | 9 | 10 | class Exporter: 11 | def __init__( 12 | self, 13 | block_id: str = None, 14 | block_url: str = None, 15 | output_filename: str = None, 16 | output_path: str = None, 17 | download: bool = False, 18 | unzipped: bool = False, 19 | token: str = None, 20 | ): 21 | self._config = Config( 22 | block_id=block_id, 23 | block_url=block_url, 24 | output_filename=output_filename, 25 | output_path=output_path, 26 | download=download, 27 | unzipped=unzipped, 28 | ) 29 | self._client = NotionClient(token) 30 | self._io = None 31 | self._block_convertor = None 32 | 33 | @property 34 | def block_convertor(self): 35 | if not self._block_convertor: 36 | self._block_convertor = BlockConvertor( 37 | self._config, self._client, self._io 38 | ) 39 | return self._block_convertor 40 | 41 | @property 42 | def config(self): 43 | return self._config 44 | 45 | @property 46 | def io(self): 47 | return self._io 48 | 49 | @io.setter 50 | def io(self, io): 51 | self._io = io 52 | 53 | def create_directories(self): 54 | if not os.path.exists(self._config.tmp_path): 55 | os.makedirs(self._config.tmp_path) 56 | if not os.path.exists(self._config.output_path): 57 | os.mkdir(self._config.output_path) 58 | 59 | def get_blocks(self): 60 | return self._client.get_children(self._config.target_id) 61 | 62 | def make_zip(self): 63 | zip_dir( 64 | os.path.join(self._config.output_path, self._config.file_name) 65 | + ".zip", 66 | self._config.tmp_path, 67 | ) 68 | shutil.rmtree(self._config.tmp_path) 69 | 70 | def export(self): 71 | pass 72 | 73 | 74 | class MarkdownExporter(Exporter): 75 | def export(self): 76 | self.create_directories() 77 | with open( 78 | os.path.join( 79 | self._config.tmp_path, self._config.file_name + ".md" 80 | ), 81 | "w", 82 | encoding="utf-8", 83 | ) as output: 84 | output.write(self.block_convertor.convert(self.get_blocks())) 85 | if not self._config.unzipped: 86 | self.make_zip() 87 | 88 | 89 | class StringExporter(Exporter): 90 | def export(self): 91 | return self.block_convertor.to_string(self.get_blocks()) 92 | 93 | 94 | class CLIExporter(Exporter): 95 | def export(self, blocks): 96 | with open( 97 | os.path.join( 98 | self._config.tmp_path, self._config.file_name + ".md" 99 | ), 100 | "w", 101 | encoding="utf-8", 102 | ) as output: 103 | output.write(self.block_convertor.convert(blocks)) 104 | -------------------------------------------------------------------------------- /notion2md/notion_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from notion_client import Client 4 | 5 | from notion2md.exceptions import MissingTokenError 6 | 7 | 8 | def singleton(cls): 9 | instance = {} 10 | 11 | def get_instance(token=""): 12 | if cls not in instance: 13 | instance[cls] = cls(token) 14 | return instance[cls] 15 | 16 | return get_instance 17 | 18 | 19 | @singleton 20 | class NotionClient: 21 | def __init__(self, token=""): 22 | if not token: 23 | token = self._get_env_variable() 24 | self._client = Client(auth=token) 25 | 26 | def _get_env_variable(self): 27 | try: 28 | return os.environ["NOTION_TOKEN"] 29 | except Exception: 30 | raise MissingTokenError() from None 31 | 32 | def get_children(self, parent_id): 33 | # Most pages are small 34 | results = [] 35 | start_cursor = None 36 | # Avoid infinite loops 37 | for _ in range(100): 38 | resp = self._client.blocks.children.list( 39 | parent_id, start_cursor=start_cursor, page_size=100 40 | ) 41 | results.extend(resp["results"]) 42 | start_cursor = resp["next_cursor"] if resp["has_more"] else None 43 | if start_cursor is None: 44 | return results 45 | raise Exception( 46 | "Can't parse notion page of > 10,000 children! (e.g. blocks)" 47 | ) 48 | -------------------------------------------------------------------------------- /notion2md/util.py: -------------------------------------------------------------------------------- 1 | from os import PathLike 2 | from pathlib import Path 3 | from typing import Union 4 | from zipfile import ZIP_DEFLATED 5 | from zipfile import ZipFile 6 | 7 | 8 | def zip_dir(zip_name: str, source_dir: Union[str, PathLike]): 9 | src_path = Path(source_dir).expanduser().resolve(strict=True) 10 | with ZipFile(zip_name, "w", ZIP_DEFLATED) as zf: 11 | for file in src_path.rglob("*"): 12 | zf.write(file, file.relative_to(src_path)) 13 | -------------------------------------------------------------------------------- /notion2md_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/echo724/notion2md/43047aba8dbf6cf71bc82d31b7c8b47331a6019e/notion2md_options.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool] 2 | [tool.poetry] 3 | name = "notion2md" 4 | version = "2.9.0" 5 | description = "Notion Markdown Exporter with Python Cli" 6 | license = "MIT" 7 | classifiers = ["License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.9"] 8 | homepage = "https://github.com/echo724/notion2md.git" 9 | authors = ["echo724 "] 10 | readme = "README.md" 11 | exclude = ["Test/test_*"] 12 | 13 | [tool.poetry.dependencies] 14 | python = ">=3.7, <4" 15 | notion-client = ">=1.0.0" 16 | cleo = ">=1.0.0a4" 17 | 18 | [tool.poetry.scripts] 19 | notion2md = 'notion2md.console.application:main' 20 | 21 | [tool.poetry.dev-dependencies] 22 | python-semantic-release = ">=7.23.0" 23 | pre-commit = ">=2.17.0" 24 | isort = ">=5.10.1" 25 | black = ">=22.1.0" 26 | flake8 = ">=4.0.1" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | black = "^23.1.0" 30 | 31 | [tool.isort] 32 | profile = "black" 33 | force_single_line = true 34 | atomic = true 35 | include_trailing_comma = true 36 | lines_after_imports = 2 37 | lines_between_types = 1 38 | use_parentheses = true 39 | src_paths = ["notion2md", "tests"] 40 | filter_files = true 41 | known_first_party = "notion2md" 42 | 43 | [tool.black] 44 | line-length = 79 45 | 46 | [tool.semantic_release] 47 | version_variable = [ 48 | "notion2md/__init__.py:__version__", 49 | "pyproject.toml:version" 50 | ] 51 | branch = "main" 52 | upload_to_pypi = true 53 | upload_to_release = true 54 | build_command = "poetry build" 55 | -------------------------------------------------------------------------------- /tests/test_numbered_list.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock 3 | from unittest.mock import patch 4 | 5 | from notion2md.exporter.block import StringExporter # type: ignore 6 | 7 | expected_md = """1. Thing1: 8 | 9 | \t1. Thing2: 10 | 11 | \t\t1. one 12 | 13 | \t\t1. two 14 | 15 |
16 | 17 | Stuff 18 | 19 |
20 | 21 | 1. Schema 22 | 23 | \t1. id (integer) 24 | 25 | \t1. type (string) 26 | 27 | """ 28 | 29 | mock_responses = [ 30 | { 31 | "object": "list", 32 | "results": [ 33 | { 34 | "object": "block", 35 | "id": "94641568-819d-4a12-a313-c64a3d176cf5", 36 | "parent": { 37 | "type": "page_id", 38 | "page_id": "811969e6-f4f5-4e27-a6ac-2b58c2f22e26", 39 | }, 40 | "created_time": "2023-09-02T22:46:00.000Z", 41 | "last_edited_time": "2023-09-02T22:48:00.000Z", 42 | "created_by": { 43 | "object": "user", 44 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 45 | }, 46 | "last_edited_by": { 47 | "object": "user", 48 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 49 | }, 50 | "has_children": True, 51 | "archived": False, 52 | "type": "numbered_list_item", 53 | "numbered_list_item": { 54 | "rich_text": [ 55 | { 56 | "type": "text", 57 | "text": {"content": "Thing1:", "link": None}, 58 | "annotations": { 59 | "bold": False, 60 | "italic": False, 61 | "strikethrough": False, 62 | "underline": False, 63 | "code": False, 64 | "color": "default", 65 | }, 66 | "plain_text": "Thing1:", 67 | "href": None, 68 | } 69 | ], 70 | "color": "default", 71 | }, 72 | }, 73 | { 74 | "object": "block", 75 | "id": "8138f3ff-8fb8-4fb6-8692-e69f7b2a64a5", 76 | "parent": { 77 | "type": "page_id", 78 | "page_id": "811969e6-f4f5-4e27-a6ac-2b58c2f22e26", 79 | }, 80 | "created_time": "2023-09-02T22:48:00.000Z", 81 | "last_edited_time": "2023-09-02T22:48:00.000Z", 82 | "created_by": { 83 | "object": "user", 84 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 85 | }, 86 | "last_edited_by": { 87 | "object": "user", 88 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 89 | }, 90 | "has_children": False, 91 | "archived": False, 92 | "type": "paragraph", 93 | "paragraph": {"rich_text": [], "color": "default"}, 94 | }, 95 | { 96 | "object": "block", 97 | "id": "36e6d74c-0069-4089-a0db-3979467a15f6", 98 | "parent": { 99 | "type": "page_id", 100 | "page_id": "811969e6-f4f5-4e27-a6ac-2b58c2f22e26", 101 | }, 102 | "created_time": "2023-09-02T22:48:00.000Z", 103 | "last_edited_time": "2023-09-02T22:48:00.000Z", 104 | "created_by": { 105 | "object": "user", 106 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 107 | }, 108 | "last_edited_by": { 109 | "object": "user", 110 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 111 | }, 112 | "has_children": False, 113 | "archived": False, 114 | "type": "paragraph", 115 | "paragraph": { 116 | "rich_text": [ 117 | { 118 | "type": "text", 119 | "text": {"content": "Stuff", "link": None}, 120 | "annotations": { 121 | "bold": False, 122 | "italic": False, 123 | "strikethrough": False, 124 | "underline": False, 125 | "code": False, 126 | "color": "default", 127 | }, 128 | "plain_text": "Stuff", 129 | "href": None, 130 | } 131 | ], 132 | "color": "default", 133 | }, 134 | }, 135 | { 136 | "object": "block", 137 | "id": "994496db-727e-42b0-bf7c-a346ad5070b8", 138 | "parent": { 139 | "type": "page_id", 140 | "page_id": "811969e6-f4f5-4e27-a6ac-2b58c2f22e26", 141 | }, 142 | "created_time": "2023-09-02T22:46:00.000Z", 143 | "last_edited_time": "2023-09-02T22:48:00.000Z", 144 | "created_by": { 145 | "object": "user", 146 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 147 | }, 148 | "last_edited_by": { 149 | "object": "user", 150 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 151 | }, 152 | "has_children": False, 153 | "archived": False, 154 | "type": "paragraph", 155 | "paragraph": {"rich_text": [], "color": "default"}, 156 | }, 157 | { 158 | "object": "block", 159 | "id": "e6bb8ac2-7af3-4cbe-af41-e7ac75fe6231", 160 | "parent": { 161 | "type": "page_id", 162 | "page_id": "811969e6-f4f5-4e27-a6ac-2b58c2f22e26", 163 | }, 164 | "created_time": "2023-09-02T22:46:00.000Z", 165 | "last_edited_time": "2023-09-02T22:48:00.000Z", 166 | "created_by": { 167 | "object": "user", 168 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 169 | }, 170 | "last_edited_by": { 171 | "object": "user", 172 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 173 | }, 174 | "has_children": True, 175 | "archived": False, 176 | "type": "numbered_list_item", 177 | "numbered_list_item": { 178 | "rich_text": [ 179 | { 180 | "type": "text", 181 | "text": {"content": "Schema", "link": None}, 182 | "annotations": { 183 | "bold": False, 184 | "italic": False, 185 | "strikethrough": False, 186 | "underline": False, 187 | "code": False, 188 | "color": "default", 189 | }, 190 | "plain_text": "Schema", 191 | "href": None, 192 | } 193 | ], 194 | "color": "default", 195 | }, 196 | }, 197 | ], 198 | "next_cursor": None, 199 | "has_more": False, 200 | "type": "block", 201 | "block": {}, 202 | }, 203 | { 204 | "object": "list", 205 | "results": [ 206 | { 207 | "object": "block", 208 | "id": "a86e3565-da49-47ba-9e96-2c22b426f66b", 209 | "parent": { 210 | "type": "block_id", 211 | "block_id": "94641568-819d-4a12-a313-c64a3d176cf5", 212 | }, 213 | "created_time": "2023-09-02T22:46:00.000Z", 214 | "last_edited_time": "2023-09-02T22:48:00.000Z", 215 | "created_by": { 216 | "object": "user", 217 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 218 | }, 219 | "last_edited_by": { 220 | "object": "user", 221 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 222 | }, 223 | "has_children": True, 224 | "archived": False, 225 | "type": "numbered_list_item", 226 | "numbered_list_item": { 227 | "rich_text": [ 228 | { 229 | "type": "text", 230 | "text": {"content": "Thing2:", "link": None}, 231 | "annotations": { 232 | "bold": False, 233 | "italic": False, 234 | "strikethrough": False, 235 | "underline": False, 236 | "code": False, 237 | "color": "default", 238 | }, 239 | "plain_text": "Thing2:", 240 | "href": None, 241 | } 242 | ], 243 | "color": "default", 244 | }, 245 | } 246 | ], 247 | "next_cursor": None, 248 | "has_more": False, 249 | "type": "block", 250 | "block": {}, 251 | }, 252 | { 253 | "object": "list", 254 | "results": [ 255 | { 256 | "object": "block", 257 | "id": "da029b1b-f4ae-40d5-95dd-c0a869f8196d", 258 | "parent": { 259 | "type": "block_id", 260 | "block_id": "a86e3565-da49-47ba-9e96-2c22b426f66b", 261 | }, 262 | "created_time": "2023-09-02T22:46:00.000Z", 263 | "last_edited_time": "2023-09-02T22:46:00.000Z", 264 | "created_by": { 265 | "object": "user", 266 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 267 | }, 268 | "last_edited_by": { 269 | "object": "user", 270 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 271 | }, 272 | "has_children": False, 273 | "archived": False, 274 | "type": "numbered_list_item", 275 | "numbered_list_item": { 276 | "rich_text": [ 277 | { 278 | "type": "text", 279 | "text": {"content": "one", "link": None}, 280 | "annotations": { 281 | "bold": False, 282 | "italic": False, 283 | "strikethrough": False, 284 | "underline": False, 285 | "code": False, 286 | "color": "default", 287 | }, 288 | "plain_text": "one", 289 | "href": None, 290 | } 291 | ], 292 | "color": "default", 293 | }, 294 | }, 295 | { 296 | "object": "block", 297 | "id": "1a490359-e05a-4b3e-b39e-0f2eb867b527", 298 | "parent": { 299 | "type": "block_id", 300 | "block_id": "a86e3565-da49-47ba-9e96-2c22b426f66b", 301 | }, 302 | "created_time": "2023-09-02T22:46:00.000Z", 303 | "last_edited_time": "2023-09-02T22:46:00.000Z", 304 | "created_by": { 305 | "object": "user", 306 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 307 | }, 308 | "last_edited_by": { 309 | "object": "user", 310 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 311 | }, 312 | "has_children": False, 313 | "archived": False, 314 | "type": "numbered_list_item", 315 | "numbered_list_item": { 316 | "rich_text": [ 317 | { 318 | "type": "text", 319 | "text": {"content": "two", "link": None}, 320 | "annotations": { 321 | "bold": False, 322 | "italic": False, 323 | "strikethrough": False, 324 | "underline": False, 325 | "code": False, 326 | "color": "default", 327 | }, 328 | "plain_text": "two", 329 | "href": None, 330 | } 331 | ], 332 | "color": "default", 333 | }, 334 | }, 335 | ], 336 | "next_cursor": None, 337 | "has_more": False, 338 | "type": "block", 339 | "block": {}, 340 | }, 341 | { 342 | "object": "list", 343 | "results": [ 344 | { 345 | "object": "block", 346 | "id": "612fac7c-d5a9-4946-819d-efd14b444760", 347 | "parent": { 348 | "type": "block_id", 349 | "block_id": "e6bb8ac2-7af3-4cbe-af41-e7ac75fe6231", 350 | }, 351 | "created_time": "2023-09-02T22:46:00.000Z", 352 | "last_edited_time": "2023-09-02T22:46:00.000Z", 353 | "created_by": { 354 | "object": "user", 355 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 356 | }, 357 | "last_edited_by": { 358 | "object": "user", 359 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 360 | }, 361 | "has_children": False, 362 | "archived": False, 363 | "type": "numbered_list_item", 364 | "numbered_list_item": { 365 | "rich_text": [ 366 | { 367 | "type": "text", 368 | "text": {"content": "id (integer)", "link": None}, 369 | "annotations": { 370 | "bold": False, 371 | "italic": False, 372 | "strikethrough": False, 373 | "underline": False, 374 | "code": False, 375 | "color": "default", 376 | }, 377 | "plain_text": "id (integer)", 378 | "href": None, 379 | } 380 | ], 381 | "color": "default", 382 | }, 383 | }, 384 | { 385 | "object": "block", 386 | "id": "29a6591f-67cd-4d34-9ab5-2ac917bdbcab", 387 | "parent": { 388 | "type": "block_id", 389 | "block_id": "e6bb8ac2-7af3-4cbe-af41-e7ac75fe6231", 390 | }, 391 | "created_time": "2023-09-02T22:46:00.000Z", 392 | "last_edited_time": "2023-09-02T22:49:00.000Z", 393 | "created_by": { 394 | "object": "user", 395 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 396 | }, 397 | "last_edited_by": { 398 | "object": "user", 399 | "id": "a7c39264-1886-4b50-ba48-87d1791cf3f4", 400 | }, 401 | "has_children": False, 402 | "archived": False, 403 | "type": "numbered_list_item", 404 | "numbered_list_item": { 405 | "rich_text": [ 406 | { 407 | "type": "text", 408 | "text": {"content": "type (string)", "link": None}, 409 | "annotations": { 410 | "bold": False, 411 | "italic": False, 412 | "strikethrough": False, 413 | "underline": False, 414 | "code": False, 415 | "color": "default", 416 | }, 417 | "plain_text": "type (string)", 418 | "href": None, 419 | } 420 | ], 421 | "color": "default", 422 | }, 423 | }, 424 | ], 425 | "next_cursor": None, 426 | "has_more": False, 427 | "type": "block", 428 | "block": {}, 429 | }, 430 | ] 431 | 432 | 433 | class NumberedListTest(unittest.TestCase): 434 | @patch("os.environ") 435 | def test_get_children(self, mock_env) -> None: 436 | # Mock environment variable 437 | mock_env["NOTION_TOKEN"] = "mock_token" 438 | 439 | # Mock the Client class 440 | with patch("notion2md.notion_api.Client") as MockedClient: 441 | # Mock the blocks.children.list method to return different values on subsequent calls 442 | mock_blocks_children_list = MagicMock() 443 | mock_blocks_children_list.side_effect = mock_responses 444 | 445 | # Attach the mock to the Client instance 446 | MockedClient.return_value.blocks.children.list = ( 447 | mock_blocks_children_list 448 | ) 449 | 450 | block_id = "1" 451 | md: str = StringExporter(block_id).export() 452 | 453 | assert md == expected_md 454 | 455 | 456 | if __name__ == "__main__": 457 | unittest.main() 458 | -------------------------------------------------------------------------------- /tests/test_richtext_convertor.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from notion2md.convertor.richtext import richtext_word_converter 4 | 5 | 6 | class ExportRichTextTest(unittest.TestCase): 7 | def test_mention_date_type(self): 8 | richtext = { 9 | "type": "mention", 10 | "mention": { 11 | "type": "date", 12 | "date": { 13 | "start": "2022-01-11", 14 | "end": None, 15 | "time_zone": None, 16 | }, 17 | }, 18 | "annotations": { 19 | "bold": False, 20 | "italic": False, 21 | "strikethrough": False, 22 | "underline": False, 23 | "code": False, 24 | "color": "default", 25 | }, 26 | "plain_text": "2022-01-11 → ", 27 | "href": None, 28 | } 29 | self.assertEqual(richtext_word_converter(richtext), "(2022-01-11 → )") 30 | 31 | def test_mention_reminder_type(self): 32 | richtext = { 33 | "type": "mention", 34 | "mention": { 35 | "type": "date", 36 | "date": { 37 | "start": "2022-01-12T09:00:00.000+09:00", 38 | "end": None, 39 | "time_zone": None, 40 | }, 41 | }, 42 | "annotations": { 43 | "bold": False, 44 | "italic": False, 45 | "strikethrough": False, 46 | "underline": False, 47 | "code": False, 48 | "color": "default", 49 | }, 50 | "plain_text": "2022-01-12T09:00:00.000+09:00 → ", 51 | "href": None, 52 | } 53 | self.assertEqual( 54 | richtext_word_converter(richtext), 55 | "(2022-01-12T09:00:00.000+09:00 → )", 56 | ) 57 | 58 | def test_mention_user_type(self): 59 | richtext = { 60 | "type": "mention", 61 | "mention": { 62 | "type": "user", 63 | "user": { 64 | "object": "user", 65 | "id": "b138e1c9-4054-4713-bd7b-62af6d7641fb", 66 | "name": "Eunchan Cho", 67 | "avatar_url": "https://s3-us-west-2.amazonaws.com/public.notion-static.com/cd39a6ac-1c5a-4b50-9778-3e03885b1e44/sketch1549349815832.png", 68 | "type": "person", 69 | "person": {"email": "e4cho@ucsd.edu"}, 70 | }, 71 | }, 72 | "annotations": { 73 | "bold": False, 74 | "italic": False, 75 | "strikethrough": False, 76 | "underline": False, 77 | "code": False, 78 | "color": "default", 79 | }, 80 | "plain_text": "@Eunchan Cho", 81 | "href": None, 82 | } 83 | self.assertEqual(richtext_word_converter(richtext), "(@Eunchan Cho)") 84 | 85 | def test_mention_page_type(self): 86 | richtext = { 87 | "type": "mention", 88 | "mention": { 89 | "type": "page", 90 | "page": {"id": "ba475a4b-2614-4c51-9e90-5c92a937bd34"}, 91 | }, 92 | "annotations": { 93 | "bold": False, 94 | "italic": False, 95 | "strikethrough": False, 96 | "underline": False, 97 | "code": False, 98 | "color": "default", 99 | }, 100 | "plain_text": "Untitled", 101 | "href": "https://www.notion.so/ba475a4b26144c519e905c92a937bd34", 102 | } 103 | self.assertEqual( 104 | richtext_word_converter(richtext), 105 | "([https://www.notion.so/ba475a4b26144c519e905c92a937bd34](https://www.notion.so/ba475a4b26144c519e905c92a937bd34])", 106 | ) 107 | 108 | def test_mention_database_type(self): 109 | richtext = { 110 | "type": "mention", 111 | "mention": { 112 | "type": "database", 113 | "database": {"id": "44a4ecdc-7d09-4165-811c-13d9ed8ed7aa"}, 114 | }, 115 | "annotations": { 116 | "bold": False, 117 | "italic": False, 118 | "strikethrough": False, 119 | "underline": False, 120 | "code": False, 121 | "color": "default", 122 | }, 123 | "plain_text": "Posts", 124 | "href": "https://www.notion.so/44a4ecdc7d094165811c13d9ed8ed7aa", 125 | } 126 | self.assertEqual( 127 | richtext_word_converter(richtext), 128 | "([Posts](https://www.notion.so/44a4ecdc7d094165811c13d9ed8ed7aa])", 129 | ) 130 | 131 | def test_equation_type1(self): 132 | richtext = { 133 | "type": "equation", 134 | "equation": {"expression": "y = f(x)"}, 135 | "annotations": { 136 | "bold": False, 137 | "italic": False, 138 | "strikethrough": False, 139 | "underline": False, 140 | "code": False, 141 | "color": "default", 142 | }, 143 | "plain_text": "y = f(x)", 144 | "href": None, 145 | } 146 | self.assertEqual(richtext_word_converter(richtext), "$ y = f(x) $") 147 | 148 | def test_equation_type2(self): 149 | richtext = { 150 | "type": "equation", 151 | "equation": {"expression": "\\therefore a = 0"}, 152 | "annotations": { 153 | "bold": False, 154 | "italic": False, 155 | "strikethrough": False, 156 | "underline": False, 157 | "code": False, 158 | "color": "default", 159 | }, 160 | "plain_text": "\\therefore a = 0", 161 | "href": None, 162 | } 163 | self.assertEqual( 164 | richtext_word_converter(richtext), "$ \\therefore a = 0 $" 165 | ) 166 | 167 | def test_text_normal_type(self): 168 | richtext = { 169 | "type": "text", 170 | "text": {"content": "text", "link": None}, 171 | "annotations": { 172 | "bold": False, 173 | "italic": False, 174 | "strikethrough": False, 175 | "underline": False, 176 | "code": False, 177 | "color": "default", 178 | }, 179 | "plain_text": "text", 180 | "href": None, 181 | } 182 | self.assertEqual(richtext_word_converter(richtext), "text") 183 | 184 | def test_text_bold_type(self): 185 | richtext = { 186 | "type": "text", 187 | "text": {"content": "bold", "link": None}, 188 | "annotations": { 189 | "bold": True, 190 | "italic": False, 191 | "strikethrough": False, 192 | "underline": False, 193 | "code": False, 194 | "color": "default", 195 | }, 196 | "plain_text": "bold", 197 | "href": None, 198 | } 199 | self.assertEqual(richtext_word_converter(richtext), "**bold**") 200 | 201 | def test_text_underline_type(self): 202 | richtext = { 203 | "type": "text", 204 | "text": {"content": "underline", "link": None}, 205 | "annotations": { 206 | "bold": False, 207 | "italic": False, 208 | "strikethrough": False, 209 | "underline": True, 210 | "code": False, 211 | "color": "default", 212 | }, 213 | "plain_text": "underline", 214 | "href": None, 215 | } 216 | self.assertEqual(richtext_word_converter(richtext), "underline") 217 | 218 | def test_text_italicaize_type(self): 219 | richtext = { 220 | "type": "text", 221 | "text": {"content": "Italicize", "link": None}, 222 | "annotations": { 223 | "bold": False, 224 | "italic": True, 225 | "strikethrough": False, 226 | "underline": False, 227 | "code": False, 228 | "color": "default", 229 | }, 230 | "plain_text": "Italicize", 231 | "href": None, 232 | } 233 | self.assertEqual(richtext_word_converter(richtext), "*Italicize*") 234 | 235 | def test_text_strike_through_type(self): 236 | richtext = { 237 | "type": "text", 238 | "text": {"content": "strike-through", "link": None}, 239 | "annotations": { 240 | "bold": False, 241 | "italic": False, 242 | "strikethrough": True, 243 | "underline": False, 244 | "code": False, 245 | "color": "default", 246 | }, 247 | "plain_text": "strike-through", 248 | "href": None, 249 | } 250 | self.assertEqual( 251 | richtext_word_converter(richtext), "~~strike-through~~" 252 | ) 253 | 254 | def test_text_code_type(self): 255 | richtext = { 256 | "type": "text", 257 | "text": {"content": "code", "link": None}, 258 | "annotations": { 259 | "bold": False, 260 | "italic": False, 261 | "strikethrough": False, 262 | "underline": False, 263 | "code": True, 264 | "color": "default", 265 | }, 266 | "plain_text": "code", 267 | "href": None, 268 | } 269 | self.assertEqual(richtext_word_converter(richtext), "`code`") 270 | 271 | def test_text_multiple_type(self): 272 | richtext = { 273 | "type": "text", 274 | "text": {"content": "multiple", "link": None}, 275 | "annotations": { 276 | "bold": True, 277 | "italic": True, 278 | "strikethrough": True, 279 | "underline": True, 280 | "code": True, 281 | "color": "default", 282 | }, 283 | "plain_text": "multiple", 284 | "href": None, 285 | } 286 | self.assertEqual( 287 | richtext_word_converter(richtext), "`~~***multiple***~~`" 288 | ) 289 | 290 | def test_text_color_type(self): 291 | richtext = { 292 | "type": "text", 293 | "text": {"content": "colored", "link": None}, 294 | "annotations": { 295 | "bold": False, 296 | "italic": False, 297 | "strikethrough": False, 298 | "underline": False, 299 | "code": False, 300 | "color": "blue", 301 | }, 302 | "plain_text": "colored", 303 | "href": None, 304 | } 305 | self.assertEqual( 306 | richtext_word_converter(richtext), 307 | "colored", 308 | ) 309 | 310 | def test_text_link_type(self): 311 | richtext = { 312 | "type": "text", 313 | "text": { 314 | "content": "link", 315 | "link": {"url": "/8e3bb46322d54fcf85155f747d205f8d"}, 316 | }, 317 | "annotations": { 318 | "bold": False, 319 | "italic": False, 320 | "strikethrough": False, 321 | "underline": False, 322 | "code": False, 323 | "color": "default", 324 | }, 325 | "plain_text": "link", 326 | "href": "/8e3bb46322d54fcf85155f747d205f8d", 327 | } 328 | self.assertEqual( 329 | richtext_word_converter(richtext), 330 | "[link](/8e3bb46322d54fcf85155f747d205f8d)", 331 | ) 332 | 333 | def test_text_url_type(self): 334 | richtext = { 335 | "type": "text", 336 | "text": {"content": "url", "link": {"url": "https://google.com"}}, 337 | "annotations": { 338 | "bold": False, 339 | "italic": False, 340 | "strikethrough": False, 341 | "underline": False, 342 | "code": False, 343 | "color": "default", 344 | }, 345 | "plain_text": "url", 346 | "href": "https://google.com", 347 | } 348 | self.assertEqual( 349 | richtext_word_converter(richtext), "[url](https://google.com)" 350 | ) 351 | 352 | 353 | if __name__ == "__main__": 354 | unittest.main() 355 | --------------------------------------------------------------------------------