├── .coveragerc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.md ├── md2notion ├── NotionPyRenderer.py ├── __init__.py ├── __main__.py └── upload.py ├── setup.py └── tests ├── COMPREHENSIVE_TEST.md ├── TEST IMAGE HAS SPACES.png ├── TEST.md ├── TEST_IMAGE.png ├── __init__.py ├── conftest.py ├── test_NotionPyRenderer.py └── test_upload.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = md2notion -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Package Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | max-parallel: 4 17 | matrix: 18 | python-version: [3.6, 3.7, 3.8] 19 | 20 | steps: 21 | - uses: actions/checkout@v1 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install pipenv 30 | pipenv install 31 | #Install manually to avoid all other dev deps 32 | - name: Test with pytest 33 | run: | 34 | pipenv install pytest 35 | pipenv run pytest -v -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general things to ignore 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | *.egg 6 | *.py[cod] 7 | __pycache__/ 8 | *.so 9 | *~ 10 | venv/ 11 | venv-ci/ 12 | .pytest_cache/ 13 | .cache/ 14 | .coverage 15 | htmlcov -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here's how to run all the development stuff. 4 | 5 | ## Setup Development Environment 6 | * `pyenv global 3.6.8-amd64` 7 | * `pipenv install --dev` 8 | 9 | ## Testing 10 | * `pytest -v` in the root directory 11 | * It's best to run a test against Notion's API as well with `pipenv run python -m md2notion.upload [token] https://www.notion.so/TestPage-8937635afd984d2f953a1750dfce4d26 tests/COMPREHENSIVE_TEST.md` with your token and page. 12 | * To test coverage run `pipenv run coverage run -m pytest -v` 13 | * Then run `pipenv run coverage report` or `pipenv run coverage html` and browser the coverage (TODO: Figure out a way to make a badge for this??) 14 | 15 | ## Releasing 16 | Refer to [the python docs on packaging for clarification](https://packaging.python.org/tutorials/packaging-projects/). 17 | * Make sure you've updated `setup.py` 18 | * `python setup.py sdist bdist_wheel` - Create a source distribution and a binary wheel distribution into `dist/` 19 | * `twine upload dist/md2notion-x.x.x*` - Upload all `dist/` files to PyPI of a given version 20 | * Make sure to tag the commit you released! 21 | 22 | ## Useful tips 23 | Mistletoe comes with a helpful tokenizing parser called `ASTRenderer`. This gives us great insight into what `NotionPyRenderer` is going to be seeing while rendering. 24 | 25 | Example: 26 | ``` 27 | import mistletoe 28 | from mistletoe.ast_renderer import ASTRenderer 29 | 30 | print(mistletoe.markdown(f"#Owo what's this?", ASTRenderer)) 31 | ``` 32 | outputs 33 | ``` 34 | { 35 | "type": "Document", 36 | "footnotes": {}, 37 | "children": [ 38 | { 39 | "type": "Paragraph", 40 | "children": [ 41 | { 42 | "type": "RawText", 43 | "content": "#Owo what's this?" 44 | } 45 | ] 46 | } 47 | ] 48 | } 49 | ``` -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2019 Peter Fornari / "Cobertos" 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. -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | twine = "*" 9 | setuptools = "*" 10 | wheel = "*" 11 | coverage = "*" 12 | 13 | [packages] 14 | md2notion = {editable = true,path = "."} 15 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "9a876b59201c2978c51b625ba0ac5d4916046a36646126602950f65ecce9a14e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "beautifulsoup4": { 18 | "hashes": [ 19 | "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35", 20 | "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25", 21 | "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666" 22 | ], 23 | "version": "==4.9.3" 24 | }, 25 | "bs4": { 26 | "hashes": [ 27 | "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a" 28 | ], 29 | "version": "==0.0.1" 30 | }, 31 | "cached-property": { 32 | "hashes": [ 33 | "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130", 34 | "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0" 35 | ], 36 | "version": "==1.5.2" 37 | }, 38 | "certifi": { 39 | "hashes": [ 40 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 41 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 42 | ], 43 | "version": "==2021.5.30" 44 | }, 45 | "chardet": { 46 | "hashes": [ 47 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 48 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 49 | ], 50 | "version": "==4.0.0" 51 | }, 52 | "commonmark": { 53 | "hashes": [ 54 | "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", 55 | "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" 56 | ], 57 | "version": "==0.9.1" 58 | }, 59 | "dictdiffer": { 60 | "hashes": [ 61 | "sha256:1adec0d67cdf6166bda96ae2934ddb5e54433998ceab63c984574d187cc563d2", 62 | "sha256:d79d9a39e459fe33497c858470ca0d2e93cb96621751de06d631856adfd9c390" 63 | ], 64 | "version": "==0.8.1" 65 | }, 66 | "idna": { 67 | "hashes": [ 68 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 69 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 70 | ], 71 | "version": "==2.10" 72 | }, 73 | "md2notion": { 74 | "editable": true, 75 | "path": "." 76 | }, 77 | "mistletoe": { 78 | "hashes": [ 79 | "sha256:24d0f18cc5f0381c2cfb8a24ef3de83eb9f7929cb7d0e71ecb164b671d86e6a3", 80 | "sha256:3e2d31b2fa6231ea2ee46981274ebac8d5e5736e3aad2d1cd449e2c053b7023b" 81 | ], 82 | "version": "==0.7.2" 83 | }, 84 | "notion": { 85 | "hashes": [ 86 | "sha256:a6c511e1055bb69f05d75220f3f6a5de377a52d42ae38186c3138557c6b4942a", 87 | "sha256:cc380ea2ffe70ee8d5f19244df910cc79ea56a577ca569553d30f8798f0b5b61" 88 | ], 89 | "version": "==0.0.28" 90 | }, 91 | "python-slugify": { 92 | "hashes": [ 93 | "sha256:6d8c5df75cd4a7c3a2d21e257633de53f52ab0265cd2d1dc62a730e8194a7380", 94 | "sha256:f13383a0b9fcbe649a1892b9c8eb4f8eab1d6d84b84bb7a624317afa98159cab" 95 | ], 96 | "version": "==5.0.2" 97 | }, 98 | "pytz": { 99 | "hashes": [ 100 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 101 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 102 | ], 103 | "version": "==2021.1" 104 | }, 105 | "requests": { 106 | "hashes": [ 107 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 108 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 109 | ], 110 | "version": "==2.25.1" 111 | }, 112 | "soupsieve": { 113 | "hashes": [ 114 | "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc", 115 | "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b" 116 | ], 117 | "markers": "python_version >= '3.0'", 118 | "version": "==2.2.1" 119 | }, 120 | "text-unidecode": { 121 | "hashes": [ 122 | "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", 123 | "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" 124 | ], 125 | "version": "==1.3" 126 | }, 127 | "tzlocal": { 128 | "hashes": [ 129 | "sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44", 130 | "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4" 131 | ], 132 | "version": "==2.1" 133 | }, 134 | "urllib3": { 135 | "hashes": [ 136 | "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", 137 | "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" 138 | ], 139 | "index": "pypi", 140 | "version": "==1.26.5" 141 | } 142 | }, 143 | "develop": { 144 | "attrs": { 145 | "hashes": [ 146 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 147 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 148 | ], 149 | "version": "==21.2.0" 150 | }, 151 | "bleach": { 152 | "hashes": [ 153 | "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125", 154 | "sha256:98b3170739e5e83dd9dc19633f074727ad848cbedb6026708c8ac2d3b697a433" 155 | ], 156 | "version": "==3.3.0" 157 | }, 158 | "certifi": { 159 | "hashes": [ 160 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 161 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 162 | ], 163 | "version": "==2021.5.30" 164 | }, 165 | "cffi": { 166 | "hashes": [ 167 | "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", 168 | "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", 169 | "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", 170 | "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", 171 | "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", 172 | "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", 173 | "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", 174 | "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", 175 | "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", 176 | "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", 177 | "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", 178 | "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", 179 | "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", 180 | "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", 181 | "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", 182 | "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", 183 | "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", 184 | "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", 185 | "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", 186 | "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", 187 | "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", 188 | "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", 189 | "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", 190 | "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", 191 | "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", 192 | "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", 193 | "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", 194 | "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", 195 | "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", 196 | "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", 197 | "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", 198 | "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", 199 | "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", 200 | "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", 201 | "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", 202 | "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", 203 | "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", 204 | "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", 205 | "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", 206 | "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", 207 | "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", 208 | "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", 209 | "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", 210 | "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", 211 | "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", 212 | "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", 213 | "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", 214 | "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", 215 | "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" 216 | ], 217 | "version": "==1.14.5" 218 | }, 219 | "chardet": { 220 | "hashes": [ 221 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 222 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 223 | ], 224 | "version": "==4.0.0" 225 | }, 226 | "colorama": { 227 | "hashes": [ 228 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 229 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 230 | ], 231 | "version": "==0.4.4" 232 | }, 233 | "coverage": { 234 | "hashes": [ 235 | "sha256:03ed2a641e412e42cc35c244508cf186015c217f0e4d496bf6d7078ebe837ae7", 236 | "sha256:04b14e45d6a8e159c9767ae57ecb34563ad93440fc1b26516a89ceb5b33c1ad5", 237 | "sha256:0cdde51bfcf6b6bd862ee9be324521ec619b20590787d1655d005c3fb175005f", 238 | "sha256:0f48fc7dc82ee14aeaedb986e175a429d24129b7eada1b7e94a864e4f0644dde", 239 | "sha256:107d327071061fd4f4a2587d14c389a27e4e5c93c7cba5f1f59987181903902f", 240 | "sha256:1375bb8b88cb050a2d4e0da901001347a44302aeadb8ceb4b6e5aa373b8ea68f", 241 | "sha256:14a9f1887591684fb59fdba8feef7123a0da2424b0652e1b58dd5b9a7bb1188c", 242 | "sha256:16baa799ec09cc0dcb43a10680573269d407c159325972dd7114ee7649e56c66", 243 | "sha256:1b811662ecf72eb2d08872731636aee6559cae21862c36f74703be727b45df90", 244 | "sha256:1ccae21a076d3d5f471700f6d30eb486da1626c380b23c70ae32ab823e453337", 245 | "sha256:2f2cf7a42d4b7654c9a67b9d091ec24374f7c58794858bff632a2039cb15984d", 246 | "sha256:322549b880b2d746a7672bf6ff9ed3f895e9c9f108b714e7360292aa5c5d7cf4", 247 | "sha256:32ab83016c24c5cf3db2943286b85b0a172dae08c58d0f53875235219b676409", 248 | "sha256:3fe50f1cac369b02d34ad904dfe0771acc483f82a1b54c5e93632916ba847b37", 249 | "sha256:4a780807e80479f281d47ee4af2eb2df3e4ccf4723484f77da0bb49d027e40a1", 250 | "sha256:4a8eb7785bd23565b542b01fb39115a975fefb4a82f23d407503eee2c0106247", 251 | "sha256:5bee3970617b3d74759b2d2df2f6a327d372f9732f9ccbf03fa591b5f7581e39", 252 | "sha256:60a3307a84ec60578accd35d7f0c71a3a971430ed7eca6567399d2b50ef37b8c", 253 | "sha256:6625e52b6f346a283c3d563d1fd8bae8956daafc64bb5bbd2b8f8a07608e3994", 254 | "sha256:66a5aae8233d766a877c5ef293ec5ab9520929c2578fd2069308a98b7374ea8c", 255 | "sha256:68fb816a5dd901c6aff352ce49e2a0ffadacdf9b6fae282a69e7a16a02dad5fb", 256 | "sha256:6b588b5cf51dc0fd1c9e19f622457cc74b7d26fe295432e434525f1c0fae02bc", 257 | "sha256:6c4d7165a4e8f41eca6b990c12ee7f44fef3932fac48ca32cecb3a1b2223c21f", 258 | "sha256:6d2e262e5e8da6fa56e774fb8e2643417351427604c2b177f8e8c5f75fc928ca", 259 | "sha256:6d9c88b787638a451f41f97446a1c9fd416e669b4d9717ae4615bd29de1ac135", 260 | "sha256:755c56beeacac6a24c8e1074f89f34f4373abce8b662470d3aa719ae304931f3", 261 | "sha256:7e40d3f8eb472c1509b12ac2a7e24158ec352fc8567b77ab02c0db053927e339", 262 | "sha256:812eaf4939ef2284d29653bcfee9665f11f013724f07258928f849a2306ea9f9", 263 | "sha256:84df004223fd0550d0ea7a37882e5c889f3c6d45535c639ce9802293b39cd5c9", 264 | "sha256:859f0add98707b182b4867359e12bde806b82483fb12a9ae868a77880fc3b7af", 265 | "sha256:87c4b38288f71acd2106f5d94f575bc2136ea2887fdb5dfe18003c881fa6b370", 266 | "sha256:89fc12c6371bf963809abc46cced4a01ca4f99cba17be5e7d416ed7ef1245d19", 267 | "sha256:9564ac7eb1652c3701ac691ca72934dd3009997c81266807aef924012df2f4b3", 268 | "sha256:9754a5c265f991317de2bac0c70a746efc2b695cf4d49f5d2cddeac36544fb44", 269 | "sha256:a565f48c4aae72d1d3d3f8e8fb7218f5609c964e9c6f68604608e5958b9c60c3", 270 | "sha256:a636160680c6e526b84f85d304e2f0bb4e94f8284dd765a1911de9a40450b10a", 271 | "sha256:a839e25f07e428a87d17d857d9935dd743130e77ff46524abb992b962eb2076c", 272 | "sha256:b62046592b44263fa7570f1117d372ae3f310222af1fc1407416f037fb3af21b", 273 | "sha256:b7f7421841f8db443855d2854e25914a79a1ff48ae92f70d0a5c2f8907ab98c9", 274 | "sha256:ba7ca81b6d60a9f7a0b4b4e175dcc38e8fef4992673d9d6e6879fd6de00dd9b8", 275 | "sha256:bb32ca14b4d04e172c541c69eec5f385f9a075b38fb22d765d8b0ce3af3a0c22", 276 | "sha256:c0ff1c1b4d13e2240821ef23c1efb1f009207cb3f56e16986f713c2b0e7cd37f", 277 | "sha256:c669b440ce46ae3abe9b2d44a913b5fd86bb19eb14a8701e88e3918902ecd345", 278 | "sha256:c67734cff78383a1f23ceba3b3239c7deefc62ac2b05fa6a47bcd565771e5880", 279 | "sha256:c6809ebcbf6c1049002b9ac09c127ae43929042ec1f1dbd8bb1615f7cd9f70a0", 280 | "sha256:cd601187476c6bed26a0398353212684c427e10a903aeafa6da40c63309d438b", 281 | "sha256:ebfa374067af240d079ef97b8064478f3bf71038b78b017eb6ec93ede1b6bcec", 282 | "sha256:fbb17c0d0822684b7d6c09915677a32319f16ff1115df5ec05bdcaaee40b35f3", 283 | "sha256:fff1f3a586246110f34dc762098b5afd2de88de507559e63553d7da643053786" 284 | ], 285 | "index": "pypi", 286 | "version": "==5.4" 287 | }, 288 | "cryptography": { 289 | "hashes": [ 290 | "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", 291 | "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", 292 | "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", 293 | "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", 294 | "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", 295 | "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", 296 | "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", 297 | "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", 298 | "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", 299 | "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", 300 | "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", 301 | "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" 302 | ], 303 | "version": "==3.4.7" 304 | }, 305 | "docutils": { 306 | "hashes": [ 307 | "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", 308 | "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" 309 | ], 310 | "version": "==0.17.1" 311 | }, 312 | "idna": { 313 | "hashes": [ 314 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 315 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 316 | ], 317 | "version": "==2.10" 318 | }, 319 | "importlib-metadata": { 320 | "hashes": [ 321 | "sha256:960d52ba7c21377c990412aca380bf3642d734c2eaab78a2c39319f67c6a5786", 322 | "sha256:e592faad8de1bda9fe920cf41e15261e7131bcf266c30306eec00e8e225c1dd5" 323 | ], 324 | "version": "==4.4.0" 325 | }, 326 | "iniconfig": { 327 | "hashes": [ 328 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 329 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 330 | ], 331 | "version": "==1.1.1" 332 | }, 333 | "jeepney": { 334 | "hashes": [ 335 | "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657", 336 | "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae" 337 | ], 338 | "markers": "sys_platform == 'linux'", 339 | "version": "==0.6.0" 340 | }, 341 | "keyring": { 342 | "hashes": [ 343 | "sha256:045703609dd3fccfcdb27da201684278823b72af515aedec1a8515719a038cb8", 344 | "sha256:8f607d7d1cc502c43a932a275a56fe47db50271904513a379d39df1af277ac48" 345 | ], 346 | "version": "==23.0.1" 347 | }, 348 | "packaging": { 349 | "hashes": [ 350 | "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", 351 | "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" 352 | ], 353 | "version": "==20.9" 354 | }, 355 | "pkginfo": { 356 | "hashes": [ 357 | "sha256:029a70cb45c6171c329dfc890cde0879f8c52d6f3922794796e06f577bb03db4", 358 | "sha256:9fdbea6495622e022cc72c2e5e1b735218e4ffb2a2a69cde2694a6c1f16afb75" 359 | ], 360 | "version": "==1.7.0" 361 | }, 362 | "pluggy": { 363 | "hashes": [ 364 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 365 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 366 | ], 367 | "version": "==0.13.1" 368 | }, 369 | "py": { 370 | "hashes": [ 371 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 372 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 373 | ], 374 | "version": "==1.10.0" 375 | }, 376 | "pycparser": { 377 | "hashes": [ 378 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 379 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 380 | ], 381 | "version": "==2.20" 382 | }, 383 | "pygments": { 384 | "hashes": [ 385 | "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", 386 | "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" 387 | ], 388 | "version": "==2.9.0" 389 | }, 390 | "pyparsing": { 391 | "hashes": [ 392 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 393 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 394 | ], 395 | "version": "==2.4.7" 396 | }, 397 | "pytest": { 398 | "hashes": [ 399 | "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9", 400 | "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839" 401 | ], 402 | "index": "pypi", 403 | "version": "==6.2.2" 404 | }, 405 | "readme-renderer": { 406 | "hashes": [ 407 | "sha256:63b4075c6698fcfa78e584930f07f39e05d46f3ec97f65006e430b595ca6348c", 408 | "sha256:92fd5ac2bf8677f310f3303aa4bce5b9d5f9f2094ab98c29f13791d7b805a3db" 409 | ], 410 | "version": "==29.0" 411 | }, 412 | "requests": { 413 | "hashes": [ 414 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 415 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 416 | ], 417 | "version": "==2.25.1" 418 | }, 419 | "requests-toolbelt": { 420 | "hashes": [ 421 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 422 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 423 | ], 424 | "version": "==0.9.1" 425 | }, 426 | "rfc3986": { 427 | "hashes": [ 428 | "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", 429 | "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" 430 | ], 431 | "version": "==1.5.0" 432 | }, 433 | "secretstorage": { 434 | "hashes": [ 435 | "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f", 436 | "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195" 437 | ], 438 | "markers": "sys_platform == 'linux'", 439 | "version": "==3.3.1" 440 | }, 441 | "six": { 442 | "hashes": [ 443 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 444 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 445 | ], 446 | "version": "==1.16.0" 447 | }, 448 | "toml": { 449 | "hashes": [ 450 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 451 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 452 | ], 453 | "version": "==0.10.2" 454 | }, 455 | "tqdm": { 456 | "hashes": [ 457 | "sha256:736524215c690621b06fc89d0310a49822d75e599fcd0feb7cc742b98d692493", 458 | "sha256:cd5791b5d7c3f2f1819efc81d36eb719a38e0906a7380365c556779f585ea042" 459 | ], 460 | "version": "==4.61.0" 461 | }, 462 | "twine": { 463 | "hashes": [ 464 | "sha256:2f6942ec2a17417e19d2dd372fc4faa424c87ee9ce49b4e20c427eb00a0f3f41", 465 | "sha256:fcffa8fc37e8083a5be0728371f299598870ee1eccc94e9a25cef7b1dcfa8297" 466 | ], 467 | "index": "pypi", 468 | "version": "==3.3.0" 469 | }, 470 | "urllib3": { 471 | "hashes": [ 472 | "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", 473 | "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" 474 | ], 475 | "index": "pypi", 476 | "version": "==1.26.5" 477 | }, 478 | "webencodings": { 479 | "hashes": [ 480 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 481 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 482 | ], 483 | "version": "==0.5.1" 484 | }, 485 | "wheel": { 486 | "hashes": [ 487 | "sha256:78b5b185f0e5763c26ca1e324373aadd49182ca90e825f7853f4b2509215dc0e", 488 | "sha256:e11eefd162658ea59a60a0f6c7d493a7190ea4b9a85e335b33489d9f17e0245e" 489 | ], 490 | "index": "pypi", 491 | "version": "==0.36.2" 492 | }, 493 | "zipp": { 494 | "hashes": [ 495 | "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", 496 | "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" 497 | ], 498 | "version": "==3.4.1" 499 | } 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### This package is package is no longer maintained. I stopped using Notion.so some time ago and switched to Obsidian due to persistant problems with then Notion API. 2 | 3 | --- 4 | 5 |

