├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pylintrc ├── CONTRIBUTING.md ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.md ├── media ├── folders.png └── where-to-export.png ├── notion_export_enhancer ├── __init__.py ├── __main__.py └── enhancer.py ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_files ├── README.md ├── merge_handle │ ├── test 0123456789abcdef0123456789abcdef.md │ └── test 0123456789abcdef0123456789abcdef │ │ └── .git-commit-folder ├── zip_complex.zip ├── zip_complex │ ├── beep 0123456789abcdef0123456789abcdef.md │ ├── beep 0123456789abcdef0123456789abcdef │ │ └── types 11111111111111111111111111111111.md │ ├── device 00000000000000000000000000000000.md │ └── something_else.csv ├── zip_simple.zip └── zip_simple │ └── test 0123456789abcdef0123456789abcdef.md └── test_upload.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Package Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - owo 7 | pull_request: 8 | branches: 9 | - owo 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 4 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8] 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v1 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install pipenv 29 | pipenv install 30 | #Install manually to avoid all other dev deps 31 | - name: Test with pytest 32 | run: | 33 | pipenv install pytest 34 | pipenv run pytest -vv -------------------------------------------------------------------------------- /.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 16 | zip_complex.zip.formatted 17 | zip_simple.zip.formatted 18 | pyproject.toml -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | 2 | [BASIC] 3 | variable-naming-style=any 4 | function-naming-style=any 5 | argument-naming-style=any 6 | attr-naming-style=any 7 | method-naming-style=any 8 | 9 | [LOGGING] 10 | logging-format-style=new 11 | 12 | [STRING] 13 | 14 | # This flag controls whether inconsistent-quotes generates a warning when the 15 | # character used as a quote delimiter is used inconsistently within a module. 16 | check-quote-consistency=no 17 | 18 | # This flag controls whether the implicit-str-concat should generate a warning 19 | # on implicit string concatenation in sequences defined over several lines. 20 | check-str-concat-over-line-jumps=no 21 | 22 | 23 | [FORMAT] 24 | indent-string=' ' -------------------------------------------------------------------------------- /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 | * TODO:! 11 | * `pytest -v` in the root directory 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/notion_export_enhancer-x.x.x*` - Upload all `dist/` files to PyPI of a given version 20 | * Make sure to tag the commit you released! 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 Samantha 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 | colorama = "*" 13 | 14 | [packages] 15 | notion_export_enhancer = {editable = true, path = "."} 16 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "f00c2141c5a043ee093a5a3b065374606565e3c9415dd41170a15c9f7b46eaa0" 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 | "backoff": { 18 | "hashes": [ 19 | "sha256:48373089ccc81b094281884e48305cbf879b4fc7a80ab7c6de2011e2f741267b", 20 | "sha256:dfaca81d40554e271a53a5f0e7c920100c948d38f5448600de0eab551fcd4fed" 21 | ], 22 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 23 | "version": "==1.11.0" 24 | }, 25 | "beautifulsoup4": { 26 | "hashes": [ 27 | "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35", 28 | "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25", 29 | "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666" 30 | ], 31 | "version": "==4.9.3" 32 | }, 33 | "bs4": { 34 | "hashes": [ 35 | "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a" 36 | ], 37 | "version": "==0.0.1" 38 | }, 39 | "cached-property": { 40 | "hashes": [ 41 | "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130", 42 | "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0" 43 | ], 44 | "version": "==1.5.2" 45 | }, 46 | "certifi": { 47 | "hashes": [ 48 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 49 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 50 | ], 51 | "version": "==2021.5.30" 52 | }, 53 | "charset-normalizer": { 54 | "hashes": [ 55 | "sha256:ad0da505736fc7e716a8da15bf19a985db21ac6415c26b34d2fafd3beb3d927e", 56 | "sha256:b68b38179052975093d71c1b5361bf64afd80484697c1f27056e50593e695ceb" 57 | ], 58 | "markers": "python_version >= '3'", 59 | "version": "==2.0.1" 60 | }, 61 | "commonmark": { 62 | "hashes": [ 63 | "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", 64 | "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" 65 | ], 66 | "version": "==0.9.1" 67 | }, 68 | "dictdiffer": { 69 | "hashes": [ 70 | "sha256:1adec0d67cdf6166bda96ae2934ddb5e54433998ceab63c984574d187cc563d2", 71 | "sha256:d79d9a39e459fe33497c858470ca0d2e93cb96621751de06d631856adfd9c390" 72 | ], 73 | "version": "==0.8.1" 74 | }, 75 | "emoji-extractor": { 76 | "hashes": [ 77 | "sha256:23b0ca51602b15b6d3d090d76be0ee0ad95f28d7e95ed53e58da9b9e589293df" 78 | ], 79 | "version": "==1.0.19" 80 | }, 81 | "idna": { 82 | "hashes": [ 83 | "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", 84 | "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" 85 | ], 86 | "markers": "python_version >= '3'", 87 | "version": "==3.2" 88 | }, 89 | "notion": { 90 | "git": "https://github.com/Cobertos/notion-py.git", 91 | "ref": "c4e67e5b2d8efeedbbc9c48d6947bab3cabca1c5" 92 | }, 93 | "notion-export-enhancer": { 94 | "editable": true, 95 | "path": "." 96 | }, 97 | "python-slugify": { 98 | "hashes": [ 99 | "sha256:6d8c5df75cd4a7c3a2d21e257633de53f52ab0265cd2d1dc62a730e8194a7380", 100 | "sha256:f13383a0b9fcbe649a1892b9c8eb4f8eab1d6d84b84bb7a624317afa98159cab" 101 | ], 102 | "markers": "python_version >= '3.6'", 103 | "version": "==5.0.2" 104 | }, 105 | "pytz": { 106 | "hashes": [ 107 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 108 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 109 | ], 110 | "version": "==2021.1" 111 | }, 112 | "requests": { 113 | "hashes": [ 114 | "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", 115 | "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" 116 | ], 117 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 118 | "version": "==2.26.0" 119 | }, 120 | "soupsieve": { 121 | "hashes": [ 122 | "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc", 123 | "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b" 124 | ], 125 | "markers": "python_version >= '3.0'", 126 | "version": "==2.2.1" 127 | }, 128 | "text-unidecode": { 129 | "hashes": [ 130 | "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", 131 | "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" 132 | ], 133 | "version": "==1.3" 134 | }, 135 | "tzlocal": { 136 | "hashes": [ 137 | "sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44", 138 | "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4" 139 | ], 140 | "version": "==2.1" 141 | }, 142 | "urllib3": { 143 | "hashes": [ 144 | "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", 145 | "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" 146 | ], 147 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 148 | "version": "==1.26.6" 149 | } 150 | }, 151 | "develop": { 152 | "attrs": { 153 | "hashes": [ 154 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 155 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 156 | ], 157 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 158 | "version": "==21.2.0" 159 | }, 160 | "bleach": { 161 | "hashes": [ 162 | "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125", 163 | "sha256:98b3170739e5e83dd9dc19633f074727ad848cbedb6026708c8ac2d3b697a433" 164 | ], 165 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 166 | "version": "==3.3.0" 167 | }, 168 | "certifi": { 169 | "hashes": [ 170 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 171 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 172 | ], 173 | "version": "==2021.5.30" 174 | }, 175 | "cffi": { 176 | "hashes": [ 177 | "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d", 178 | "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771", 179 | "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872", 180 | "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c", 181 | "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc", 182 | "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762", 183 | "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202", 184 | "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5", 185 | "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548", 186 | "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a", 187 | "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f", 188 | "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20", 189 | "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218", 190 | "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c", 191 | "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e", 192 | "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56", 193 | "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224", 194 | "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a", 195 | "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2", 196 | "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a", 197 | "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819", 198 | "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346", 199 | "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b", 200 | "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e", 201 | "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534", 202 | "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb", 203 | "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0", 204 | "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156", 205 | "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd", 206 | "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87", 207 | "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc", 208 | "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195", 209 | "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33", 210 | "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f", 211 | "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d", 212 | "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd", 213 | "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728", 214 | "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7", 215 | "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca", 216 | "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99", 217 | "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf", 218 | "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e", 219 | "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c", 220 | "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5", 221 | "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69" 222 | ], 223 | "version": "==1.14.6" 224 | }, 225 | "charset-normalizer": { 226 | "hashes": [ 227 | "sha256:ad0da505736fc7e716a8da15bf19a985db21ac6415c26b34d2fafd3beb3d927e", 228 | "sha256:b68b38179052975093d71c1b5361bf64afd80484697c1f27056e50593e695ceb" 229 | ], 230 | "markers": "python_version >= '3'", 231 | "version": "==2.0.1" 232 | }, 233 | "colorama": { 234 | "hashes": [ 235 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 236 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 237 | ], 238 | "index": "pypi", 239 | "version": "==0.4.4" 240 | }, 241 | "coverage": { 242 | "hashes": [ 243 | "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", 244 | "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6", 245 | "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45", 246 | "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a", 247 | "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03", 248 | "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529", 249 | "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a", 250 | "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a", 251 | "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2", 252 | "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6", 253 | "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759", 254 | "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53", 255 | "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a", 256 | "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4", 257 | "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff", 258 | "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502", 259 | "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793", 260 | "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb", 261 | "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905", 262 | "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821", 263 | "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b", 264 | "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81", 265 | "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0", 266 | "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b", 267 | "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3", 268 | "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184", 269 | "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701", 270 | "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a", 271 | "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82", 272 | "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638", 273 | "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5", 274 | "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083", 275 | "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6", 276 | "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90", 277 | "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465", 278 | "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a", 279 | "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3", 280 | "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e", 281 | "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066", 282 | "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf", 283 | "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b", 284 | "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae", 285 | "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669", 286 | "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873", 287 | "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b", 288 | "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6", 289 | "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb", 290 | "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160", 291 | "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c", 292 | "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079", 293 | "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", 294 | "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" 295 | ], 296 | "index": "pypi", 297 | "version": "==5.5" 298 | }, 299 | "cryptography": { 300 | "hashes": [ 301 | "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", 302 | "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", 303 | "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", 304 | "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", 305 | "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", 306 | "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", 307 | "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", 308 | "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", 309 | "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", 310 | "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", 311 | "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", 312 | "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" 313 | ], 314 | "markers": "python_version >= '3.6'", 315 | "version": "==3.4.7" 316 | }, 317 | "docutils": { 318 | "hashes": [ 319 | "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", 320 | "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" 321 | ], 322 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 323 | "version": "==0.17.1" 324 | }, 325 | "idna": { 326 | "hashes": [ 327 | "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", 328 | "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" 329 | ], 330 | "markers": "python_version >= '3'", 331 | "version": "==3.2" 332 | }, 333 | "importlib-metadata": { 334 | "hashes": [ 335 | "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac", 336 | "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e" 337 | ], 338 | "markers": "python_version >= '3.6'", 339 | "version": "==4.6.1" 340 | }, 341 | "iniconfig": { 342 | "hashes": [ 343 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 344 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 345 | ], 346 | "version": "==1.1.1" 347 | }, 348 | "jeepney": { 349 | "hashes": [ 350 | "sha256:1237cd64c8f7ac3aa4b3f332c4d0fb4a8216f39eaa662ec904302d4d77de5a54", 351 | "sha256:71335e7a4e93817982f473f3507bffc2eff7a544119ab9b73e089c8ba1409ba3" 352 | ], 353 | "markers": "sys_platform == 'linux'", 354 | "version": "==0.7.0" 355 | }, 356 | "keyring": { 357 | "hashes": [ 358 | "sha256:045703609dd3fccfcdb27da201684278823b72af515aedec1a8515719a038cb8", 359 | "sha256:8f607d7d1cc502c43a932a275a56fe47db50271904513a379d39df1af277ac48" 360 | ], 361 | "markers": "python_version >= '3.6'", 362 | "version": "==23.0.1" 363 | }, 364 | "packaging": { 365 | "hashes": [ 366 | "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", 367 | "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" 368 | ], 369 | "markers": "python_version >= '3.6'", 370 | "version": "==21.0" 371 | }, 372 | "pkginfo": { 373 | "hashes": [ 374 | "sha256:37ecd857b47e5f55949c41ed061eb51a0bee97a87c969219d144c0e023982779", 375 | "sha256:e7432f81d08adec7297633191bbf0bd47faf13cd8724c3a13250e51d542635bd" 376 | ], 377 | "version": "==1.7.1" 378 | }, 379 | "pluggy": { 380 | "hashes": [ 381 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 382 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 383 | ], 384 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 385 | "version": "==0.13.1" 386 | }, 387 | "py": { 388 | "hashes": [ 389 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 390 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 391 | ], 392 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 393 | "version": "==1.10.0" 394 | }, 395 | "pycparser": { 396 | "hashes": [ 397 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 398 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 399 | ], 400 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 401 | "version": "==2.20" 402 | }, 403 | "pygments": { 404 | "hashes": [ 405 | "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", 406 | "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" 407 | ], 408 | "markers": "python_version >= '3.5'", 409 | "version": "==2.9.0" 410 | }, 411 | "pyparsing": { 412 | "hashes": [ 413 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 414 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 415 | ], 416 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", 417 | "version": "==2.4.7" 418 | }, 419 | "pytest": { 420 | "hashes": [ 421 | "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", 422 | "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" 423 | ], 424 | "index": "pypi", 425 | "version": "==6.2.4" 426 | }, 427 | "readme-renderer": { 428 | "hashes": [ 429 | "sha256:63b4075c6698fcfa78e584930f07f39e05d46f3ec97f65006e430b595ca6348c", 430 | "sha256:92fd5ac2bf8677f310f3303aa4bce5b9d5f9f2094ab98c29f13791d7b805a3db" 431 | ], 432 | "version": "==29.0" 433 | }, 434 | "requests": { 435 | "hashes": [ 436 | "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", 437 | "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" 438 | ], 439 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 440 | "version": "==2.26.0" 441 | }, 442 | "requests-toolbelt": { 443 | "hashes": [ 444 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 445 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 446 | ], 447 | "version": "==0.9.1" 448 | }, 449 | "rfc3986": { 450 | "hashes": [ 451 | "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", 452 | "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" 453 | ], 454 | "version": "==1.5.0" 455 | }, 456 | "secretstorage": { 457 | "hashes": [ 458 | "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f", 459 | "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195" 460 | ], 461 | "markers": "sys_platform == 'linux'", 462 | "version": "==3.3.1" 463 | }, 464 | "six": { 465 | "hashes": [ 466 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 467 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 468 | ], 469 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 470 | "version": "==1.16.0" 471 | }, 472 | "toml": { 473 | "hashes": [ 474 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 475 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 476 | ], 477 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", 478 | "version": "==0.10.2" 479 | }, 480 | "tqdm": { 481 | "hashes": [ 482 | "sha256:5aa445ea0ad8b16d82b15ab342de6b195a722d75fc1ef9934a46bba6feafbc64", 483 | "sha256:8bb94db0d4468fea27d004a0f1d1c02da3cdedc00fe491c0de986b76a04d6b0a" 484 | ], 485 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 486 | "version": "==4.61.2" 487 | }, 488 | "twine": { 489 | "hashes": [ 490 | "sha256:16f706f2f1687d7ce30e7effceee40ed0a09b7c33b9abb5ef6434e5551565d83", 491 | "sha256:a56c985264b991dc8a8f4234eb80c5af87fa8080d0c224ad8f2cd05a2c22e83b" 492 | ], 493 | "index": "pypi", 494 | "version": "==3.4.1" 495 | }, 496 | "urllib3": { 497 | "hashes": [ 498 | "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", 499 | "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" 500 | ], 501 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 502 | "version": "==1.26.6" 503 | }, 504 | "webencodings": { 505 | "hashes": [ 506 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 507 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 508 | ], 509 | "version": "==0.5.1" 510 | }, 511 | "wheel": { 512 | "hashes": [ 513 | "sha256:78b5b185f0e5763c26ca1e324373aadd49182ca90e825f7853f4b2509215dc0e", 514 | "sha256:e11eefd162658ea59a60a0f6c7d493a7190ea4b9a85e335b33489d9f17e0245e" 515 | ], 516 | "index": "pypi", 517 | "version": "==0.36.2" 518 | }, 519 | "zipp": { 520 | "hashes": [ 521 | "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", 522 | "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" 523 | ], 524 | "markers": "python_version >= '3.6'", 525 | "version": "==3.5.0" 526 | } 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | build status 3 | pypi python versions 4 | twitter 5 | twitter 6 |