6 | build status 7 | pypi python versions 8 | cobertos 9 |

10 | 11 | # Notion.so Markdown Importer 12 | 13 | An importer for Markdown files to [Notion.so](https://notion.so) using [`notion-py`](https://github.com/jamalex/notion-py) 14 | 15 | It provides these features over Notion.so's Markdown importer: 16 | 17 | * Picking a Notion.so page to upload to (instead of them all uploading to the root) 18 | * Code fences keep their original language (or as close as we can match it) 19 | * Code fences are formatted properly 20 | * Inline HTML is preserved 21 | * (Optionally) Upload images that are memtioned in the HTML `` tags. 22 | * Markdown frontmatter is preserved 23 | * Local image references will be uploaded from relative URLs 24 | * Image alts are loaded as captions instead of as `TextBlock`s 25 | * Handles nested lists properly 26 | * Among other improvements... 27 | 28 | Supports Python 3.6+ 29 | 30 | ## Usage from CLI 31 | 32 | * `pip install md2notion` 33 | * Then run like `python -m md2notion [token_v2] [page-url] [...markdown_path_glob_or_url]` 34 | * The markdown at the given path will be added as a new child to the Notion.so note at `page-url` 35 | 36 | There are also some configuration options: 37 | 38 | * `--clear-previous`: If a child of the note at `page-url` has the same name as what you're uploading, it will first be removed. 39 | * `--append`: Instead of making a new child, it will append the markdown contents to the note at `page-url` 40 | * `--html-img`: Upload images that are memtioned in the HTML `` tags. 41 | 42 | ## Usage from script 43 | 44 | * `pip install md2notion` 45 | * In your Python file: 46 | ```python 47 | from notion.client import NotionClient 48 | from notion.block import PageBlock 49 | from md2notion.upload import upload 50 | 51 | # Follow the instructions at https://github.com/jamalex/notion-py#quickstart to setup Notion.py 52 | client = NotionClient(token_v2="") 53 | page = client.get_block("https://www.notion.so/myorg/Test-c0d20a71c0944985ae96e661ccc99821") 54 | 55 | with open("TestMarkdown.md", "r", encoding="utf-8") as mdFile: 56 | newPage = page.children.add_new(PageBlock, title="TestMarkdown Upload") 57 | upload(mdFile, newPage) #Appends the converted contents of TestMarkdown.md to newPage 58 | ``` 59 | 60 | If you need to process `notion-py` block descriptors after parsing from Markdown but before uploading, consider using `convert` and `uploadBlock` separately. Take a look at [`upload.py#upload()`](https://github.com/Cobertos/md2notion/blob/master/md2notion/upload.py) for more. 61 | 62 | ```python 63 | from md2notion.upload import convert, uploadBlock 64 | 65 | rendered = convert(mdFile) 66 | 67 | # Process the rendered array of `notion-py` block descriptors here 68 | # (just dicts with some properties to pass to `notion-py`) 69 | 70 | # Upload all the blocks 71 | for blockDescriptor in rendered: 72 | uploadBlock(blockDescriptor, page, mdFile.name) 73 | ``` 74 | 75 | If you need to parse Markdown differently from the default, consider subclassing [`NotionPyRenderer`](https://github.com/Cobertos/md2notion/blob/master/md2notion/NotionPyRenderer.py) (a [`BaseRenderer` for `mistletoe`](https://github.com/miyuchina/mistletoe)). You can then pass it to `upload(..., notionPyRendererCls=NotionPyRenderer)` as a parameter. 76 | 77 | ## Example, Custom Hexo Importer 78 | 79 | Here's an example that imports a Hexo blog (slghtly hacky). 80 | 81 | ```python 82 | import io 83 | import os.path 84 | import glob 85 | from pathlib import Path 86 | from notion.block import PageBlock 87 | from notion.client import NotionClient 88 | from md2notion.upload import upload 89 | 90 | client = NotionClient(token_v2="") 91 | page = client.get_block("https://www.notion.so/myorg/Test-c0d20a71c0944985ae96e661ccc99821") 92 | 93 | for fp in glob.glob("../source/_posts/*.md", recursive=True): 94 | with open(fp, "r", encoding="utf-8") as mdFile: 95 | #Preprocess the Markdown frontmatter into yaml code fences 96 | mdStr = mdFile.read() 97 | mdChunks = mdStr.split("---") 98 | mdStr = \ 99 | f"""```yaml 100 | {mdChunks[1]} 101 | `` ` 102 | 103 | {'---'.join(mdChunks[2:])} 104 | """ 105 | mdFile = io.StringIO(mdStr) 106 | mdFile.__dict__["name"] = fp #Set this so we can resolve images later 107 | 108 | pageName = os.path.basename(fp)[:40] 109 | newPage = page.children.add_new(PageBlock, title=pageName) 110 | print(f"Uploading {fp} to Notion.so at page {pageName}") 111 | #Get the image relative to the markdown file in the flavor that Hexo 112 | #stores its images (in a folder with the same name as the md file) 113 | def convertImagePath(imagePath, mdFilePath): 114 | return Path(mdFilePath).parent / Path(mdFilePath).stem / Path(imagePath) 115 | upload(mdFile, newPage, imagePathFunc=convertImagePath) 116 | ``` 117 | 118 | ## Contributing 119 | See [CONTRIBUTING.md](https://github.com/Cobertos/md2notion/blob/master/CONTRIBUTING.md) 120 | -------------------------------------------------------------------------------- /md2notion/NotionPyRenderer.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | import random 3 | import re 4 | from collections.abc import Iterable 5 | from notion.block import CodeBlock, DividerBlock, HeaderBlock, SubheaderBlock, \ 6 | SubsubheaderBlock, QuoteBlock, TextBlock, NumberedListBlock, \ 7 | BulletedListBlock, ImageBlock, CollectionViewBlock, TodoBlock, EquationBlock 8 | from mistletoe.base_renderer import BaseRenderer 9 | from mistletoe.block_token import HTMLBlock, CodeFence 10 | from mistletoe.span_token import Image, Link, HTMLSpan, SpanToken 11 | from html.parser import HTMLParser 12 | 13 | def flatten(l): 14 | for el in l: 15 | if isinstance(el, Iterable) and not isinstance(el, (str, bytes, dict)): 16 | yield from flatten(el) 17 | else: 18 | yield el 19 | 20 | def addHtmlImgTagExtension(notionPyRendererCls): 21 | """A decorator that add the image tag extension to the argument list. The 22 | decorator pattern allows us to chain multiple extensions. For example, we 23 | can create a renderer with extension A, B, C by writing: 24 | addAExtension(addBExtension(addCExtension(notionPyRendererCls))) 25 | """ 26 | def newNotionPyRendererCls(*extraExtensions): 27 | new_extension = [HTMLBlock, HTMLSpan] 28 | return notionPyRendererCls(*chain(new_extension, extraExtensions)) 29 | return newNotionPyRendererCls 30 | 31 | def addLatexExtension(notionPyRendererCls): 32 | """A decorator that add the latex extensions to the argument list. 33 | Markdown such as $equation$ is parsed as inline-equations and 34 | $$equation$$ is parsed as an equation block. 35 | """ 36 | def newNotionPyRendererCls(*extraExtensions): 37 | new_extension = [BlockEquation, InlineEquation] 38 | return notionPyRendererCls(*chain(new_extension, extraExtensions)) 39 | return newNotionPyRendererCls 40 | 41 | class NotionPyRenderer(BaseRenderer): 42 | """ 43 | A class that will render out a Markdown file into a descriptor for upload 44 | with notion-py. Each object will have a .type for the block type and then 45 | a bunch of different dict entries corresponding to kwargs for that block 46 | type. 47 | For CollectionViewBlocks, a .rows entry exists in the dictionary with a list 48 | object containing a descriptor for every row. This is still TODO 49 | """ 50 | 51 | def __init__(self, *extraExtensions): 52 | """ 53 | Args: 54 | *extraExtensions: a list of custom tokens to be added to the mistletoe parser. 55 | """ 56 | super().__init__(*extraExtensions) 57 | 58 | def render(self, token): 59 | """ 60 | Takes a single Markdown token and renders it down to 61 | NotionPy classes. Note that all the recursion is handled in the delegated 62 | methods. 63 | Overrides super().render but still uses render_map and then just 64 | does special parsing for stuff 65 | """ 66 | return self.render_map[token.__class__.__name__](token) 67 | 68 | def renderMultiple(self, tokens): 69 | """ 70 | Takes an array of sibling tokens and renders each one out. 71 | """ 72 | return list(flatten(self.render(t) for t in tokens)) 73 | 74 | def renderMultipleToString(self, tokens): 75 | """ 76 | Takes tokens and render them to a single string (if possible). Anything it 77 | can't convert to a string will be returned in the second part of the tuple 78 | @param {objects} tokens 79 | @returns {tuple} (str, dict[]) 80 | """ 81 | def toString(renderedBlock): 82 | if isinstance(renderedBlock, dict) and renderedBlock['type'] == TextBlock: 83 | return renderedBlock['title'] #This unwraps TextBlocks/paragraphs to use in other blocks 84 | else: #Returns str as-is or returns blocks we can't convert 85 | return renderedBlock 86 | 87 | #Try to convert any objects to strings 88 | rendered = [ toString(b) for b in self.renderMultiple(tokens)] 89 | strs = "".join([s for s in rendered if isinstance(s, str)]) 90 | blocks = [b for b in rendered if isinstance(b, dict)] 91 | #Return a tuple of strings and any extra blocks we couldn't convert 92 | return (strs, blocks) 93 | 94 | def renderMultipleToStringAndCombine(self, tokens, toBlockFunc): 95 | """ 96 | renderMultipleToString but combines the string with the other blocks 97 | with the returned block from toBlockFunc 98 | @param {objects} tokens 99 | @param {function} toBlockFunc Takes a str and returns a dict for the created 100 | @returns {dict[]} 101 | """ 102 | strs, blocks = self.renderMultipleToString(tokens) 103 | ret = [] 104 | if strs: #If a non-empty string block 105 | ret = ret + [toBlockFunc(strs)] 106 | if blocks: 107 | ret = ret + blocks 108 | return ret 109 | 110 | def render_document(self, token): 111 | return self.renderMultiple(token.children) 112 | 113 | # == MD Block Tokens == 114 | def render_block_code(self, token): 115 | #Indented code and ``` ``` code fence 116 | #Notion seems really picky about the language field and the case sensitivity 117 | #so we match the string to the specific version that Notion.so expects 118 | notionSoLangs = [ 119 | "ABAP", 120 | "Arduino", 121 | "Bash", 122 | "BASIC", 123 | "C", 124 | "Clojure", 125 | "CoffeeScript", 126 | "C++", 127 | "C#", 128 | "CSS", 129 | "Dart", 130 | "Diff", 131 | "Docker", 132 | "Elixir", 133 | "Elm", 134 | "Erlang", 135 | "Flow", 136 | "Fortran", 137 | "F#", 138 | "Gherkin", 139 | "GLSL", 140 | "Go", 141 | "GraphQL", 142 | "Groovy", 143 | "Haskell", 144 | "HTML", 145 | "Java", 146 | "JavaScript", 147 | "JSON", 148 | "Kotlin", 149 | "LaTeX", 150 | "Less", 151 | "Lisp", 152 | "LiveScript", 153 | "Lua", 154 | "Makefile", 155 | "Markdown", 156 | "Markup", 157 | "MATLAB", 158 | "Nix", 159 | "Objective-C", 160 | "OCaml", 161 | "Pascal", 162 | "Perl", 163 | "PHP", 164 | "Plain Text", 165 | "PowerShell", 166 | "Prolog", 167 | "Python", 168 | "R", 169 | "Reason", 170 | "Ruby", 171 | "Rust", 172 | "Sass", 173 | "Scala", 174 | "Scheme", 175 | "Scss", 176 | "Shell", 177 | "SQL", 178 | "Swift", 179 | "TypeScript", 180 | "VB.Net", 181 | "Verilog", 182 | "VHDL", 183 | "Visual Basic", 184 | "WebAssembly", 185 | "XML", 186 | "YAML" 187 | ] 188 | if token.language != "": 189 | matchLang = next((lang for lang in notionSoLangs if re.match(re.escape(token.language), lang, re.I)), "") 190 | if not matchLang: 191 | print(f"Code block language {token.language} has no corresponding syntax in Notion.so") 192 | else: 193 | matchLang = "Plain Text" 194 | 195 | def blockFunc(blockStr): 196 | return { 197 | 'type': CodeBlock, 198 | 'language': matchLang, 199 | 'title_plaintext': blockStr 200 | } 201 | return self.renderMultipleToStringAndCombine(token.children, blockFunc) 202 | 203 | def render_thematic_break(self, token): 204 | return { 205 | 'type': DividerBlock 206 | } 207 | def render_heading(self, token): 208 | level = token.level 209 | if level > 3: 210 | print(f"h{level} not supported in Notion.so, converting to h3") 211 | level = 3 212 | 213 | def blockFunc(blockStr): 214 | return { 215 | 'type': [HeaderBlock, SubheaderBlock, SubsubheaderBlock][level-1], 216 | 'title': blockStr 217 | } 218 | return self.renderMultipleToStringAndCombine(token.children, blockFunc) 219 | def render_quote(self, token): 220 | def blockFunc(blockStr): 221 | return { 222 | 'type': QuoteBlock, 223 | 'title': blockStr 224 | } 225 | return self.renderMultipleToStringAndCombine(token.children, blockFunc) 226 | def render_paragraph(self, token): 227 | def blockFunc(blockStr): 228 | return { 229 | 'type': TextBlock, 230 | 'title': blockStr 231 | } 232 | return self.renderMultipleToStringAndCombine(token.children, blockFunc) 233 | def render_list(self, token): 234 | #List items themselves are each blocks, so skip it and directly render 235 | #the children 236 | return self.renderMultiple(token.children) 237 | def render_list_item(self, token): 238 | # Lists can have "children" (nested lists, nested images...), so we need 239 | # to render out all the nodes and sort through them to find the string 240 | # for this item and any children 241 | rendered = self.renderMultiple(token.children) 242 | children = [b for b in rendered if b['type'] != TextBlock] 243 | strings = [s['title'] for s in rendered if s['type'] == TextBlock] 244 | strContent = "".join(strings) 245 | 246 | commonAttrs = { 247 | 'title': strContent, 248 | 'children': children 249 | } 250 | 251 | # Figure out which type of block we need to render 252 | if re.match(r'\d', token.leader): #Contains a number 253 | return { 254 | 'type': NumberedListBlock, 255 | **commonAttrs 256 | } 257 | 258 | match = re.match(r"^\[([x ])\][ \t]", strContent, re.I) 259 | if match: 260 | # Handle GFM checkboxes as TodoBlocks 261 | return { 262 | 'type': TodoBlock, 263 | 'checked': match[1] != " ", 264 | **commonAttrs, 265 | # We want everything but the checkbox text, so remove 266 | # the full match width from the string 267 | 'title': strContent[len(match[0]):] 268 | } 269 | 270 | return { 271 | 'type': BulletedListBlock, 272 | **commonAttrs 273 | } 274 | def render_table(self, token): 275 | headerRow = self.render(token.header) #Header is a single row 276 | rows = [self.render(r) for r in token.children] #don't use renderMultiple because it flattens 277 | 278 | def randColId(): 279 | def randChr(): 280 | #ASCII 32 - 126 is ' ' to '~', all printable characters 281 | return chr(random.randrange(32,126)) 282 | #4 characters long of random printable characters, I don't think it 283 | #has any correlation to the data? 284 | return "".join([randChr() for c in range(4)]) 285 | def textColSchema(colName): 286 | return { 'name' : colName, 'type': 'text' } 287 | #The schema is basically special identifiers + the type of property 288 | #to put into Notion. Coming from Markdown, everything is going to 289 | #be text. 290 | # 'J=}x': { 291 | # 'name': 'Column', 292 | # 'type': 'text' 293 | # }, 294 | schema = { randColId() : textColSchema(headerRow[r]) for r in range(len(headerRow) - 1) } 295 | #The last one needs to be named 'Title' and is type title 296 | # 'title': { 297 | # 'name': 'Name', 298 | # 'type': 'title' 299 | # } 300 | schema.update({ 301 | 'title' : { 302 | 'name': headerRow[-1], 303 | 'type': 'title' 304 | } 305 | }) 306 | 307 | #CollectionViewBlock, and it's gonna be a bit hard to do because this 308 | #isn't fully fleshed out in notion-py yet but we can still use create_record 309 | return { 310 | 'type': CollectionViewBlock, 311 | 'rows': rows, #everything except the initial row 312 | 'schema': schema 313 | } 314 | def render_table_row(self, token): 315 | #Rows are a concept in Notion (`CollectionRowBlock`) but notion-py provides 316 | #another API to use it, `.add_row()` so we just render down to an array 317 | #and handle in the Table block. 318 | return self.renderMultiple(token.children) 319 | def render_table_cell(self, token): 320 | #Render straight down to a string, cells aren't a concept in Notion 321 | strs, blocks = self.renderMultipleToString(token.children) 322 | if blocks: 323 | print("Table cell contained non-strings (maybe an image?) and could not add...") 324 | return strs 325 | 326 | # == MD Span Tokens == 327 | # These tokens always appear inside another block-level token (so we can return 328 | # a string instead of a block if necessary). Most of these are handled by 329 | # notion-py's uploader as it will convert them to the internal Notion.so 330 | # MD-like formatting 331 | def render_strong(self, token): 332 | return self.renderMultipleToStringAndCombine(token.children, lambda s: f"**{s}**") 333 | def render_emphasis(self, token): 334 | return self.renderMultipleToStringAndCombine(token.children, lambda s: f"*{s}*") 335 | def render_inline_code(self, token): 336 | return self.renderMultipleToStringAndCombine(token.children, lambda s: f"`{s}`") 337 | def render_raw_text(self, token): 338 | return token.content 339 | def render_strikethrough(self, token): 340 | return self.renderMultipleToStringAndCombine(token.children, lambda s: f"~{s}~") 341 | def render_link(self, token): 342 | strs, blocks = self.renderMultipleToString(token.children) 343 | return [ f"[{strs}]({token.target})" ] + blocks 344 | def render_escape_sequence(self, token): 345 | #Pretty sure this is just like \xxx type escape sequences? 346 | return self.renderMultipleToStringAndCombine(token.children, lambda s: f"\\{s}") 347 | def render_line_break(self, token): 348 | return '\n' 349 | def render_image(self, token): 350 | #Alt text 351 | alt = token.title or self.renderMultipleToString(token.children)[0] 352 | return { 353 | 'type': ImageBlock, 354 | 'display_source': token.src, 355 | 'source': token.src, 356 | 'caption': alt 357 | } 358 | 359 | class __HTMLParser(HTMLParser): 360 | 361 | def __init__(self): 362 | super().__init__() 363 | self._images = [] 364 | self._html = [] 365 | 366 | def get_result(self): 367 | return (''.join(self._html), self._images) 368 | 369 | def handle_starttag(self, tag, attrs): 370 | if tag != "img": 371 | self._html.append(self.get_starttag_text()) 372 | return 373 | 374 | src = next((value for key, value in attrs if key == "src"), "") 375 | alt = next((value for key, value in attrs if key == "alt"), None) 376 | image = { 377 | 'type': ImageBlock, 378 | 'display_source': src, 379 | 'source': src, 380 | 'caption': alt 381 | } 382 | self._images.append(image) 383 | 384 | def handle_endtag(self, tag): 385 | if tag != "img": 386 | self._html.append(f'') 387 | 388 | def handle_data(self, data): 389 | self._html.append(data) 390 | 391 | def render_html(self, token): 392 | content = token.content 393 | parser = self.__HTMLParser() 394 | parser.feed(content) 395 | strippedContent, images = parser.get_result() 396 | 397 | ret = images 398 | if strippedContent.strip() != "": 399 | ret.insert(0, { 400 | 'type': TextBlock, 401 | 'title': strippedContent 402 | }) 403 | return ret 404 | 405 | def render_html_block(self, token): 406 | assert not hasattr(token, "children") 407 | return self.render_html(token) 408 | 409 | def render_html_span(self, token): 410 | assert not hasattr(token, "children") 411 | return self.render_html(token) 412 | 413 | def render_block_equation(self, token): 414 | def blockFunc(blockStr): 415 | return { 416 | 'type': EquationBlock, 417 | 'title_plaintext': blockStr.replace('\\', '\\\\') 418 | } 419 | return self.renderMultipleToStringAndCombine(token.children, blockFunc) 420 | 421 | def render_inline_equation(self, token): 422 | return self.renderMultipleToStringAndCombine(token.children, lambda s: f"$${s}$$") 423 | 424 | 425 | class InlineEquation(SpanToken): 426 | pattern = re.compile(r"(? Notion.so differently 120 | """ 121 | return mistletoe.markdown(mdFile, notionPyRendererCls) 122 | 123 | def upload(mdFile, notionPage, imagePathFunc=None, notionPyRendererCls=NotionPyRenderer): 124 | """ 125 | Uploads a single markdown file at mdFilePath to Notion.so as a child of 126 | notionPage. 127 | @param {file} mdFile The file handle to a markdown file 128 | @param {NotionBlock} notionPage The Notion.so block to add the markdown to 129 | @param {callable|None) [imagePathFunc=None] Function taking image source and mdFilePath 130 | to transform the relative image paths by if necessary (useful if your images are stored in weird 131 | locations relative to your md file. Should return a pathlib.Path 132 | @param {NotionPyRenderer} notionPyRendererCls Class inheritting from the renderer 133 | incase you want to render the Markdown => Notion.so differently 134 | """ 135 | # Convert the Markdown file 136 | rendered = convert(mdFile, notionPyRendererCls) 137 | 138 | # Upload all the blocks 139 | for idx, blockDescriptor in enumerate(rendered): 140 | pct = (idx+1)/len(rendered) * 100 141 | print(f"\rUploading {blockDescriptor['type'].__name__}, {idx+1}/{len(rendered)} ({pct:.1f}%)", end='') 142 | uploadBlock(blockDescriptor, notionPage, mdFile.name, imagePathFunc) 143 | 144 | 145 | def filesFromPathsUrls(paths): 146 | """ 147 | Takes paths or URLs and yields file (path, fileName, file) tuples for 148 | them 149 | """ 150 | for path in paths: 151 | if '://' in path: 152 | r = requests.get(path) 153 | if not r.status_code < 300: #TODO: Make this better..., should only accept success 154 | raise RuntimeError(f'Could not get file {path}, HTTP {r.status_code}') 155 | fileName = path.split('?')[0] 156 | fileName = fileName.split('/')[-1] 157 | fileLike = io.StringIO(r.text) 158 | fileLike.name = path 159 | yield (path, fileName, fileLike) 160 | else: 161 | globPaths = glob.glob(path, recursive=True) 162 | if not globPaths: 163 | raise RuntimeError(f'No file found for glob {path}') 164 | for path in globPaths: 165 | with open(path, "r", encoding="utf-8") as file: 166 | yield (path, os.path.basename(path), file) 167 | 168 | def cli(argv): 169 | parser = argparse.ArgumentParser(description='Uploads Markdown files to Notion.so') 170 | parser.add_argument('token_v2', type=str, 171 | help='the token for your Notion.so session') 172 | parser.add_argument('page_url', type=str, 173 | help='the url of the Notion.so page you want to upload your Markdown files to') 174 | parser.add_argument('md_path_url', type=str, nargs='+', 175 | help='A path, glob, or url to the Markdown file you want to upload') 176 | parser.add_argument('--create', action='store_const', dest='mode', const='create', 177 | help='Create a new child page (default)') 178 | parser.add_argument('--append', action='store_const', dest='mode', const='append', 179 | help='Append to page instead of creating a child page') 180 | parser.add_argument('--clear-previous', action='store_const', dest='mode', const='clear', 181 | help='Clear a previous child page with the same name if it exists') 182 | parser.set_defaults(mode='create') 183 | parser.add_argument('--html-img', action='store_true', default=False, 184 | help="Upload images in HTML tags (disabled by default)") 185 | parser.add_argument('--latex', action='store_true', default=False, 186 | help="Support for latex inline ($..$) and block ($$..$$) equations (disabled by default)") 187 | 188 | args = parser.parse_args(argv) 189 | 190 | notionPyRendererCls = NotionPyRenderer 191 | if args.html_img: 192 | notionPyRendererCls = addHtmlImgTagExtension(notionPyRendererCls) 193 | if args.latex: 194 | notionPyRendererCls = addLatexExtension(notionPyRendererCls) 195 | 196 | print("Initializing Notion.so client...") 197 | client = NotionClient(token_v2=args.token_v2) 198 | print("Getting target PageBlock...") 199 | page = client.get_block(args.page_url) 200 | uploadPage = page 201 | 202 | for mdPath, mdFileName, mdFile in filesFromPathsUrls(args.md_path_url): 203 | if args.mode == 'create' or args.mode == 'clear': 204 | # Clear any old pages if it's a PageBlock that has the same name 205 | if args.mode == 'clear': 206 | for child in [c for c in page.children if isinstance(c, PageBlock) and c.title == mdFileName]: 207 | print(f"Removing previous {child.title}...") 208 | child.remove() 209 | # Make the new page in Notion.so 210 | uploadPage = page.children.add_new(PageBlock, title=mdFileName) 211 | print(f"Uploading {mdPath} to Notion.so at page {uploadPage.title}...") 212 | upload(mdFile, uploadPage, None, notionPyRendererCls) 213 | 214 | 215 | if __name__ == "__main__": 216 | cli(sys.argv[1:]) 217 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='md2notion', 5 | version='2.4.1', 6 | description='Utilities for importing Markdown files to Notion.so', 7 | long_description=open('README.md', 'r').read(), 8 | long_description_content_type="text/markdown", 9 | url='https://github.com/Cobertos/md2notion/', 10 | author='Cobertos', 11 | author_email='me+python@cobertos.com', 12 | license='MIT', 13 | classifiers=[ 14 | 'Development Status :: 5 - Production/Stable', 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: MIT License', 17 | 'Programming Language :: Python :: 3.6', 18 | 'Programming Language :: Python :: 3.7', 19 | 'Programming Language :: Python :: 3.8', 20 | 'Topic :: Office/Business :: News/Diary', 21 | 'Topic :: System :: Filesystems', 22 | 'Topic :: Text Processing :: Markup :: Markdown', 23 | 'Topic :: Utilities' 24 | ], 25 | install_requires=[ 26 | 'mistletoe>=0.7.2', 27 | 'notion>=0.0.28', 28 | 'requests>=2.22.0', 29 | ], 30 | keywords='notion notion.so notion-py markdown md converter', 31 | packages=['md2notion'] 32 | ) 33 | -------------------------------------------------------------------------------- /tests/COMPREHENSIVE_TEST.md: -------------------------------------------------------------------------------- 1 | # Markdown: Syntax (https://raw.githubusercontent.com/mxstbr/markdown-test-file/master/TEST.md) 2 | 3 | * [Overview](#overview) 4 | * [Philosophy](#philosophy) 5 | * [Inline HTML](#html) 6 | * [Automatic Escaping for Special Characters](#autoescape) 7 | * [Block Elements](#block) 8 | * [Paragraphs and Line Breaks](#p) 9 | * [Headers](#header) 10 | * [Blockquotes](#blockquote) 11 | * [Lists](#list) 12 | * [Code Blocks](#precode) 13 | * [Horizontal Rules](#hr) 14 | * [Span Elements](#span) 15 | * [Links](#link) 16 | * [Emphasis](#em) 17 | * [Code](#code) 18 | * [Images](#img) 19 | * [Miscellaneous](#misc) 20 | * [Backslash Escapes](#backslash) 21 | * [Automatic Links](#autolink) 22 | 23 | 24 | **Note:** This document is itself written using Markdown; you 25 | can [see the source for it by adding '.text' to the URL](/projects/markdown/syntax.text). 26 | 27 | ---- 28 | 29 | ## Overview 30 | 31 | ### Philosophy 32 | 33 | Markdown is intended to be as easy-to-read and easy-to-write as is feasible. 34 | 35 | Readability, however, is emphasized above all else. A Markdown-formatted 36 | document should be publishable as-is, as plain text, without looking 37 | like it's been marked up with tags or formatting instructions. While 38 | Markdown's syntax has been influenced by several existing text-to-HTML 39 | filters -- including [Setext](http://docutils.sourceforge.net/mirror/setext.html), [atx](http://www.aaronsw.com/2002/atx/), [Textile](http://textism.com/tools/textile/), [reStructuredText](http://docutils.sourceforge.net/rst.html), 40 | [Grutatext](http://www.triptico.com/software/grutatxt.html), and [EtText](http://ettext.taint.org/doc/) -- the single biggest source of 41 | inspiration for Markdown's syntax is the format of plain text email. 42 | 43 | ## Block Elements 44 | 45 | ### Paragraphs and Line Breaks 46 | 47 | A paragraph is simply one or more consecutive lines of text, separated 48 | by one or more blank lines. (A blank line is any line that looks like a 49 | blank line -- a line containing nothing but spaces or tabs is considered 50 | blank.) Normal paragraphs should not be indented with spaces or tabs. 51 | 52 | The implication of the "one or more consecutive lines of text" rule is 53 | that Markdown supports "hard-wrapped" text paragraphs. This differs 54 | significantly from most other text-to-HTML formatters (including Movable 55 | Type's "Convert Line Breaks" option) which translate every line break 56 | character in a paragraph into a `
` tag. 57 | 58 | When you *do* want to insert a `
` break tag using Markdown, you 59 | end a line with two or more spaces, then type return. 60 | 61 | ### Headers 62 | 63 | Markdown supports two styles of headers, [Setext] [1] and [atx] [2]. 64 | 65 | Optionally, you may "close" atx-style headers. This is purely 66 | cosmetic -- you can use this if you think it looks better. The 67 | closing hashes don't even need to match the number of hashes 68 | used to open the header. (The number of opening hashes 69 | determines the header level.) 70 | 71 | 72 | ### Blockquotes 73 | 74 | Markdown uses email-style `>` characters for blockquoting. If you're 75 | familiar with quoting passages of text in an email message, then you 76 | know how to create a blockquote in Markdown. It looks best if you hard 77 | wrap the text and put a `>` before every line: 78 | 79 | > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, 80 | > consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. 81 | > Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. 82 | > 83 | > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse 84 | > id sem consectetuer libero luctus adipiscing. 85 | 86 | Markdown allows you to be lazy and only put the `>` before the first 87 | line of a hard-wrapped paragraph: 88 | 89 | > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, 90 | consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. 91 | Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. 92 | 93 | > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse 94 | id sem consectetuer libero luctus adipiscing. 95 | 96 | Blockquotes can be nested (i.e. a blockquote-in-a-blockquote) by 97 | adding additional levels of `>`: 98 | 99 | > This is the first level of quoting. 100 | > 101 | > > This is nested blockquote. 102 | > 103 | > Back to the first level. 104 | 105 | Blockquotes can contain other Markdown elements, including headers, lists, 106 | and code blocks: 107 | 108 | > ## This is a header. 109 | > 110 | > 1. This is the first list item. 111 | > 2. This is the second list item. 112 | > 113 | > Here's some example code: 114 | > 115 | > return shell_exec("echo $input | $markdown_script"); 116 | 117 | Any decent text editor should make email-style quoting easy. For 118 | example, with BBEdit, you can make a selection and choose Increase 119 | Quote Level from the Text menu. 120 | 121 | 122 | ### Lists 123 | 124 | Markdown supports ordered (numbered) and unordered (bulleted) lists. 125 | 126 | Unordered lists use asterisks, pluses, and hyphens -- interchangably 127 | -- as list markers: 128 | 129 | * Red 130 | * Green 131 | * Blue 132 | 133 | is equivalent to: 134 | 135 | + Red 136 | + Green 137 | + Blue 138 | 139 | and: 140 | 141 | - Red 142 | - Green 143 | - Blue 144 | 145 | Ordered lists use numbers followed by periods: 146 | 147 | 1. Bird 148 | 2. McHale 149 | 3. Parish 150 | 151 | It's important to note that the actual numbers you use to mark the 152 | list have no effect on the HTML output Markdown produces. The HTML 153 | Markdown produces from the above list is: 154 | 155 | If you instead wrote the list in Markdown like this: 156 | 157 | 1. Bird 158 | 1. McHale 159 | 1. Parish 160 | 161 | or even: 162 | 163 | 3. Bird 164 | 1. McHale 165 | 8. Parish 166 | 167 | you'd get the exact same HTML output. The point is, if you want to, 168 | you can use ordinal numbers in your ordered Markdown lists, so that 169 | the numbers in your source match the numbers in your published HTML. 170 | But if you want to be lazy, you don't have to. 171 | 172 | To make lists look nice, you can wrap items with hanging indents: 173 | 174 | * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. 175 | Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi, 176 | viverra nec, fringilla in, laoreet vitae, risus. 177 | * Donec sit amet nisl. Aliquam semper ipsum sit amet velit. 178 | Suspendisse id sem consectetuer libero luctus adipiscing. 179 | 180 | But if you want to be lazy, you don't have to: 181 | 182 | * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. 183 | Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi, 184 | viverra nec, fringilla in, laoreet vitae, risus. 185 | * Donec sit amet nisl. Aliquam semper ipsum sit amet velit. 186 | Suspendisse id sem consectetuer libero luctus adipiscing. 187 | 188 | List items may consist of multiple paragraphs. Each subsequent 189 | paragraph in a list item must be indented by either 4 spaces 190 | or one tab: 191 | 192 | 1. This is a list item with two paragraphs. Lorem ipsum dolor 193 | sit amet, consectetuer adipiscing elit. Aliquam hendrerit 194 | mi posuere lectus. 195 | 196 | Vestibulum enim wisi, viverra nec, fringilla in, laoreet 197 | vitae, risus. Donec sit amet nisl. Aliquam semper ipsum 198 | sit amet velit. 199 | 200 | 2. Suspendisse id sem consectetuer libero luctus adipiscing. 201 | 202 | It looks nice if you indent every line of the subsequent 203 | paragraphs, but here again, Markdown will allow you to be 204 | lazy: 205 | 206 | * This is a list item with two paragraphs. 207 | 208 | This is the second paragraph in the list item. You're 209 | only required to indent the first line. Lorem ipsum dolor 210 | sit amet, consectetuer adipiscing elit. 211 | 212 | * Another item in the same list. 213 | 214 | To put a blockquote within a list item, the blockquote's `>` 215 | delimiters need to be indented: 216 | 217 | * A list item with a blockquote: 218 | 219 | > This is a blockquote 220 | > inside a list item. 221 | 222 | To put a code block within a list item, the code block needs 223 | to be indented *twice* -- 8 spaces or two tabs: 224 | 225 | * A list item with a code block: 226 | 227 | 228 | 229 | ### Code Blocks 230 | 231 | Pre-formatted code blocks are used for writing about programming or 232 | markup source code. Rather than forming normal paragraphs, the lines 233 | of a code block are interpreted literally. Markdown wraps a code block 234 | in both `
` and `` tags.
235 | 
236 | To produce a code block in Markdown, simply indent every line of the
237 | block by at least 4 spaces or 1 tab.
238 | 
239 | This is a normal paragraph:
240 | 
241 |     This is a code block.
242 | 
243 | Here is an example of AppleScript:
244 | 
245 |     tell application "Foo"
246 |         beep
247 |     end tell
248 | 
249 | A code block continues until it reaches a line that is not indented
250 | (or the end of the article).
251 | 
252 | Within a code block, ampersands (`&`) and angle brackets (`<` and `>`)
253 | are automatically converted into HTML entities. This makes it very
254 | easy to include example HTML source code using Markdown -- just paste
255 | it and indent it, and Markdown will handle the hassle of encoding the
256 | ampersands and angle brackets. For example, this:
257 | 
258 |     
261 | 
262 | Regular Markdown syntax is not processed within code blocks. E.g.,
263 | asterisks are just literal asterisks within a code block. This means
264 | it's also easy to use Markdown to write about Markdown's own syntax.
265 | 
266 | ```
267 | tell application "Foo"
268 |     beep
269 | end tell
270 | ```
271 | 
272 | There's also code fences with the language specified
273 | 
274 | ```python
275 | def test():
276 |     pass
277 | ```
278 | 
279 | ## Span Elements
280 | 
281 | ### Links
282 | 
283 | Markdown supports two style of links: *inline* and *reference*.
284 | 
285 | In both styles, the link text is delimited by [square brackets].
286 | 
287 | To create an inline link, use a set of regular parentheses immediately
288 | after the link text's closing square bracket. Inside the parentheses,
289 | put the URL where you want the link to point, along with an *optional*
290 | title for the link, surrounded in quotes. For example:
291 | 
292 | This is [an example](http://example.com/) inline link.
293 | 
294 | [This link](http://example.net/) has no title attribute.
295 | 
296 | ### Emphasis
297 | 
298 | Markdown treats asterisks (`*`) and underscores (`_`) as indicators of
299 | emphasis. Text wrapped with one `*` or `_` will be wrapped with an
300 | HTML `` tag; double `*`'s or `_`'s will be wrapped with an HTML
301 | `` tag. E.g., this input:
302 | 
303 | *single asterisks*
304 | 
305 | _single underscores_
306 | 
307 | **double asterisks**
308 | 
309 | __double underscores__
310 | 
311 | ### Code
312 | 
313 | To indicate a span of code, wrap it with backtick quotes (`` ` ``).
314 | Unlike a pre-formatted code block, a code span indicates code within a
315 | normal paragraph. For example:
316 | 
317 | Use the `printf()` function.
318 | 
319 | ### Images
320 | 
321 | Images use the image syntax with an ` ! `
322 | 
323 | ![my test image](./TEST_IMAGE.png)
324 | 
325 | We can also use images from externally
326 | 
327 | ![an external placeholder image](https://via.placeholder.com/350x150)
328 | 
329 | ## Other stuff
330 | 
331 | ### Tables
332 | 
333 | We can do tables too
334 | 
335 | | tic | tac | toe |
336 | |-----|-----|-----|
337 | | x   | o   | o   |
338 | | o   | x   | x   |
339 | | o   | x   | o   |
340 | 
341 | ### TODOs
342 | 
343 | * This is a normal list item
344 | * [] Still a normal list item
345 | * [ ] Empty todo
346 | * [x] Todo that's checked
347 | * [ x] Still a normal list item
348 | 
349 | ### Math stuff
350 | 
351 | This is inline $y = ax + b$
352 | 
353 | This is a block of _math_
354 | 
355 | $$y = ax + b$$


--------------------------------------------------------------------------------
/tests/TEST IMAGE HAS SPACES.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cobertos/md2notion/18ae9f1cc4511777329e9c6585db64b80ce65b1f/tests/TEST IMAGE HAS SPACES.png


--------------------------------------------------------------------------------
/tests/TEST.md:
--------------------------------------------------------------------------------
1 | # TEST
2 | 
3 | |  Awoo   |  Awooo  |  Awoooo |
4 | |---------|---------|---------|
5 | | Test100 | Test200 | Test300 |
6 | |         | Test400 |         |
7 | 


--------------------------------------------------------------------------------
/tests/TEST_IMAGE.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cobertos/md2notion/18ae9f1cc4511777329e9c6585db64b80ce65b1f/tests/TEST_IMAGE.png


--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cobertos/md2notion/18ae9f1cc4511777329e9c6585db64b80ce65b1f/tests/__init__.py


--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | def pytest_generate_tests(metafunc):
2 |     if "headerLevel" in metafunc.fixturenames:
3 |         metafunc.parametrize("headerLevel", map(lambda n: n+1, range(6)))


--------------------------------------------------------------------------------
/tests/test_NotionPyRenderer.py:
--------------------------------------------------------------------------------
  1 | '''
  2 | Tests NotionPyRenderer parsing
  3 | '''
  4 | import re
  5 | import mistletoe
  6 | import notion
  7 | from md2notion.NotionPyRenderer import NotionPyRenderer, addHtmlImgTagExtension, addLatexExtension
  8 | 
  9 | def test_header(capsys, headerLevel):
 10 |     '''it renders a range of headers, warns if it cant render properly'''
 11 |     #arrange/act
 12 |     output = mistletoe.markdown(f"{'#'*headerLevel} Owo what's this?", NotionPyRenderer)
 13 |     captured = capsys.readouterr()
 14 | 
 15 |     #assert
 16 |     assert len(output) == 1
 17 |     output = output[0]
 18 |     assert isinstance(output, dict)
 19 |     if headerLevel > 3: #Should print error
 20 |         assert re.search(r"not support", captured.out, re.I) #Should print out warning
 21 |     if headerLevel == 1:
 22 |         assert output['type'] == notion.block.HeaderBlock
 23 |     elif headerLevel == 2:
 24 |         assert output['type'] == notion.block.SubheaderBlock
 25 |     else:
 26 |         assert output['type'] == notion.block.SubsubheaderBlock
 27 |     assert output['title'] == "Owo what's this?"
 28 | 
 29 | def test_list():
 30 |     '''it should render a normal list'''
 31 |     #arrange/act
 32 |     output = mistletoe.markdown("* asdf", NotionPyRenderer)
 33 | 
 34 |     #assert
 35 |     assert len(output) == 1
 36 |     output = output[0]
 37 |     assert isinstance(output, dict)
 38 |     assert output['type'] == notion.block.BulletedListBlock
 39 |     assert output['title'] == 'asdf'
 40 | 
 41 | def test_list_numbered():
 42 |     '''it should render a numbered list if given one'''
 43 |     #arrange/act
 44 |     output = mistletoe.markdown("1. asdf", NotionPyRenderer)
 45 | 
 46 |     #assert
 47 |     assert len(output) == 1
 48 |     output = output[0]
 49 |     assert isinstance(output, dict)
 50 |     assert output['type'] == notion.block.NumberedListBlock
 51 |     assert output['title'] == 'asdf'
 52 | 
 53 | def test_list2():
 54 |     '''it should render a GFM list item'''
 55 |     #arrange/act
 56 |     output = mistletoe.markdown(\
 57 | """
 58 | * [] Really
 59 | * [ ] big
 60 | * [x] uwu
 61 | """, NotionPyRenderer)
 62 | 
 63 |     #assert
 64 |     assert len(output) == 3
 65 |     assert isinstance(output[0], dict)
 66 |     assert output[0]['type'] == notion.block.BulletedListBlock
 67 |     assert output[0]['title'] == '[] Really'
 68 |     assert isinstance(output[1], dict)
 69 |     assert output[1]['type'] == notion.block.TodoBlock
 70 |     assert output[1]['title'] == 'big'
 71 |     assert output[1]['checked'] == False
 72 |     assert isinstance(output[2], dict)
 73 |     assert output[2]['type'] == notion.block.TodoBlock
 74 |     assert output[2]['title'] == 'uwu'
 75 |     assert output[2]['checked'] == True
 76 | 
 77 | def test_quote():
 78 |     '''it should render a numbered list if given one'''
 79 |     #arrange/act
 80 |     output = mistletoe.markdown("> Quoth thee 'Mr. Obama... Hewwo? MR OBAMA??'", NotionPyRenderer)
 81 | 
 82 |     #assert
 83 |     assert len(output) == 1
 84 |     output = output[0]
 85 |     assert isinstance(output, dict)
 86 |     assert output['type'] == notion.block.QuoteBlock
 87 |     assert output['title'] == "Quoth thee 'Mr. Obama... Hewwo? MR OBAMA??'"
 88 | 
 89 | def test_image():
 90 |     '''it should render an image'''
 91 |     #arrange/act
 92 |     output = mistletoe.markdown("![](https://via.placeholder.com/500)", NotionPyRenderer)
 93 | 
 94 |     #assert
 95 |     assert len(output) == 1
 96 |     output = output[0]
 97 |     assert isinstance(output, dict)
 98 |     assert output['type'] == notion.block.ImageBlock
 99 | 
100 | def test_imageInLink():
101 |     '''it should render an image in a link, but separately because notion doesnt support that'''
102 |     #arrange/act
103 |     output = mistletoe.markdown("[![](https://via.placeholder.com/500)](https://cobertos.com)", NotionPyRenderer)
104 | 
105 |     #assert
106 |     assert len(output) == 2
107 |     assert isinstance(output[0], dict)
108 |     assert output[0]['type'] == notion.block.TextBlock
109 |     assert output[0]['title'] == "[](https://cobertos.com)" #Should extract the image
110 |     assert isinstance(output[1], dict) #The ImageBlock can't be in a link in Notion, so we get it outside
111 |     assert output[1]['type'] == notion.block.ImageBlock
112 | 
113 | def test_imageBlockText():
114 |     '''it should render an image in bold text'''
115 |     #arrange/act
116 |     output = mistletoe.markdown("**texttext![](https://via.placeholder.com/500)texttext**", NotionPyRenderer)
117 | 
118 |     #assert
119 |     assert len(output) == 2
120 |     assert isinstance(output[0], dict)
121 |     assert output[0]['type'] == notion.block.TextBlock
122 |     assert output[0]['title'] == "**texttexttexttext**" #Should extract the image
123 |     assert isinstance(output[1], dict) #The ImageBlock can't be inline with anything else so it comes out
124 |     assert output[1]['type'] == notion.block.ImageBlock
125 | 
126 | def test_imageInHtml():
127 |     '''it should render an image that is mentioned in the html  tag'''
128 |     #arrange/act
129 |     output = mistletoe.markdown("headtail",
130 |         addHtmlImgTagExtension(NotionPyRenderer))
131 | 
132 |     #assert
133 |     assert len(output) == 2
134 |     assert isinstance(output[0], dict)
135 |     assert output[0]['type'] == notion.block.TextBlock
136 |     assert output[0]['title'] == "headtail" #Should extract the image
137 |     assert isinstance(output[1], dict) #The ImageBlock can't be inline with anything else so it comes out
138 |     assert output[1]['type'] == notion.block.ImageBlock
139 |     assert output[1]['caption'] is None
140 | 
141 | def test_imageInHtmlBlock():
142 |     '''it should render an image that is mentioned in the html block'''
143 |     output = mistletoe.markdown(\
144 | """
145 | 
ImCaptiontext in div
146 | 147 | tail 148 | """, addHtmlImgTagExtension(NotionPyRenderer)) 149 | 150 | #assert 151 | assert len(output) == 3 152 | assert isinstance(output[0], dict) 153 | assert output[0]['type'] == notion.block.TextBlock 154 | assert output[0]['title'] == "
text in div
" #Should extract the image 155 | assert isinstance(output[1], dict) 156 | assert output[1]['type'] == notion.block.ImageBlock 157 | assert output[1]['caption'] == "ImCaption" 158 | assert isinstance(output[2], dict) 159 | assert output[2]['type'] == notion.block.TextBlock 160 | assert output[2]['title'] == "tail" 161 | 162 | def test_latex_inline(): 163 | output = mistletoe.markdown(r""" 164 | # Test for latex blocks 165 | Text before $f(x) = \sigma(w \cdot x + b)$ Text after 166 | """, addLatexExtension(NotionPyRenderer)) 167 | 168 | assert output[1] is not None 169 | assert output[1]['type'] == notion.block.TextBlock 170 | assert output[1]['title'] == r'Text before $$f(x) = \sigma(w \cdot x + b)$$ Text after' 171 | 172 | def test_latex_inline_with_underscores(): 173 | '''it should render a latex block with underscores in it properly''' 174 | output = mistletoe.markdown(r'Text before $X^T=(X_1, X_2, \ldots, X_p)$ Text after', addLatexExtension(NotionPyRenderer)) 175 | 176 | assert len(output) == 1 177 | output = output[0] 178 | assert output is not None 179 | assert output['type'] == notion.block.TextBlock 180 | assert output['title'] == r'Text before $$X^T=(X_1, X_2, \ldots, X_p)$$ Text after' 181 | 182 | def test_latex_block(): 183 | output = mistletoe.markdown(r""" 184 | # Test for latex blocks 185 | Text before 186 | 187 | $$ 188 | f(x) = \sigma(w \cdot x + b) 189 | $$ 190 | 191 | Text after 192 | """, addLatexExtension(NotionPyRenderer)) 193 | 194 | assert output[2] is not None 195 | assert output[2]['type'] == notion.block.EquationBlock 196 | assert output[2]['title_plaintext'] == 'f(x) = \\\\sigma(w \\\\cdot x + b)\n' 197 | 198 | def test_latex_block_with_underscores(): 199 | '''it should render a latex block with underscores in it properly''' 200 | output = mistletoe.markdown(r""" 201 | $$ 202 | \hat{Y} = \hat{\beta}_0 + \sum_{j=1}^p X_j \hat{\beta}_j 203 | $$""", addLatexExtension(NotionPyRenderer)) 204 | 205 | assert len(output) == 1 206 | output = output[0] 207 | assert output is not None 208 | assert output['type'] == notion.block.EquationBlock 209 | assert output['title_plaintext'] == '\\\\hat{Y} = \\\\hat{\\\\beta}_0 + \\\\sum_{j=1}^p X_j \\\\hat{\\\\beta}_j\n' 210 | 211 | def test_escapeSequence(): 212 | '''it should render out an escape sequence''' 213 | #arrange/act 214 | output = mistletoe.markdown("\\066", NotionPyRenderer) 215 | 216 | #assert 217 | assert len(output) == 1 218 | output = output[0] 219 | assert output['type'] == notion.block.TextBlock 220 | assert output['title'] == "\\066" 221 | 222 | def test_table(): 223 | '''it should render a table''' 224 | #arrange/act 225 | output = mistletoe.markdown(\ 226 | """ 227 | | Awoo | Awooo | Awoooo | 228 | |---------|---------|---------| 229 | | Test100 | Test200 | Test300 | 230 | | | Test400 | | 231 | """, NotionPyRenderer) 232 | 233 | #assert 234 | assert len(output) == 1 235 | output = output[0] 236 | assert isinstance(output, dict) 237 | assert output['type'] == notion.block.CollectionViewBlock 238 | 239 | assert isinstance(output['schema'], dict) 240 | assert len(output['schema']) == 3 #3 properties 241 | assert list(output['schema'].keys())[2] == 'title' #Last one is 'title' 242 | assert list(output['schema'].values())[0] == { 243 | 'type': 'text', 244 | 'name': 'Awoo' 245 | } 246 | assert list(output['schema'].values())[1] == { 247 | 'type': 'text', 248 | 'name': 'Awooo' 249 | } 250 | assert list(output['schema'].values())[2] == { 251 | 'type': 'title', 252 | 'name': 'Awoooo' 253 | } 254 | 255 | assert isinstance(output['rows'], list) 256 | assert len(output['rows']) == 2 #2 rows 257 | assert output['rows'][0] == ['Test100', 'Test200', 'Test300'] 258 | assert output['rows'][1] == ['', 'Test400', ''] 259 | 260 | def test_nested_list(): 261 | '''it should render nested lists''' 262 | #arrange/act 263 | output = mistletoe.markdown(\ 264 | """ 265 | * Awoo 266 | * Hewwo 267 | """, NotionPyRenderer) 268 | 269 | #assert 270 | assert len(output) == 1 271 | output = output[0] 272 | assert isinstance(output, dict) 273 | assert output['type'] == notion.block.BulletedListBlock 274 | assert output['title'] == 'Awoo' 275 | 276 | assert len(output['children']) == 1 277 | outputChild = output['children'][0] 278 | assert isinstance(outputChild, dict) 279 | assert outputChild['type'] == notion.block.BulletedListBlock 280 | assert outputChild['title'] == 'Hewwo' 281 | 282 | def test_code_block_with_language(): 283 | '''it should render a fenced code block with explicit language''' 284 | #arrange/act 285 | raw =\ 286 | """\ 287 | ```python 288 | def get_favorite_fruit(): 289 | return Watermelon 290 | ```""" 291 | expected = "def get_favorite_fruit():\n return Watermelon\n" 292 | output = mistletoe.markdown(raw, NotionPyRenderer) 293 | 294 | #assert 295 | assert len(output) == 1 296 | output = output[0] 297 | assert isinstance(output, dict) 298 | assert output['type'] == notion.block.CodeBlock 299 | assert output['title_plaintext'] == expected 300 | assert output['language'] == 'Python' 301 | 302 | def test_code_block_without_language(): 303 | '''it should render a fenced code block with no language specified''' 304 | #arrange/act 305 | raw =\ 306 | """\ 307 | ``` 308 | (f_ my_made_up_language a b)! 309 | ```""" 310 | expected = "(f_ my_made_up_language a b)!\n" 311 | output = mistletoe.markdown(raw, NotionPyRenderer) 312 | 313 | #assert 314 | assert len(output) == 1 315 | output = output[0] 316 | assert isinstance(output, dict) 317 | assert output['type'] == notion.block.CodeBlock 318 | assert output['title_plaintext'] == expected 319 | assert output['language'] == "Plain Text" 320 | 321 | def test_big_file(): 322 | '''it should be able to render a full Markdown file''' 323 | #arrange/act 324 | #TODO: For now we just test that this doesn't file, not that it's correct 325 | mistletoe.markdown(open("tests/COMPREHENSIVE_TEST.md", "r", encoding="utf-8").read(), NotionPyRenderer) 326 | -------------------------------------------------------------------------------- /tests/test_upload.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Tests NotionPyRenderer parsing 3 | ''' 4 | import pytest 5 | from pathlib import Path 6 | import re 7 | import notion 8 | import sys 9 | from io import IOBase 10 | from md2notion.upload import filesFromPathsUrls, uploadBlock, cli, relativePathForMarkdownUrl 11 | from notion.block import TextBlock, ImageBlock, CollectionViewBlock, PageBlock 12 | from unittest.mock import Mock, patch, call 13 | 14 | def test_filesFromPathUrl_with_file(): 15 | '''it can get a file name, path, and file object from a file''' 16 | #arrange/act 17 | filePath, fileName, file = next(filesFromPathsUrls(['tests/TEST.md'])) 18 | 19 | #assert 20 | assert fileName == 'TEST.md' 21 | assert filePath == 'tests/TEST.md' 22 | assert isinstance(file, IOBase) 23 | 24 | def test_filesFromPathUrl_with_glob(): 25 | '''it can get a file name, path, and file object from a file''' 26 | #arrange/act 27 | tuples = list(filesFromPathsUrls(['tests/TES*.md'])) 28 | 29 | #assert 30 | assert len(tuples) == 1 31 | 32 | def test_filesFromPathUrl_with_url(): 33 | '''it can get a file name, path, and file object from a file''' 34 | #arrange/act 35 | filePath, fileName, file = next(filesFromPathsUrls(['https://raw.githubusercontent.com/Cobertos/md2notion/master/README.md'])) 36 | 37 | #assert 38 | assert fileName == 'README.md' 39 | assert filePath == 'https://raw.githubusercontent.com/Cobertos/md2notion/master/README.md' 40 | assert isinstance(file, IOBase) 41 | 42 | def test_relativePathForMarkdownUrl(): 43 | '''gets relative path for simple file''' 44 | #arrange/act 45 | relPath = relativePathForMarkdownUrl('TEST_IMAGE.png', 'tests/TEST.md') 46 | 47 | #assert 48 | assert relPath == Path('tests/TEST_IMAGE.png') 49 | 50 | def test_relativePathForMarkdownUrl_http_url(): 51 | '''gets relative path for simple file''' 52 | #arrange/act 53 | relPath = relativePathForMarkdownUrl('http://cobertos.com/non_exist.png', 'tests/TEST.md') 54 | 55 | #assert 56 | assert relPath == None 57 | 58 | def test_relativePathForMarkdownUrl_file_url(): 59 | '''gets relative path for a url beginning with file://''' 60 | #arrange/act 61 | relPath = relativePathForMarkdownUrl('file://TEST%20IMAGE%20HAS%20SPACES.png', 'tests/TEST.md') 62 | 63 | #assert 64 | assert relPath == Path('tests/TEST IMAGE HAS SPACES.png') 65 | 66 | def test_relativePathForMarkdownUrl_encoded(): 67 | '''gets relative path for a path that has encoding (which is kind of wonky by we'll support it)''' 68 | #arrange/act 69 | relPath = relativePathForMarkdownUrl('TEST%20IMAGE%20HAS%20SPACES.png', 'tests/TEST.md') 70 | 71 | #assert 72 | assert relPath == Path('tests/TEST IMAGE HAS SPACES.png') 73 | 74 | def test_relativePathForMarkdownUrl_non_exist(): 75 | '''gets relative path for a file that doesn't exist should return None''' 76 | #arrange/act 77 | relPath = relativePathForMarkdownUrl('NON_EXIST.png', 'tests/TEST.md') 78 | 79 | #assert 80 | assert relPath == None 81 | 82 | def test_uploadBlock(): 83 | '''uploads a simple block to Notion using add_new''' 84 | #arrange 85 | blockDescriptor = { 86 | 'type': TextBlock, 87 | 'title': 'This is a test of the test system' 88 | } 89 | notionBlock = Mock() 90 | notionBlock.children.add_new = Mock() 91 | 92 | #act 93 | uploadBlock(blockDescriptor, notionBlock, '') 94 | 95 | #assert 96 | notionBlock.children.add_new.assert_called_with(TextBlock, title='This is a test of the test system') 97 | 98 | def test_uploadBlock_image(): 99 | '''uploads an external image block to Notion without uploading''' 100 | #arrange 101 | blockDescriptor = { 102 | 'type': ImageBlock, 103 | 'title': 'test', 104 | 'source': 'https://example.com' 105 | } 106 | notionBlock = Mock() 107 | newBlock = Mock(spec=blockDescriptor['type']) 108 | notionBlock.children.add_new = Mock(return_value=newBlock) 109 | 110 | #act 111 | uploadBlock(blockDescriptor, notionBlock, '') 112 | 113 | #assert 114 | notionBlock.children.add_new.assert_called_with(ImageBlock, title='test', source='https://example.com') 115 | newBlock.upload_file.assert_not_called() 116 | 117 | def test_uploadBlock_image_local(): 118 | '''uploads an Image block with local image to Notion''' 119 | #arrange 120 | blockDescriptor = { 121 | 'type': ImageBlock, 122 | 'title': 'test', 123 | 'source': 'TEST_IMAGE.png' 124 | } 125 | notionBlock = Mock() 126 | notionBlock.children.add_new.return_value = newBlock = Mock(spec=blockDescriptor['type']) 127 | 128 | #act 129 | uploadBlock(blockDescriptor, notionBlock, 'tests/DUMMY.md') 130 | 131 | #assert 132 | notionBlock.children.add_new.assert_called_with(ImageBlock, title='test', source='TEST_IMAGE.png') 133 | newBlock.upload_file.assert_called_with(str(Path('tests/TEST_IMAGE.png'))) 134 | 135 | def test_uploadBlock_image_local_file_scheme_url_encoded(): 136 | '''uploads an Image block with local image to Notion if it has a file:// scheme''' 137 | #arrange 138 | blockDescriptor = { 139 | 'type': ImageBlock, 140 | 'title': 'test', 141 | 'source': 'file://TEST%20IMAGE%20HAS%20SPACES.png' 142 | } 143 | notionBlock = Mock() 144 | notionBlock.children.add_new.return_value = newBlock = Mock(spec=blockDescriptor['type']) 145 | 146 | #act 147 | uploadBlock(blockDescriptor, notionBlock, 'tests/DUMMY.md') 148 | 149 | #assert 150 | notionBlock.children.add_new.assert_called_with(ImageBlock, title='test', source='file://TEST%20IMAGE%20HAS%20SPACES.png') 151 | newBlock.upload_file.assert_called_with(str(Path('tests/TEST IMAGE HAS SPACES.png'))) 152 | 153 | def test_uploadBlock_image_local_img_func(): 154 | '''uploads an Image block with local image to Notion with a special transform''' 155 | #arrange 156 | blockDescriptor = { 157 | 'type': ImageBlock, 158 | 'title': 'test', 159 | 'source': 'NONEXIST_IMAGE.png' 160 | } 161 | notionBlock = Mock() 162 | notionBlock.children.add_new.return_value = newBlock = Mock(spec=blockDescriptor['type']) 163 | imagePathFunc = Mock(return_value=Path('tests/TEST_IMAGE.png')) 164 | 165 | #act 166 | uploadBlock(blockDescriptor, notionBlock, 'tests/DUMMY.md', imagePathFunc=imagePathFunc) 167 | 168 | #assert 169 | imagePathFunc.assert_called_with('NONEXIST_IMAGE.png', 'tests/DUMMY.md') 170 | notionBlock.children.add_new.assert_called_with(ImageBlock, title='test', source='NONEXIST_IMAGE.png') 171 | newBlock.upload_file.assert_called_with(str(Path('tests/TEST_IMAGE.png'))) 172 | 173 | def test_uploadBlock_collection(): 174 | #arrange 175 | blockDescriptor = { 176 | 'type': CollectionViewBlock, 177 | 'schema': { 178 | 'J=}2': { 179 | 'type': 'text', 180 | 'name': 'Awoooo' 181 | }, 182 | 'J=}x': { 183 | 'type': 'text', 184 | 'name': 'Awooo' 185 | }, 186 | 'title': { 187 | 'type': 'text', 188 | 'name': 'Awoo' 189 | } 190 | }, 191 | 'rows': [ 192 | ['Test100', 'Test200', 'Test300'], 193 | ['', 'Test400', ''] 194 | ] 195 | } 196 | schema = blockDescriptor['schema'] 197 | rows = blockDescriptor['rows'] 198 | notionBlock = Mock() 199 | newBlock = Mock(spec=blockDescriptor['type']) 200 | notionBlock.children.add_new = Mock(return_value=newBlock) 201 | 202 | collection = Mock() 203 | notionBlock._client.create_record = Mock(return_value=collection) 204 | notionBlock._client.get_collection = Mock(return_value=collection) 205 | 206 | #act 207 | uploadBlock(blockDescriptor, notionBlock, '') 208 | 209 | #assert 210 | notionBlock.children.add_new.assert_called_with(CollectionViewBlock) 211 | notionBlock._client.create_record.assert_called_with("collection", parent=newBlock, schema=schema) 212 | notionBlock._client.get_collection.assert_called_with(collection) 213 | #TODO: This is incomplete... 214 | 215 | def MockClient(): 216 | #No-op, seal doesn't exist in Python 3.6 217 | if sys.version_info >= (3,7,0): 218 | from unittest.mock import seal 219 | else: 220 | seal = lambda x: x 221 | notionClient = Mock() 222 | notionClient.return_value = notionClient 223 | getBlock = Mock(spec=PageBlock) 224 | class MockNodeList(list): 225 | def add_new(self, t, title=None): 226 | m = Mock(spec=t) 227 | def remove(): 228 | self.remove(m) 229 | return None 230 | m.remove = Mock(return_value=None, side_effect=remove) 231 | m.title = title 232 | seal(m) 233 | self.append(m) 234 | return m 235 | getBlock.children = MockNodeList() 236 | getBlock.title = Mock(return_value="") 237 | notionClient.get_block = Mock(return_value=getBlock) 238 | seal(getBlock) 239 | seal(notionClient) 240 | return notionClient 241 | 242 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 243 | def test_cli_no_arguments(mockClient): 244 | '''should error when nothing is passed''' 245 | #act/assert 246 | with pytest.raises(SystemExit): 247 | cli([]) 248 | 249 | @patch('md2notion.upload.upload') 250 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 251 | def test_cli_create_single_page(mockClient, upload): 252 | '''should create a single page''' 253 | #act 254 | cli(['token_v2', 'page_url', 'tests/TEST.md']) 255 | 256 | #assert 257 | #Should've been called with a file and the passed block 258 | args0, kwargs0 = upload.call_args 259 | assert isinstance(args0[0], IOBase) 260 | assert args0[0].name == 'tests/TEST.md' 261 | assert args0[1] == mockClient.get_block.return_value.children[0] 262 | assert args0[1].title == 'TEST.md' 263 | 264 | @patch('md2notion.upload.upload') 265 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 266 | def test_cli_create_multiple_pages(mockClient, upload): 267 | '''should create multiple pages''' 268 | #act 269 | cli(['token_v2', 'page_url', 'tests/TEST.md', 'tests/COMPREHENSIVE_TEST.md']) 270 | args0, kwargs0 = upload.call_args_list[0] 271 | assert isinstance(args0[0], IOBase) 272 | assert args0[0].name == 'tests/TEST.md' 273 | assert args0[1] == mockClient.get_block.return_value.children[0] 274 | assert args0[1].title == 'TEST.md' 275 | args1, kwargs1 = upload.call_args_list[1] 276 | assert isinstance(args1[0], IOBase) 277 | assert args1[0].name == 'tests/COMPREHENSIVE_TEST.md' 278 | assert args1[1] == mockClient.get_block.return_value.children[1] 279 | assert args1[1].title == 'COMPREHENSIVE_TEST.md' 280 | 281 | @patch('md2notion.upload.upload') 282 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 283 | def test_cli_append(mockClient, upload): 284 | '''should append when using that flag''' 285 | #act 286 | cli(['token_v2', 'page_url', 'tests/TEST.md', '--append']) 287 | 288 | #assert 289 | #Should've been called with a file and the passed block 290 | args0, kwargs0 = upload.call_args 291 | assert isinstance(args0[0], IOBase) 292 | assert args0[0].name == 'tests/TEST.md' 293 | assert args0[1] == mockClient.get_block.return_value 294 | 295 | @patch('md2notion.upload.upload') 296 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 297 | def test_cli_clear_previous(mockClient, upload): 298 | '''should clear previously title pages with the same name when passed that flag''' 299 | #arrange 300 | testNoBlock = mockClient.get_block().children.add_new(PageBlock, title='NO_TEST.md') 301 | testBlock = mockClient.get_block().children.add_new(PageBlock, title='TEST.md') 302 | 303 | #act 304 | cli(['token_v2', 'page_url', 'tests/TEST.md', '--clear-previous']) 305 | 306 | #assert 307 | testNoBlock.remove.assert_not_called() 308 | testBlock.remove.assert_called_with() 309 | args0, kwargs0 = upload.call_args 310 | assert isinstance(args0[0], IOBase) 311 | assert args0[0].name == 'tests/TEST.md' 312 | assert args0[1] == mockClient.get_block.return_value.children[1] 313 | assert args0[1].title == 'TEST.md' 314 | 315 | @patch('md2notion.upload.upload') 316 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 317 | def test_cli_html_img_tag(mockClient, upload): 318 | '''should enable the extension''' 319 | 320 | #act 321 | cli(['token_v2', 'page_url', 'tests/TEST.md', '--append', '--html-img']) 322 | 323 | #assert 324 | args0, kwargs0 = upload.call_args 325 | renderer = args0[3]() 326 | assert "HTMLSpan" in renderer.render_map 327 | assert "HTMLBlock" in renderer.render_map 328 | 329 | @patch('md2notion.upload.upload') 330 | @patch('md2notion.upload.NotionClient', new_callable=MockClient) 331 | def test_cli_latex(mockClient, upload): 332 | '''should enable the extension''' 333 | 334 | #act 335 | cli(['token_v2', 'page_url', 'tests/TEST.md', '--append', '--latex']) 336 | 337 | #assert 338 | args0, kwargs0 = upload.call_args 339 | renderer = args0[3]() 340 | assert "InlineEquation" in renderer.render_map 341 | assert "BlockEquation" in renderer.render_map 342 | --------------------------------------------------------------------------------