7 | 8 | # Notion Export Enhancer 9 | 10 | Takes a [Notion.so](https://notion.so) export .zip and enhances it by: 11 | 12 | * Removing all Notion IDs from the end of folders and files 13 | * Adds Unicode Emoji to start of folder/file names if it was in your Notion notes 14 | * Retruncates note titles to 200 characters instead of 50 15 | * Applies Notion's modification time to the file data itself 16 | * Moves root md files into the folder with their name, giving them a name like `!index.md` instead so they sort to the top. 17 | 18 |

19 | folders with emojis 20 |

21 | 22 | TODO: 23 | * Remove empty notes (ones with only links)? 24 | * Rewrite csv + md tables into md tables where appropriate? 25 | * .exe instead of .py? 26 | * Image captions should become MD alt image text, not a separate paragraph 27 | * Would require exporting everything ourselves, paragraph after image is ambiguous 28 | 29 | Supports Python 3.6+ 30 | 31 | ## Usage from CLI 32 | 33 | * Export your notion workspace 34 | * You can export a single workspace from `Settings > [Workspace] Settings > Export Content > Export all workspace content` 35 | 36 |

37 | Notion export menu for where to export workspace 38 |

39 | 40 | * Choose export option `"Markdown & CSV"` 41 | * `pip install notion_export_enhancer` 42 | * Then run like `python -m notion_export_enhancer [token_v2] [path_to_zip]` 43 | * `token_v2` is your Notion.so token, which can be obtained by inspecting your browser cookies on a logged-in (non-guest) session on Notion.so 44 | 45 | There are also some configuration options: 46 | 47 | * `--output-path`: Optionally set an output path, otherwise uses the current working directory 48 | * `--remove-title`: Removes the title that Notion adds. H1s at the top of every file (default false) 49 | * `--rewrite-paths`: Rewrite the paths in the Markdown files themselves to match file renaming (default true) 50 | 51 | ## Contributing 52 | See [CONTRIBUTING.md](https://github.com/Cobertos/notion_export_enhancer/blob/master/CONTRIBUTING.md) 53 | -------------------------------------------------------------------------------- /media/folders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/media/folders.png -------------------------------------------------------------------------------- /media/where-to-export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/media/where-to-export.png -------------------------------------------------------------------------------- /notion_export_enhancer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/notion_export_enhancer/__init__.py -------------------------------------------------------------------------------- /notion_export_enhancer/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .enhancer import cli 3 | 4 | if __name__ == "__main__": 5 | cli(sys.argv[1:]) -------------------------------------------------------------------------------- /notion_export_enhancer/enhancer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Takes a [Notion.so](https://notion.so) export .zip and enhances it 3 | """ 4 | 5 | import tempfile 6 | import sys 7 | import os 8 | import time 9 | import re 10 | import argparse 11 | import zipfile 12 | import urllib.parse 13 | from datetime import datetime 14 | from pathlib import Path 15 | import backoff 16 | import requests 17 | from emoji_extractor.extract import Extractor as EmojiExtractor 18 | from notion.client import NotionClient 19 | from notion.block import PageBlock 20 | 21 | def noteNameRewrite(nCl, originalNameNoExt): 22 | """ 23 | Takes original name (with no extension) and renames it using the Notion ID 24 | and data from Notion itself 25 | * Removes the Notion ID 26 | * Looks up the Notion ID for it's icon, and appends if we can find it 27 | """ 28 | match = re.search(r"(.+?) ([0-9a-f]{32})$", originalNameNoExt) 29 | if not match: 30 | return (None, None, None) 31 | 32 | notionId = match[2] 33 | 34 | # Query notion for the ID 35 | #print(f"Fetching Notion ID '{notionId}' for '{originalNameNoExt}'") 36 | try: 37 | pageBlock = nCl.get_block(notionId) 38 | except requests.exceptions.HTTPError: 39 | print(f"Failed to retrieve ID {notionId}") 40 | return (None, None, None) 41 | 42 | # The ID might not be a PageBlock (like when a note with no child PageBlocks 43 | # has an image in it, generating a folder, Notion uses the ID of the first 44 | # ImageBlock, maybe a bug on Notion's end? lol) 45 | if not isinstance(pageBlock, PageBlock): 46 | print(f"Block at ID {notionId}, was not PageBlock. Was {type(pageBlock).__name__}") 47 | if hasattr(pageBlock, 'parent') and pageBlock.parent is not None: 48 | # Try traversing up the parents for the first page 49 | while hasattr(pageBlock, 'parent') and not isinstance(pageBlock, PageBlock): 50 | pageBlock = pageBlock.parent 51 | if isinstance(pageBlock, PageBlock): 52 | print(f"Using some .parent as PageBlock") 53 | elif hasattr(pageBlock, 'children') and pageBlock.children is not None: 54 | # Try to find a PageBlock in the children, but only use if one single one exists 55 | pageBlockChildren = [c for c in pageBlock.children if isinstance(c, PageBlock)] 56 | if len(pageBlockChildren) != 1: 57 | print(f"Ambiguous .children, contained {len(pageBlockChildren)} chlidren PageBlocks") 58 | else: 59 | print(f"Using .children[0] as PageBlock") 60 | pageBlock = pageBlockChildren[0] 61 | 62 | if not isinstance(pageBlock, PageBlock): 63 | print(f"Failed to retrieve PageBlock for ID {notionId}") 64 | return (None, None, None) 65 | 66 | 67 | #print(f"Found parent '{type(pageBlock).__name__}' instead") 68 | 69 | # Check for name truncation 70 | newName = match[1] 71 | if len(match[1]) == 50: 72 | # Use full name instead, invalids replaced with " ", like the normal export 73 | # TODO: These are just Windows reserved characters 74 | # TODO: 200 was just a value to stop Windows from complaining 75 | newName = re.sub(r"[\\/?:*\"<>|]", " ", pageBlock.title) 76 | if len(newName) > 200: 77 | print(f"'{newName}' too long, truncating to 200") 78 | newName = newName[0:200] 79 | 80 | # Add icon to the front if it's there and usable 81 | icon = pageBlock.icon 82 | if icon and EmojiExtractor().big_regex.match(icon): # A full match of a single emoji, might be None or an https://aws.amazon uploaded icon 83 | newName = f"{icon} {newName}" 84 | 85 | # Also get the times to set the file to 86 | createdTime = datetime.fromtimestamp(int(pageBlock._get_record_data()["created_time"])/1000) 87 | lastEditedTime = datetime.fromtimestamp(int(pageBlock._get_record_data()["last_edited_time"])/1000) 88 | 89 | return (newName, createdTime, lastEditedTime) 90 | 91 | class NotionExportRenamer: 92 | """ 93 | Holds state information for renaming a single Notion.so export. Allows it to avoid 94 | naming collisions and store other state 95 | """ 96 | def __init__(self, notionClient, rootPath): 97 | self.notionClient = notionClient 98 | self.rootPath = rootPath 99 | # Dict containing all the paths we've renamed and what they were renamed to 100 | # (plus createdtime and lastEditedTime). Strings with relative directories to 101 | # rootPath mapped to 3 tuples returned from noteNameRewrite 102 | self._renameCache = {} 103 | # Dict containing keys where it is an unrenamed path with the last part being 104 | # renamed mapped to True. Used to see if other files in the folder might 105 | # have the same name and to act accordingly 106 | self._collisionCache = {} 107 | 108 | def renameAndTimesWithNotion(self, pathToRename): 109 | """ 110 | Takes an original on file-system path and rewrites _just the basename_. It 111 | collects rename operations for speed and collision prevention (as some renames 112 | will cause the same name to occur) 113 | @param {string} realPath The path to rename the basename of. Must point to an 114 | actual unrenamed file/folder on disk rooted at self.rootPath so we can scan around it 115 | @returns {tuple} 3 tuple of new name, created time and modified time 116 | """ 117 | if pathToRename in self._renameCache: 118 | return self._renameCache[pathToRename] 119 | 120 | path, name = os.path.split(pathToRename) 121 | nameNoExt, ext = os.path.splitext(name) 122 | newNameNoExt, createdTime, lastEditedTime = noteNameRewrite(self.notionClient, nameNoExt) 123 | if not newNameNoExt: # No rename happened, probably no ID in the name or not an .md file 124 | self._renameCache[pathToRename] = (name, None, None) 125 | else: 126 | # Merge files into folders in path at same name if that folder exists 127 | if ext == '.md': 128 | p = Path(os.path.join(self.rootPath, path, nameNoExt)) 129 | if p.exists() and p.is_dir(): 130 | # NOTE: newNameNoExt can contain a '/' for path joining later! 131 | newNameNoExt = os.path.join(newNameNoExt, "!index") 132 | 133 | # Check to see if name collides 134 | if os.path.join(path, newNameNoExt) in self._collisionCache: 135 | # If it does, try progressive (i) until a new one is found 136 | i = 1 137 | collidingNameNoExt = newNameNoExt 138 | while os.path.join(path, newNameNoExt) in self._collisionCache: 139 | newNameNoExt = f"{collidingNameNoExt} ({i})" 140 | i += 1 141 | 142 | self._renameCache[pathToRename] = (f"{newNameNoExt}{ext}", createdTime, lastEditedTime) 143 | self._collisionCache[os.path.join(path, newNameNoExt)] = True 144 | 145 | return self._renameCache[pathToRename] 146 | 147 | def renameWithNotion(self, pathToRename): 148 | """ 149 | Takes an original on file-system path and rewrites _just the basename_. It 150 | collects rename operations for speed and collision prevention (as some renames 151 | will cause the same name to occur) 152 | @param {string} pathToRename The path to rename the basename of. Must point to an 153 | actual unrenamed file/folder on disk rooted at self.rootPath so we can scan around it 154 | @returns {string} The new name 155 | """ 156 | return self.renameAndTimesWithNotion(pathToRename)[0] 157 | 158 | def renamePathWithNotion(self, pathToRename): 159 | """ 160 | Renames all parts of a path 161 | @param {string} pathToRename A real path on disk to a file or folder root at 162 | self.rootPath. All pieces of the path will be renamed 163 | """ 164 | pathToRenameSplit = re.split(r"[\\/]", pathToRename) 165 | paths = [os.path.join(*pathToRenameSplit[0:rpc + 1]) for rpc in range(len(pathToRenameSplit))] 166 | return os.path.join(*[self.renameWithNotion(rp) for rp in paths]) 167 | 168 | def renamePathAndTimesWithNotion(self, pathToRename): 169 | """ 170 | Renames all parts of a path and return the created and lastEditedTime for the last 171 | part of the path (the file) 172 | @param {string} pathToRename A real path on disk to a file or folder root at 173 | self.rootPath. All pieces of the path will be renamed 174 | """ 175 | newPath = self.renamePathWithNotion(os.path.dirname(pathToRename)) 176 | newName, createdTime, lastEditedTime = self.renameAndTimesWithNotion(pathToRename) 177 | return (os.path.join(newPath, newName), createdTime, lastEditedTime) 178 | 179 | def mdFileRewrite(renamer, mdFilePath, mdFileContents=None, removeTopH1=False, rewritePaths=False): 180 | """ 181 | Takes a Notion exported md file and rewrites parts of it 182 | @param {string} mdFilePath String to the markdown file that's being editted, rooted at 183 | self.rootPath 184 | @param {string} [mdFileContents=None] The contents of the markdown file, if not provided 185 | we will read it manually 186 | @param {boolean} [removeTopH1=False] Remove the title on the first line of the MD file? 187 | @param {boolean} [rewritePaths=False] Rewrite the relative paths in the MD file (images and links) 188 | using Notion file name rewriting 189 | """ 190 | if not mdFileContents: 191 | raise NotImplementedError("TODO: Not passing mdFileContents is not implemented... please pass it ;w;") 192 | 193 | newMDFileContents = mdFileContents 194 | if removeTopH1: 195 | lines = mdFileContents.split("\n") 196 | newMDFileContents = "\n".join(lines[1:]) 197 | 198 | if rewritePaths: 199 | # Notion link/images use relative paths to other notes, which we can't known without 200 | # consulting the file tree and renaming (to handle duplicates and such) 201 | # Notion links are also URL encoded 202 | # Can't use finditer because we modify the string each time... 203 | searchStartIndex = 0 204 | while True: 205 | m = re.search(r"!?\[.+?\]\(([\w\d\-._~:/?=#%\]\[@!$&'\(\)*+,;]+?)\)", newMDFileContents[searchStartIndex:]) 206 | if not m: 207 | break 208 | 209 | if re.search(r":/", m.group(1)): 210 | searchStartIndex = searchStartIndex + m.end(1) 211 | continue # Not a local file path 212 | relTargetFilePath = urllib.parse.unquote(m.group(1)) 213 | 214 | # Convert the current MD file path and link target path to the renamed version 215 | # (also taking into account potentially mdFilePath renames moving the directory) 216 | mdDirPath = os.path.dirname(mdFilePath) 217 | newTargetFilePath = renamer.renamePathWithNotion(os.path.join(mdDirPath, relTargetFilePath)) 218 | newMDDirPath = os.path.dirname(renamer.renamePathWithNotion(mdFilePath)) 219 | # Find the relative path to the newly converted paths for both files 220 | newRelTargetFilePath = os.path.relpath(newTargetFilePath, newMDDirPath) 221 | # Convert back to the way markdown expects the link to be 222 | newRelTargetFilePath = re.sub(r"\\", "/", newRelTargetFilePath) 223 | newRelTargetFilePath = urllib.parse.quote(newRelTargetFilePath) 224 | 225 | # Replace the path in the original string with the new relative renamed 226 | # target path 227 | newMDFileContents = newMDFileContents[0:m.start(1) + searchStartIndex] + newRelTargetFilePath + newMDFileContents[m.end(1) + searchStartIndex:] 228 | searchStartIndex = searchStartIndex + m.start(1) + len(newRelTargetFilePath) 229 | 230 | return newMDFileContents 231 | 232 | def rewriteNotionZip(notionClient, zipPath, outputPath=".", removeTopH1=False, rewritePaths=True): 233 | """ 234 | Takes a Notion .zip and prettifies the whole thing 235 | * Removes all Notion IDs from end of names, folders and files 236 | * Add icon to the start of folder/file name if Unicode character 237 | * For files had content in Notion, move them inside the folder, and set the 238 | name to something that will sort to the top 239 | * Fix links inside of files 240 | * Optionally remove titles at the tops of files 241 | 242 | @param {NotionClient} notionClient The NotionClient to use to query Notion with 243 | @param {string} zipPath The path to the Notion zip 244 | @param {string} [outputPath="."] Optional output path, otherwise will use cwd 245 | @param {boolean} [removeTopH1=False] To remove titles at the top of all the md files 246 | @param {boolean} [rewritePaths=True] To rewrite all the links and images in the Markdown files too 247 | @returns {string} Path to the output zip file 248 | """ 249 | with tempfile.TemporaryDirectory() as tmpDir: 250 | # Unpack the whole thing first (probably faster than traversing it zipped, like with tar files) 251 | print(f"Extracting '{zipPath}' temporarily...") 252 | with zipfile.ZipFile(zipPath) as zf: 253 | zf.extractall(tmpDir) 254 | 255 | # Make new zip to begin filling 256 | zipName = os.path.basename(zipPath) 257 | newZipName = f"{zipName}.formatted" 258 | newZipPath = os.path.join(outputPath, newZipName) 259 | with zipfile.ZipFile(newZipPath, 'w', zipfile.ZIP_DEFLATED) as zf: 260 | 261 | #Traverse over the files, renaming, modifying, and rewriting back to the zip 262 | renamer = NotionExportRenamer(notionClient, tmpDir) 263 | for tmpWalkDir, dirs, files in os.walk(tmpDir): 264 | walkDir = os.path.relpath(tmpWalkDir, tmpDir) 265 | for name in files: 266 | realPath = os.path.join(tmpWalkDir, name) 267 | relPath = os.path.join("" if walkDir == "." else walkDir, name) # Prevent paths starting with .\\ which, when written to the tar, do annoying things 268 | # print(f"Reading '{root}' '{name}'") 269 | 270 | # Rewrite the current path and get the times from Notion 271 | print("---") 272 | print(f"Working on '{relPath}'") 273 | newPath, createdTime, lastEditedTime = renamer.renamePathAndTimesWithNotion(relPath) 274 | 275 | if os.path.splitext(name)[1] == ".md": 276 | # Grab the data from the file if md file 277 | with open(realPath, "r", encoding='utf-8') as f: 278 | mdFileData = f.read() 279 | mdFileData = mdFileRewrite(renamer, relPath, mdFileContents=mdFileData, removeTopH1=removeTopH1, rewritePaths=rewritePaths) 280 | 281 | print(f"Writing as '{newPath}' with time '{lastEditedTime}'") 282 | zi = zipfile.ZipInfo(newPath, lastEditedTime.timetuple()) 283 | zf.writestr(zi, mdFileData) 284 | else: 285 | print(f"Writing as '{newPath}' with time from original export (not an .md file)") 286 | zf.write(realPath, newPath) 287 | return newZipPath 288 | 289 | 290 | def cli(argv): 291 | """ 292 | CLI entrypoint, takes CLI arguments array 293 | """ 294 | parser = argparse.ArgumentParser(description='Prettifies Notion .zip exports') 295 | parser.add_argument('token_v2', type=str, 296 | help='the token for your Notion.so session') 297 | parser.add_argument('zip_path', type=str, 298 | help='the path to the Notion exported .zip file') 299 | parser.add_argument('--output-path', action='store', type=str, default=".", 300 | help='The path to output to, defaults to cwd') 301 | parser.add_argument('--remove-title', action='store_true', 302 | help='Removes the title that Notion adds. H1s at the top of every file') 303 | parser.add_argument('--rewrite-paths', action='store_false', default=True, 304 | help='Rewrite the paths in the Markdown files themselves to match file renaming') 305 | args = parser.parse_args(argv) 306 | 307 | startTime = time.time() 308 | nCl = NotionClient(token_v2=args.token_v2) 309 | nCl.get_block = backoff.on_exception(backoff.expo, 310 | requests.exceptions.HTTPError, 311 | max_tries=5, 312 | )(nCl.get_block) 313 | 314 | outFileName = rewriteNotionZip(nCl, args.zip_path, outputPath=args.output_path, 315 | removeTopH1=args.remove_title, rewritePaths=args.rewrite_paths) 316 | print("--- Finished in %s seconds ---" % (time.time() - startTime)) 317 | print(f"Output file written as '{outFileName}'") 318 | 319 | if __name__ == "__main__": 320 | cli(sys.argv[1:]) 321 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='notion_export_enhancer', 5 | version='0.0.7', 6 | description='Export and _enhance_, takes Notion\'s export and makes it just a bit more usable.', 7 | long_description=open('README.md', 'r').read(), 8 | long_description_content_type="text/markdown", 9 | url='https://github.com/Cobertos/notion_export_enhancer/', 10 | author='Cobertos', 11 | author_email='me+python@cobertos.com', 12 | license='MIT', 13 | classifiers=[ 14 | 'Development Status :: 4 - Beta', 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 | 'backoff>=1.11.0', 27 | 'emoji_extractor>=1.0.19', 28 | 'notion-cobertos-fork>=0.0.29', 29 | ], 30 | keywords='notion notion.so notion-py markdown md export enhance enhancer', 31 | packages=['notion_export_enhancer'] 32 | ) 33 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/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_files/README.md: -------------------------------------------------------------------------------- 1 | # Test files 2 | 3 | * merge_handle - Tests the merging functionality of a file into a folder 4 | * zip_simple - Contains a single markdown file at root which should be renamed and packaged back up 5 | * `0123456789abcdef0123456789abcdef` - ID of the MD file 6 | * zip_complex - TODO document -------------------------------------------------------------------------------- /tests/test_files/merge_handle/test 0123456789abcdef0123456789abcdef.md: -------------------------------------------------------------------------------- 1 | # Test 2 | 3 | This is a test of the merge functionality -------------------------------------------------------------------------------- /tests/test_files/merge_handle/test 0123456789abcdef0123456789abcdef/.git-commit-folder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/tests/test_files/merge_handle/test 0123456789abcdef0123456789abcdef/.git-commit-folder -------------------------------------------------------------------------------- /tests/test_files/zip_complex.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/tests/test_files/zip_complex.zip -------------------------------------------------------------------------------- /tests/test_files/zip_complex/beep 0123456789abcdef0123456789abcdef.md: -------------------------------------------------------------------------------- 1 | # Beep 2 | 3 | A tiny little sound 4 | it feels ever so comforting 5 | an impulse and then it's gone 6 | but the effect ripples through your body 7 | 8 | [Types](beep/types%2011111111111111111111111111111111.md) -------------------------------------------------------------------------------- /tests/test_files/zip_complex/beep 0123456789abcdef0123456789abcdef/types 11111111111111111111111111111111.md: -------------------------------------------------------------------------------- 1 | # Types of beeps 2 | 3 | * Small 4 | * Soft 5 | * Agile 6 | 7 | [beep](../device%2000000000000000000000000000000000.md) -------------------------------------------------------------------------------- /tests/test_files/zip_complex/device 00000000000000000000000000000000.md: -------------------------------------------------------------------------------- 1 | You come across a small device 2 | It's covered in ash and debris 3 | The smooth body has faint ridges that trace your palms 4 | Until it gives and it lets out a soft 5 | [_Beep_](beep%200123456789abcdef0123456789abcdef.md) -------------------------------------------------------------------------------- /tests/test_files/zip_complex/something_else.csv: -------------------------------------------------------------------------------- 1 | beep,beep,beep,beep 2 | beep,beep,beep,beep 3 | beep,beep,beep,beep 4 | beep,beep,beep,beep 5 | -------------------------------------------------------------------------------- /tests/test_files/zip_simple.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cobertos/notion_export_enhancer/07a34d4b3daeb1ec69cd4253c089ba4d9dc5bbc3/tests/test_files/zip_simple.zip -------------------------------------------------------------------------------- /tests/test_files/zip_simple/test 0123456789abcdef0123456789abcdef.md: -------------------------------------------------------------------------------- 1 | Simple zip test -------------------------------------------------------------------------------- /tests/test_upload.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Tests NotionPyRenderer parsing 3 | ''' 4 | import pytest 5 | from datetime import datetime 6 | import io 7 | import sys 8 | import os 9 | import re 10 | import zipfile 11 | import requests 12 | from notion_export_enhancer.enhancer import noteNameRewrite, NotionExportRenamer, \ 13 | mdFileRewrite, rewriteNotionZip 14 | from notion.block import PageBlock, ImageBlock 15 | from unittest.mock import Mock, patch 16 | 17 | #No-op, seal doesn't exist in Python 3.6 18 | if sys.version_info >= (3,7,0): 19 | from unittest.mock import seal 20 | else: 21 | seal = lambda x: x 22 | 23 | testsRoot = os.path.dirname(os.path.realpath(__file__)) 24 | defaultBlockTimeNotion = "7955187742000" #2/2/2222 22:22:22 25 | defaultBlockTime = datetime.fromtimestamp(7955187742) 26 | 27 | def MockBlock(title='', icon=None, createdTime=defaultBlockTimeNotion, lastEditedTime=None, spec=PageBlock, parent=None, children=None): 28 | mockBlock = Mock(spec=spec) 29 | mockBlock._get_record_data = Mock(return_value={ "created_time": createdTime, "last_edited_time": lastEditedTime or createdTime }) 30 | mockBlock.icon = icon 31 | mockBlock.title = title 32 | mockBlock.parent = parent 33 | mockBlock.children = children 34 | seal(mockBlock) 35 | return mockBlock 36 | 37 | def MockClient(blockMap={}): 38 | notionClient = Mock() 39 | notionClient.return_value = notionClient 40 | 41 | def get_block(bId): 42 | ret = blockMap[bId] 43 | if isinstance(ret, Exception): 44 | raise ret 45 | else: 46 | return ret 47 | notionClient.get_block = get_block 48 | seal(notionClient) 49 | return notionClient 50 | 51 | def test_noteNameRewrite_non_matching_names(): 52 | '''it will return None tuple when not matching pattern''' 53 | #arrange 54 | nCl = MockClient() 55 | 56 | #act/assert 57 | assert noteNameRewrite(nCl, 'asdf') == (None, None, None) 58 | assert noteNameRewrite(nCl, 'asdf 4fe9r0ogij') == (None, None, None) 59 | 60 | def test_noteNameRewrite_name(): 61 | '''it will return properly extracted name''' 62 | #arrange 63 | nCl = MockClient({ 64 | '0123456789abcdef0123456789abcdef': MockBlock() 65 | }) 66 | 67 | #act 68 | ret = noteNameRewrite(nCl, 'asdf 0123456789abcdef0123456789abcdef') 69 | 70 | #assert 71 | assert ret == ('asdf', defaultBlockTime, defaultBlockTime) 72 | 73 | @patch('sys.stdout', new_callable=io.StringIO) 74 | def test_noteNameRewrite_HTTPError(mockStdout): 75 | '''HTTPError will skip''' 76 | #arrange 77 | nCl = MockClient({ 78 | '0123456789abcdef0123456789abcdef': requests.exceptions.HTTPError('asdf') 79 | }) 80 | 81 | #act 82 | ret = noteNameRewrite(nCl, 'asdf 0123456789abcdef0123456789abcdef') 83 | 84 | #assert 85 | assert ret == (None, None, None) 86 | assert re.search(r"Failed", mockStdout.getvalue(), flags=re.IGNORECASE) 87 | 88 | def test_noteNameRewrite_returns_imageblock_with_parent(): 89 | '''HTTPError will skip''' 90 | #arrange 91 | nCl = MockClient({ 92 | '0123456789abcdef0123456789abcdef': MockBlock(spec=ImageBlock, parent=MockBlock('yyyy')) 93 | }) 94 | 95 | #act 96 | ret = noteNameRewrite(nCl, 'yyyy 0123456789abcdef0123456789abcdef') 97 | 98 | #assert 99 | assert ret == ('yyyy', defaultBlockTime, defaultBlockTime) 100 | 101 | def test_noteNameRewrite_returns_imageblock_with_children(): 102 | '''HTTPError will skip''' 103 | #arrange 104 | nCl = MockClient({ 105 | '0123456789abcdef0123456789abcdef': MockBlock(spec=ImageBlock, children=[MockBlock('yyyy')]) 106 | }) 107 | 108 | #act 109 | ret = noteNameRewrite(nCl, 'yyyy 0123456789abcdef0123456789abcdef') 110 | 111 | #assert 112 | assert ret == ('yyyy', defaultBlockTime, defaultBlockTime) 113 | 114 | @patch('sys.stdout', new_callable=io.StringIO) 115 | def test_noteNameRewrite_returns_imageblock_with_multiple_children(mockStdout): 116 | '''HTTPError will skip''' 117 | #arrange 118 | nCl = MockClient({ 119 | '0123456789abcdef0123456789abcdef': MockBlock(spec=ImageBlock, children=[MockBlock('aaaa'), MockBlock('yyyy')]) 120 | }) 121 | 122 | #act 123 | ret = noteNameRewrite(nCl, 'yyyy 0123456789abcdef0123456789abcdef') 124 | 125 | #assert 126 | assert ret == (None, None, None) 127 | assert re.search(r"Failed", mockStdout.getvalue(), flags=re.IGNORECASE) 128 | assert re.search(r"Ambiguous", mockStdout.getvalue(), flags=re.IGNORECASE) 129 | 130 | @patch('sys.stdout', new_callable=io.StringIO) 131 | def test_noteNameRewrite_returns_imageblock_with_no_connection(mockStdout): 132 | '''HTTPError will skip''' 133 | #arrange 134 | nCl = MockClient({ 135 | '0123456789abcdef0123456789abcdef': MockBlock(spec=ImageBlock) 136 | }) 137 | 138 | #act 139 | ret = noteNameRewrite(nCl, 'yyyy 0123456789abcdef0123456789abcdef') 140 | 141 | #assert 142 | assert ret == (None, None, None) 143 | assert re.search(r"Failed", mockStdout.getvalue(), flags=re.IGNORECASE) 144 | 145 | def test_noteNameRewrite_long_names(): 146 | '''it will retruncate names from Notion''' 147 | #arrange 148 | nCl = MockClient({ 149 | '0123456789abcdef0123456789abcdef': MockBlock(title="abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz") 150 | }) 151 | 152 | #act 153 | ret = noteNameRewrite(nCl, 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx 0123456789abcdef0123456789abcdef') 154 | 155 | #assert 156 | assert ret == ('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz', defaultBlockTime, defaultBlockTime) 157 | 158 | def test_noteNameRewrite_icon_not_emoji(): 159 | '''it will not use the icon from the block if it's not an emoji''' 160 | #arrange 161 | nCl = MockClient({ 162 | '0123456789abcdef0123456789abcdef': MockBlock(icon="https://example.com") 163 | }) 164 | 165 | #act 166 | ret = noteNameRewrite(nCl, 'owo 0123456789abcdef0123456789abcdef') 167 | 168 | #assert 169 | assert ret == ('owo', defaultBlockTime, defaultBlockTime) 170 | 171 | def test_noteNameRewrite_icon_emoji(): 172 | '''it will not use the icon if it's an emoji, even if multiple unicode characters''' 173 | #arrange 174 | nCl = MockClient({ 175 | '0123456789abcdef0123456789abcdef': MockBlock(icon="🌲"), # Single code point 176 | '00000000000000000000000000000000': MockBlock(icon="🕳️") # Multiple code points "U+1F573, U+FE0F" 177 | }) 178 | 179 | #act 180 | ret = noteNameRewrite(nCl, 'owo 0123456789abcdef0123456789abcdef') 181 | ret2 = noteNameRewrite(nCl, 'owo 00000000000000000000000000000000') 182 | 183 | #assert 184 | assert ret == ('🌲 owo', defaultBlockTime, defaultBlockTime) 185 | assert ret2 == ('🕳️ owo', defaultBlockTime, defaultBlockTime) 186 | 187 | def test_noteNameRewrite_times(): 188 | '''it will get times from Notion as well, as datetime objects''' 189 | #arrange 190 | nCl = MockClient({ 191 | '0123456789abcdef0123456789abcdef': MockBlock(createdTime="1555555555000", lastEditedTime="16666666666777") 192 | }) 193 | 194 | #act 195 | ret = noteNameRewrite(nCl, 'owo 0123456789abcdef0123456789abcdef') 196 | 197 | #assert 198 | assert ret == ('owo', datetime.fromtimestamp(1555555555), datetime.fromtimestamp(16666666666.777)) 199 | 200 | 201 | 202 | def test_NotionExportRewriter_renameAndTimesWithNotion_no_rename(): 203 | '''it will not rename paths that dont match''' 204 | #arrange 205 | nCl = MockClient() 206 | rn = NotionExportRenamer(nCl, "") 207 | 208 | #act 209 | ret = rn.renameAndTimesWithNotion(os.path.join('a', 'b', 'c.png')) 210 | 211 | #assert 212 | assert ret == ('c.png', None, None) 213 | 214 | def test_NotionExportRewriter_renameAndTimesWithNotion_simple_rename(): 215 | '''it will rename normal paths''' 216 | #arrange 217 | nCl = MockClient({ 218 | '0123456789abcdef0123456789abcdef': MockBlock() 219 | }) 220 | rn = NotionExportRenamer(nCl, "") 221 | 222 | #act 223 | ret = rn.renameAndTimesWithNotion(os.path.join('a', 'b', 'c 0123456789abcdef0123456789abcdef.md')) 224 | 225 | #assert 226 | assert ret == ('c.md', defaultBlockTime, defaultBlockTime) 227 | 228 | def test_NotionExportRewriter_renameAndTimesWithNotion_merge_handle(): 229 | '''it will rename while handling collisions from previous conversions''' 230 | #arrange 231 | nCl = MockClient({ 232 | '0123456789abcdef0123456789abcdef': MockBlock(), 233 | }) 234 | rn = NotionExportRenamer(nCl, os.path.join(testsRoot, 'test_files', 'merge_handle')) 235 | 236 | #act 237 | ret = rn.renameAndTimesWithNotion('test 0123456789abcdef0123456789abcdef.md') 238 | 239 | #assert 240 | assert ret == (os.path.join('test','!index.md'), defaultBlockTime, defaultBlockTime) 241 | 242 | def test_NotionExportRewriter_renameAndTimesWithNotion_rename_collision_handle(): 243 | '''it will rename while handling collisions from previous conversions''' 244 | #arrange 245 | nCl = MockClient({ 246 | '0123456789abcdef0123456789abcdef': MockBlock(), 247 | '00000000000000000000000000000000': MockBlock(), 248 | '11111111111111111111111111111111': MockBlock(), 249 | }) 250 | rn = NotionExportRenamer(nCl, "") 251 | 252 | #act 253 | ret = rn.renameAndTimesWithNotion(os.path.join('a', 'b', 'c 0123456789abcdef0123456789abcdef.md')) 254 | ret2 = rn.renameAndTimesWithNotion(os.path.join('a', 'b', 'c 00000000000000000000000000000000.md')) 255 | ret3 = rn.renameAndTimesWithNotion(os.path.join('a', 'b', 'c 11111111111111111111111111111111.md')) 256 | 257 | #assert 258 | assert ret == ('c.md', defaultBlockTime, defaultBlockTime) 259 | assert ret2 == ('c (1).md', defaultBlockTime, defaultBlockTime) 260 | assert ret3 == ('c (2).md', defaultBlockTime, defaultBlockTime) 261 | 262 | def test_NotionExportRewriter_renameWithNotion_simple_rename(): 263 | '''it will rename if path matches and only return name''' 264 | #arrange 265 | nCl = MockClient({ 266 | '0123456789abcdef0123456789abcdef': MockBlock(), 267 | }) 268 | rn = NotionExportRenamer(nCl, "") 269 | 270 | #act 271 | ret = rn.renameWithNotion(os.path.join('a', 'b', 'c 0123456789abcdef0123456789abcdef.md')) 272 | 273 | #assert 274 | assert ret == 'c.md' 275 | 276 | def test_NotionExportRewriter_renamePathWithNotion_simple_rename(): 277 | '''it will rename a full path''' 278 | #arrange 279 | nCl = MockClient({ 280 | '0123456789abcdef0123456789abcdef': MockBlock(), 281 | '00000000000000000000000000000000': MockBlock(), 282 | '11111111111111111111111111111111': MockBlock(), 283 | }) 284 | rn = NotionExportRenamer(nCl, os.path.join('x', 'y')) 285 | 286 | #act 287 | ret = rn.renamePathWithNotion( \ 288 | os.path.join('a 11111111111111111111111111111111', 'b 00000000000000000000000000000000', 'c 0123456789abcdef0123456789abcdef.md')) 289 | 290 | #assert 291 | assert ret == os.path.join('a', 'b', 'c.md') 292 | 293 | def test_NotionExportRewriter_renamePathAndTimesWithNotion_simple_rename(): 294 | '''it will rename a full path''' 295 | #arrange 296 | nCl = MockClient({ 297 | '0123456789abcdef0123456789abcdef': MockBlock(createdTime="1000000000000", lastEditedTime="1111111111000"), 298 | '00000000000000000000000000000000': MockBlock(createdTime="1555555555000", lastEditedTime="1666666666000"), 299 | '11111111111111111111111111111111': MockBlock(createdTime="1555555555000", lastEditedTime="1666666666000"), 300 | }) 301 | rn = NotionExportRenamer(nCl, os.path.join('x', 'y')) 302 | 303 | #act 304 | ret = rn.renamePathAndTimesWithNotion( \ 305 | os.path.join('a 11111111111111111111111111111111', 'b 00000000000000000000000000000000', 'c 0123456789abcdef0123456789abcdef.md')) 306 | 307 | #assert 308 | assert ret == (os.path.join('a', 'b', 'c.md'), datetime.fromtimestamp(1000000000), datetime.fromtimestamp(1111111111)) 309 | 310 | def test_mdFileRewrite_no_op(): 311 | '''it will do nothing to md files by default''' 312 | md = """# I'm really good at taking copypastas from reddit and putting them in 313 | 314 | Now, this is a stowy aww about how My wife got fwipped-tuwned upside down And I'd wike to take a minute Just sit wight thewe I'ww teww you how I became the pwince of a town cawwed Bew Aiw In west Phiwadewphia bown and waised On the pwaygwound was whewe I spent most of my days Chiwwin' out maxin' wewaxin' aww coow And aww shootin some b-baww outside of the schoow When a coupwe of guys who wewe up to no good Stawted making twoubwe in my neighbowhood I got in one wittwe fight and my mom got scawed She said 'You'we movin' with youw auntie and uncwe in Bew Aiw' 315 | 316 | I begged and pweaded with hew day aftew day But she packed my suit case and sent me on my way She gave me a kiss and then she gave me my ticket. I put my Wawkman on and said, 'I might as weww kick it'. 317 | 318 | Fiwst cwass, yo this is bad Dwinking owange juice out of a champagne gwass. Is this what the peopwe of Bew-Aiw wiving wike? Hmmmmm this might be awwight. 319 | """ 320 | nCl = MockClient() 321 | rn = NotionExportRenamer(nCl, '') 322 | 323 | #act 324 | ret = mdFileRewrite(rn, os.path.join('a', 'b', 'c.md'), mdFileContents=md) 325 | 326 | #assert 327 | assert ret == md 328 | 329 | def test_mdFileRewrite_remove_top_h1(): 330 | '''it will remove the top h1 if configured to''' 331 | md = """# Copypasta 332 | 333 | Okay but for real this is not one of those copy-ma-pastas that you people are taking about. Spaghetti 334 | """ 335 | nCl = MockClient() 336 | rn = NotionExportRenamer(nCl, '') 337 | 338 | #act 339 | ret = mdFileRewrite(rn, os.path.join('a', 'b', 'c.md'), mdFileContents=md, removeTopH1=True) 340 | 341 | #assert 342 | assert ret == """ 343 | Okay but for real this is not one of those copy-ma-pastas that you people are taking about. Spaghetti 344 | """ 345 | 346 | def test_mdFileRewrite_rewrite_paths(): 347 | '''it will rewrite paths in the markdown if passed''' 348 | md = """# Things to do with my time 349 | 350 | Okay but really, do you think that me writing this was a good use of time? 351 | 352 | [not really](https://example.com). But you know what is a good use of my time? Probably going to the grocery store and getting some food. 353 | 354 | What do I need? Maybe something [off my grocery list](Grocery%20List%200123456789abcdef0123456789abcdef.md). 355 | 356 | And here's another line just for fun 357 | """ 358 | nCl = MockClient({ 359 | '0123456789abcdef0123456789abcdef': MockBlock(), 360 | }) 361 | rn = NotionExportRenamer(nCl, '') 362 | 363 | #act 364 | ret = mdFileRewrite(rn, os.path.join('a', 'b', 'c.md'), mdFileContents=md, rewritePaths=True) 365 | 366 | #assert 367 | assert ret == """# Things to do with my time 368 | 369 | Okay but really, do you think that me writing this was a good use of time? 370 | 371 | [not really](https://example.com). But you know what is a good use of my time? Probably going to the grocery store and getting some food. 372 | 373 | What do I need? Maybe something [off my grocery list](Grocery%20List.md). 374 | 375 | And here's another line just for fun 376 | """ 377 | 378 | def test_mdFileRewrite_rewrite_path_complex(): 379 | '''it will rewrite paths in the markdown if passed, but more complex''' 380 | md = """# Pathssss 381 | 382 | [owo](../d%200123456789abcdef0123456789abcdef.md) [ewe](../e%2044444444444444444444444444444444.md) 383 | 384 | [uwu](im%2000000000000000000000000000000000/gay%2011111111111111111111111111111111.md) 385 | 386 | [vwv](../cute%2022222222222222222222222222222222/girls%2033333333333333333333333333333333.md) 387 | """ 388 | nCl = MockClient({ 389 | '0123456789abcdef0123456789abcdef': MockBlock(), 390 | '00000000000000000000000000000000': MockBlock(), 391 | '11111111111111111111111111111111': MockBlock(), 392 | '22222222222222222222222222222222': MockBlock(), 393 | '33333333333333333333333333333333': MockBlock(), 394 | '44444444444444444444444444444444': MockBlock(), 395 | }) 396 | rn = NotionExportRenamer(nCl, '') 397 | 398 | #act 399 | ret = mdFileRewrite(rn, os.path.join('a', 'b', 'c.md'), mdFileContents=md, rewritePaths=True) 400 | 401 | #assert 402 | assert ret == """# Pathssss 403 | 404 | [owo](../d.md) [ewe](../e.md) 405 | 406 | [uwu](im/gay.md) 407 | 408 | [vwv](../cute/girls.md) 409 | """ 410 | 411 | def test_rewriteNotionZip_simple(): 412 | '''it will rewrite an entire zip file (simple, 1 file, 1 id, no special markdown)''' 413 | nCl = MockClient({ 414 | '0123456789abcdef0123456789abcdef': MockBlock(createdTime="1000000000000", lastEditedTime="1609459200000"), #1/1/2021 12:00:00 AM 415 | }) 416 | 417 | #act 418 | outputFilePath = rewriteNotionZip(nCl, os.path.join(testsRoot, 'test_files', 'zip_simple.zip')) 419 | 420 | #assert 421 | with zipfile.ZipFile(outputFilePath) as zf: 422 | assert zf.testzip() == None 423 | assert zf.namelist() == ['test.md'] 424 | assert zf.open('test.md').read().decode('utf-8') == "Simple zip test" 425 | i = zf.getinfo('test.md') 426 | assert i.date_time == datetime.fromtimestamp(1609459200).timetuple()[0:6] 427 | 428 | def test_rewriteNotionZip_complex(): 429 | '''it will rewrite an entire zip file (simple, 1 file, 1 id, no special markdown)''' 430 | nCl = MockClient({ 431 | '0123456789abcdef0123456789abcdef': MockBlock(lastEditedTime="1609459200000"), #1/1/2021 12:00:00 AM 432 | '00000000000000000000000000000000': MockBlock(lastEditedTime="1609459200000"), #1/1/2021 12:00:00 AM 433 | '11111111111111111111111111111111': MockBlock(icon="📟", lastEditedTime="1609459200000"), #1/1/2021 12:00:00 AM 434 | }) 435 | 436 | #act 437 | outputFilePath = rewriteNotionZip(nCl, os.path.join(testsRoot, 'test_files', 'zip_complex.zip')) 438 | 439 | #assert 440 | with zipfile.ZipFile(outputFilePath) as zf: 441 | assert zf.testzip() == None 442 | assert set(zf.namelist()) == set(['beep/!index.md', 'beep/📟 types.md', 'device.md', 'something_else.csv']) 443 | assert zf.open('beep/!index.md').read().decode('utf-8') == """# Beep 444 | 445 | A tiny little sound 446 | it feels ever so comforting 447 | an impulse and then it's gone 448 | but the effect ripples through your body 449 | 450 | [Types](%F0%9F%93%9F%20types.md)""" 451 | assert zf.open('device.md').read().decode('utf-8') == """You come across a small device 452 | It's covered in ash and debris 453 | The smooth body has faint ridges that trace your palms 454 | Until it gives and it lets out a soft 455 | [_Beep_](beep/%21index.md)""" 456 | assert zf.open('beep/📟 types.md').read().decode('utf-8') == """# Types of beeps 457 | 458 | * Small 459 | * Soft 460 | * Agile 461 | 462 | [beep](../device.md)""" --------------------------------------------------------------------------------