├── .gitignore ├── CHANGELOG.md ├── Extension - CHANGELOG.md ├── LICENSE.md ├── Python-Server └── app │ ├── .dockerignore │ ├── Dockerfile │ ├── background_worker │ ├── background_worker.py │ └── task │ │ ├── remove_task.py │ │ ├── task.py │ │ └── task_manager.py │ ├── docker-compose.yml │ ├── image_tagging │ ├── clarifai_tagging │ │ └── clarifai_tagging.py │ ├── image_tagging.py │ └── tensorflow_tagging │ │ └── tensorflow_tagging.py │ ├── notion │ ├── __init__.py │ ├── block.py │ ├── client.py │ ├── collection.py │ ├── logger.py │ ├── maps.py │ ├── markdown.py │ ├── monitor.py │ ├── operations.py │ ├── records.py │ ├── settings.py │ ├── smoke_test.py │ ├── space.py │ ├── store.py │ ├── user.py │ └── utils.py │ ├── notion_ai │ ├── custom_errors.py │ ├── mind_structure.py │ ├── notion_ai.py │ ├── property_manager │ │ ├── multi_tag_manager.py │ │ ├── property_manager.py │ │ └── tag_object.py │ └── utils.py │ ├── requirements.txt │ ├── server.py │ ├── server_utils │ ├── check_update.py │ ├── handle_options_data.py │ └── utils.py │ ├── static │ ├── error.html │ ├── icon.png │ ├── thank_you.html │ ├── translations │ │ ├── en_US.json │ │ └── es_ES.json │ └── version.cfg │ ├── templates │ └── options.html │ └── translation │ └── translation_manager.py ├── README.md ├── doc ├── add_image.gif ├── add_text.gif ├── add_website.gif ├── clarifai.png ├── collections-example-notion-page.png ├── collections-example.png ├── customize_properties_name.png ├── extension_howto.png ├── get_structure_url.png ├── getting_cookie.png ├── header_collections_example.gif ├── header_collections_example_modify_title_tags.gif ├── header_gif_joined_updated.gif ├── header_gif_joined_updated_collections.gif ├── header_gif_search.gif ├── header_phone_demo.gif ├── notion-database-howto.png ├── options_python.png ├── qr_code_example.png ├── refresh-collections-menu.png ├── server_url_example.png └── settings_howto.png ├── flutter-android-ios-app └── notion_ai_my_mind │ ├── .gitignore │ ├── .metadata │ ├── README.md │ ├── android │ ├── .gitignore │ ├── app │ │ ├── build.gradle │ │ └── src │ │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ │ ├── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── elblogbruno │ │ │ │ │ └── notion_ai_my_mind │ │ │ │ │ └── MainActivity.kt │ │ │ └── res │ │ │ │ ├── drawable │ │ │ │ └── launch_background.xml │ │ │ │ ├── mipmap-hdpi │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ └── ic_launcher.png │ │ │ │ ├── values │ │ │ │ └── styles.xml │ │ │ │ └── xml │ │ │ │ └── network_security_config.xml │ │ │ └── profile │ │ │ └── AndroidManifest.xml │ ├── build.gradle │ ├── gradle.properties │ ├── gradle │ │ └── wrapper │ │ │ └── gradle-wrapper.properties │ ├── settings.gradle │ └── settings_aar.gradle │ ├── ios │ ├── .gitignore │ ├── Flutter │ │ ├── AppFrameworkInfo.plist │ │ ├── Debug.xcconfig │ │ └── Release.xcconfig │ ├── Runner.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ └── WorkspaceSettings.xcsettings │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── Runner │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ │ └── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ └── README.md │ │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ │ ├── Info.plist │ │ └── Runner-Bridging-Header.h │ ├── pubspec.lock │ ├── pubspec.yaml │ └── test │ └── widget_test.dart ├── icon.png ├── readme-translations └── README.es.md └── setup.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/images,video 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=images,video 3 | 4 | /Chrome and Firefox Extension 5 | *.jks 6 | key.properties 7 | /Python-Server/output 8 | /Python-Server/.idea 9 | /Python-Server/app/image_tagging/temp_image_folder 10 | /Python-Server/app/__pycache__ 11 | /Python-Server/app/uploads 12 | 13 | 14 | *.zip 15 | *.log 16 | *.json 17 | !Python-Server/app/static/translations/*.json 18 | !Chrome and Firefox Extension/*.json 19 | ### Images ### 20 | # JPEG 21 | *.jpg 22 | *.jpeg 23 | *.jpe 24 | *.jif 25 | *.jfif 26 | *.jfi 27 | 28 | # JPEG 2000 29 | *.jp2 30 | *.j2k 31 | *.jpf 32 | *.jpx 33 | *.jpm 34 | *.mj2 35 | 36 | # JPEG XR 37 | *.jxr 38 | *.hdp 39 | *.wdp 40 | 41 | 42 | 43 | # RAW 44 | *.raw 45 | 46 | # Web P 47 | *.webp 48 | 49 | 50 | # Animated Portable Network Graphics 51 | *.apng 52 | 53 | # Multiple-image Network Graphics 54 | *.mng 55 | 56 | # Tagged Image File Format 57 | *.tiff 58 | *.tif 59 | 60 | # Scalable Vector Graphics 61 | *.svg 62 | *.svgz 63 | 64 | # Portable Document Format 65 | *.pdf 66 | 67 | # X BitMap 68 | *.xbm 69 | 70 | # BMP 71 | *.bmp 72 | *.dib 73 | 74 | # ICO 75 | *.ico 76 | 77 | # 3D Images 78 | *.3dm 79 | *.max 80 | 81 | ### Video ### 82 | *.3g2 83 | *.3gp 84 | *.asf 85 | *.asx 86 | *.avi 87 | *.flv 88 | *.mov 89 | *.mp4 90 | *.mpg 91 | *.rm 92 | *.swf 93 | *.vob 94 | *.wmv 95 | *.webm 96 | 97 | # End of https://www.toptal.com/developers/gitignore/api/images,video 98 | 99 | # Created by https://www.toptal.com/developers/gitignore/api/python 100 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 101 | 102 | ### Python ### 103 | # Byte-compiled / optimized / DLL files 104 | __pycache__/ 105 | *.py[cod] 106 | *$py.class 107 | 108 | # C extensions 109 | *.so 110 | 111 | # Distribution / packaging 112 | .Python 113 | build/ 114 | develop-eggs/ 115 | dist/ 116 | downloads/ 117 | eggs/ 118 | .eggs/ 119 | lib/ 120 | lib64/ 121 | parts/ 122 | sdist/ 123 | var/ 124 | wheels/ 125 | pip-wheel-metadata/ 126 | share/python-wheels/ 127 | *.egg-info/ 128 | .installed.cfg 129 | *.egg 130 | MANIFEST 131 | 132 | # PyInstaller 133 | # Usually these files are written by a python script from a template 134 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 135 | *.manifest 136 | *.spec 137 | 138 | # Installer logs 139 | pip-log.txt 140 | pip-delete-this-directory.txt 141 | 142 | # Unit test / coverage reports 143 | htmlcov/ 144 | .tox/ 145 | .nox/ 146 | .coverage 147 | .coverage.* 148 | .cache 149 | nosetests.xml 150 | coverage.xml 151 | *.cover 152 | *.py,cover 153 | .hypothesis/ 154 | .pytest_cache/ 155 | pytestdebug.log 156 | 157 | # Translations 158 | *.mo 159 | *.pot 160 | 161 | # Django stuff: 162 | *.log 163 | local_settings.py 164 | db.sqlite3 165 | db.sqlite3-journal 166 | 167 | # Flask stuff: 168 | instance/ 169 | .webassets-cache 170 | 171 | # Scrapy stuff: 172 | .scrapy 173 | 174 | # Sphinx documentation 175 | docs/_build/ 176 | doc/_build/ 177 | 178 | # PyBuilder 179 | target/ 180 | 181 | # Jupyter Notebook 182 | .ipynb_checkpoints 183 | 184 | # IPython 185 | profile_default/ 186 | ipython_config.py 187 | 188 | # pyenv 189 | .python-version 190 | 191 | # pipenv 192 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 193 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 194 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 195 | # install all needed dependencies. 196 | #Pipfile.lock 197 | 198 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 199 | __pypackages__/ 200 | 201 | # Celery stuff 202 | celerybeat-schedule 203 | celerybeat.pid 204 | 205 | # SageMath parsed files 206 | *.sage.py 207 | 208 | # Environments 209 | .env 210 | .venv 211 | env/ 212 | venv/ 213 | ENV/ 214 | env.bak/ 215 | venv.bak/ 216 | pythonenv* 217 | 218 | # Spyder project settings 219 | .spyderproject 220 | .spyproject 221 | 222 | # Rope project settings 223 | .ropeproject 224 | 225 | # mkdocs documentation 226 | /site 227 | 228 | # mypy 229 | .mypy_cache/ 230 | .dmypy.json 231 | dmypy.json 232 | 233 | # Pyre type checker 234 | .pyre/ 235 | 236 | # pytype static type analyzer 237 | .pytype/ 238 | 239 | # profiling data 240 | .prof 241 | 242 | # End of https://www.toptal.com/developers/gitignore/api/python 243 | 244 | 245 | 246 | # Created by https://www.toptal.com/developers/gitignore/api/pycharm 247 | # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm 248 | 249 | ### PyCharm ### 250 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 251 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 252 | 253 | # User-specific stuff 254 | .idea/**/workspace.xml 255 | .idea/**/tasks.xml 256 | .idea/**/usage.statistics.xml 257 | .idea/**/dictionaries 258 | .idea/**/shelf 259 | 260 | # Generated files 261 | .idea/**/contentModel.xml 262 | 263 | # Sensitive or high-churn files 264 | .idea/**/dataSources/ 265 | .idea/**/dataSources.ids 266 | .idea/**/dataSources.local.xml 267 | .idea/**/sqlDataSources.xml 268 | .idea/**/dynamic.xml 269 | .idea/**/uiDesigner.xml 270 | .idea/**/dbnavigator.xml 271 | 272 | # Gradle 273 | .idea/**/gradle.xml 274 | .idea/**/libraries 275 | 276 | # Gradle and Maven with auto-import 277 | # When using Gradle or Maven with auto-import, you should exclude module files, 278 | # since they will be recreated, and may cause churn. Uncomment if using 279 | # auto-import. 280 | # .idea/artifacts 281 | # .idea/compiler.xml 282 | # .idea/jarRepositories.xml 283 | # .idea/modules.xml 284 | # .idea/*.iml 285 | # .idea/modules 286 | # *.iml 287 | # *.ipr 288 | 289 | # CMake 290 | cmake-build-*/ 291 | 292 | # Mongo Explorer plugin 293 | .idea/**/mongoSettings.xml 294 | 295 | # File-based project format 296 | *.iws 297 | 298 | # IntelliJ 299 | out/ 300 | 301 | # mpeltonen/sbt-idea plugin 302 | .idea_modules/ 303 | 304 | # JIRA plugin 305 | atlassian-ide-plugin.xml 306 | 307 | # Cursive Clojure plugin 308 | .idea/replstate.xml 309 | 310 | # Crashlytics plugin (for Android Studio and IntelliJ) 311 | com_crashlytics_export_strings.xml 312 | crashlytics.properties 313 | crashlytics-build.properties 314 | fabric.properties 315 | 316 | # Editor-based Rest Client 317 | .idea/httpRequests 318 | 319 | # Android studio 3.1+ serialized cache file 320 | .idea/caches/build_file_checksums.ser 321 | 322 | ### PyCharm Patch ### 323 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 324 | 325 | # *.iml 326 | # modules.xml 327 | # .idea/misc.xml 328 | # *.ipr 329 | 330 | # Sonarlint plugin 331 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 332 | .idea/**/sonarlint/ 333 | 334 | # SonarQube Plugin 335 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 336 | .idea/**/sonarIssues.xml 337 | 338 | # Markdown Navigator plugin 339 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 340 | .idea/**/markdown-navigator.xml 341 | .idea/**/markdown-navigator-enh.xml 342 | .idea/**/markdown-navigator/ 343 | 344 | # Cache file creation bug 345 | # See https://youtrack.jetbrains.com/issue/JBR-2257 346 | .idea/$CACHE_FILE$ 347 | 348 | # CodeStream plugin 349 | # https://plugins.jetbrains.com/plugin/12206-codestream 350 | .idea/codestream.xml 351 | 352 | # End of https://www.toptal.com/developers/gitignore/api/pycharm 353 | 354 | 355 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode 356 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode 357 | 358 | ### VisualStudioCode ### 359 | .vscode/* 360 | !.vscode/tasks.json 361 | !.vscode/launch.json 362 | *.code-workspace 363 | 364 | ### VisualStudioCode Patch ### 365 | # Ignore all local history of files 366 | .history 367 | .ionide 368 | 369 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.0.6] 9 | 10 | ### Added 11 | - Now you can add a reminder to the content, so you get whatever you added reminded on the future whenever you want. If you select the Autodestroy option, booom , content will be auto-deleted from your mind at that specified time (Wish you had that in real life right?) pd: you can restore deleted notion content from the trash bin :wink:. 12 | - Added a background worker that maintains the database, cleaning blank content added that fills the mind with garbage. (Check for duplicated content coming soon) 13 | - Android app redesign as well, with bug fixes and less immersive experience. 14 | 15 | ### Fixed 16 | - More error handling and structure fix to make everything faster and reliable. 17 | 18 | ## [2.0.5] 19 | 20 | ### Added 21 | 22 | - Now you can add Video files from web or phone! 23 | - Started the base for adding a background worker that maintains the database, cleaning duplicated or blank content added. (Coming soon) 24 | - Tags added from the AI tagging system have a number filter, so they are not all sent at the same time only the 10 first that are more used! 25 | 26 | ### Fixed 27 | - More error handling and structure fix to make everything faster. 28 | - Bug fix https://github.com/elblogbruno/NotionAI-MyMind/issues/20 29 | 30 | ## [2.0.4] 31 | 32 | ### Added 33 | 34 | - Now you can add Audio files from web or phone! 35 | - Added multilanguage-support! Now server, phone app and extension is translated to English and Spanish! Would you like to have it translated into your own language? You can have more info on how to collaborate it helps out people from all comunities and languages access this amazing tool! More info [here](https://github.com/elblogbruno/NotionAI-MyMind-Translations). 36 | 37 | - Chrome extension notifies you when there is a new update on the server! 38 | - The server tells you now if there is a newer version of it on startup! 39 | - Started the base for adding the ability to add notion reminders to content so it is reminded to you in the future. (Coming soon) 40 | - Tags added from the AI tagging system are also suggested to you now when adding tags! 41 | 42 | ### Fixed 43 | - Bug when first image was invalid, it did not add the next ones on the list. 44 | - Bug that when adding content from phone, mind_extension property tell browser-extension instead of phone-extension 45 | - More error handling 46 | - Bug fix https://github.com/elblogbruno/NotionAI-MyMind/discussions/17 47 | ## [2.0.3] 48 | 49 | ### Breaking changes 50 | - Now you can tell on the server the name of the properties, in case you want to customize the name of the basic properties that Notion AI My Mind contacts. 51 | 52 | ### Added 53 | 54 | - Now you can modify the title and url of the added content on the fly from the mind Extensions. 55 | - You can add and remove multiple tags on the content you add. The tags you write will be saved to notion, so they will be suggested to you next time as you write. 56 | It also gets the current tags you may have added in notion for that property, and suggests them to you as you write! 57 | - All this implements the ideas given by the community on #13 and #8. 58 | 59 | - Added ability to scan QR Code on the android app, so you configure the app in no-time. When you access the server, you can scan it with the phone app. 60 | - Fixed bugs and revamped the phone app UI. 61 | - Added more error handling on the phone app. 62 | ### Fixed 63 | 64 | - More error handling 65 | 66 | ## [2.0.2] 67 | 68 | ### Breaking changes 69 | - Notion API has changed. Maybe you get this error: "400 error on get_block() call",the fix is in here https://github.com/jamalex/notion-py/issues/292 but you need to modify manually the library. Meanwhile I am waiting for the library owner to update it. 70 | - If you make a lot of request to notion API, means adding lot of content in a short amount of time, you may get now a 429 error(Too Many Requests). I may look into finding a solution or workaround to this, but it is a problem on notion's not my repo or @jamalex notion-py repo. 71 | 72 | ### Added 73 | 74 | - Added collections! Now you can have different collections or databases, so you can add the content to the collection you choose. More info here as you need to change your current notion setup: https://github.com/elblogbruno/NotionAI-MyMind/wiki/Notion-AI-My-Mind-Collections 75 | 76 | -Android app has updated to allow the new collections! 77 | 78 | ### Fixed 79 | 80 | - Added more exceptions for error handling. 81 | - Added notification for Notion's recent API change that limits api requests. 82 | 83 | ## [2.0.1] 84 | 85 | ### Added 86 | 87 | - Added function to modify a row title and url by its id, this is a base for developing ideas on #8. 88 | - Now it looks at all images available on the content added so we can get more information and AI tag every image. 89 | - Click to open added content works for url's, texts and images now. 90 | - When you add content, it tracks if it was added from your phone or desktop. You need to add a new text atribute called "mind_extension". You can use this to filter by elements added from phone or desktop. 91 | ### Fixed 92 | 93 | - Fixed error #9 94 | - Fixed error no output provided when url or title was none adding a url. 95 | - Fixed error when on some websites where tagged as image not found when images were found. 96 | - Moved utils function to a util file. 97 | 98 | ## [2.0] 99 | 100 | ### Added 101 | 102 | - Added server as a .exe program on windows so it is easier as ever to run Notion AI My Mind in simple clicks. 103 | - Server url is opened automatically on the browser, so you don't get lost if you don't know what the server url is. 104 | 105 | 106 | ### Fixed 107 | 108 | - No port was returned at first run of the server. 109 | - Refactored python directory, organized with folders. 110 | 111 | ## [1.9] 112 | 113 | ### Added 114 | 115 | - Faster and more reliable thanks to switching to an async based server with Quart. 116 | - Added json responses on server with built-in responses that would be translated to other languages. When requesting to the API, it returns the same text and status code on chrome, firefox and android/ios platforms. 117 | - Updated app to work with these changes. 118 | - When clicking on the message box, you can go to the url of the added content and see it. 119 | 120 | ### Fixed 121 | 122 | - Refactored code, made it easier for example to implement in the near future a way to translate to different languages, and removed unused code. 123 | - No message was shown when trying to add something witouth having entered credentials. 124 | 125 | ## [1.8] 126 | 127 | ### Added 128 | 129 | - Added initial Android and Ios App based on flutter. You can download from Play Store here: https://play.google.com/store/apps/details?id=com.elblogbruno.notion_ai_my_mind 130 | - Disclaimer : I won't be releasing the app on the Apple App Store, as I don't have an Apple Developer Account either Mac OS based computer. 131 | 132 | ### Fixed 133 | 134 | - Fixed server issues to be able to add images by posting, so I could add local images from phone. 135 | 136 | ## [1.7] 137 | 138 | ### Added 139 | 140 | - You can log in with email and password. (You need to enable a password on notion website settings.) For security, each time you reload the server you need to log in again. This is because email and password is not saved anywhere. You hear correctly. 141 | - New revamped design and logic of the extension. Now its nicer, and shows always, even adding frome context menu. If you liked it, be careful with this update, you'll love it. 142 | 143 | ### Fixed 144 | 145 | - Fixed server issues adding callback capabilities. Before some websites images were not added due to timeouts. 146 | 147 | [2.0.6]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0.6 148 | [2.0.5]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0.5 149 | [2.0.4]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0.4 150 | [2.0.3]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0.3 151 | [2.0.2]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0.2 152 | [2.0.1]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0.1 153 | [2.0]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/2.0 154 | [1.9]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/1.9 155 | [1.8]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/1.8 156 | [1.7]: https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/1.7 157 | -------------------------------------------------------------------------------- /Extension - CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.1.3] 9 | 10 | ### Fixed 11 | 12 | - Removed unused code. 13 | - Fixed bugs. 14 | ## [1.1.2] 15 | 16 | ### Fixed 17 | 18 | - Removed unused code. 19 | - Fixed bugs. 20 | 21 | ## [1.1.1] 22 | 23 | ### Added 24 | 25 | - It notifies you when there is an update on the server. 26 | - Added option to reload the collections from the menu and close the menu. 27 | - Added Spanish language. More info on how to collaborate in translating [here](https://github.com/elblogbruno/NotionAI-MyMind-Translations) 28 | - Added tag colors as on phone App. 29 | ### Fixed 30 | 31 | - Removed unused code. 32 | - Fixed bugs. 33 | 34 | ## [1.1.0] 35 | 36 | ### Added 37 | 38 | - Added option to modify the title and url of the added content on the fly! 39 | - Added option to add and removes tags to a block. It also gets the current tags from notion, and suggests them to you as you write! 40 | - When you save the settings now, it redirects you to the server and autocompletes the tokenv2, this tries to make it easier for new people to join. 41 | - Revamped the design of the collections popup. 42 | 43 | ### Fixed 44 | 45 | - Removed unused code. 46 | - Fixed bugs. 47 | - Now it shows a loader when waiting that gives more feedback. 48 | 49 | ## [1.0.9] 50 | 51 | ### Added 52 | 53 | - Added collections! Now you can have different collections or databases, so you can add the content to the collection you choose. More info here as you need to change your current notion setup: https://github.com/elblogbruno/NotionAI-MyMind/wiki/Notion-AI-My-Mind-Collections 54 | 55 | 56 | ### Fixed 57 | 58 | - Removed unused code and permissions. 59 | - Now it checks if you enter an incorrect server url. 60 | 61 | ## [1.0.8] 62 | 63 | ### Added 64 | 65 | - Welcome landing page when extension is installed 66 | - Changelog page opens when extension is updated 67 | 68 | ### Fixed 69 | 70 | - Fixed a bug when adding an image showed and alert with the url. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bruno Moya Ruiz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Python-Server/app/.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.classpath 3 | **/.dockerignore 4 | **/.env 5 | **/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/settings 10 | **/.toolstarget 11 | **/.vs 12 | **/.vscode 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/azds.yaml 17 | **/bin 18 | **/output 19 | **/charts 20 | **/docker-compose* 21 | **/Dockerfile* 22 | **/node_modules 23 | **/npm-debug.log 24 | **/obj 25 | **/secrets.dev.yaml 26 | **/values.dev.yaml 27 | venv/ 28 | README.md 29 | -------------------------------------------------------------------------------- /Python-Server/app/Dockerfile: -------------------------------------------------------------------------------- 1 | # For more information, please refer to https://aka.ms/vscode-docker-python 2 | FROM python:3.8-slim-buster 3 | 4 | # Keeps Python from generating .pyc files in the container 5 | ENV PYTHONDONTWRITEBYTECODE=1 6 | 7 | # Turns off buffering for easier container logging 8 | ENV PYTHONUNBUFFERED=1 9 | 10 | # Install pip requirements 11 | COPY requirements.txt . 12 | RUN apt-get update 13 | RUN apt-get install git -y 14 | RUN python -m pip install -r requirements.txt 15 | 16 | WORKDIR /app 17 | COPY . /app 18 | 19 | CMD python server.py -------------------------------------------------------------------------------- /Python-Server/app/background_worker/background_worker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from urllib.error import HTTPError 4 | 5 | import schedule 6 | import time 7 | import threading 8 | import sys 9 | import os 10 | import tempfile 11 | 12 | from background_worker.task.task_manager import TaskManager 13 | 14 | dir_path = 'background_worker.log' 15 | if getattr(sys, 'frozen', False): 16 | tmp = tempfile.mkdtemp() 17 | dir_path = os.path.join(tmp, 'notion-ai-app.log') 18 | 19 | logging.basicConfig(filename=dir_path, filemode='w', format='%(name)s - %(levelname)s - %(message)s', 20 | level=logging.INFO) 21 | 22 | # # define a Handler which writes INFO messages or higher to the sys.stderr 23 | # console = logging.StreamHandler() 24 | # console.setLevel(logging.INFO) 25 | # # add the handler to the root logger 26 | # logging.getLogger('').addHandler(console) 27 | logging.info("Log file will be saved to temporary path: {0}".format(dir_path)) 28 | 29 | class Worker: 30 | def __init__(self, client, notion_ai): 31 | self.client = client 32 | self.notion_ai = notion_ai 33 | self.task_manager = TaskManager(logging, notion_ai) 34 | self.logging = logging 35 | # self.background_job() 36 | schedule.every(5).minutes.do(self.background_job) 37 | 38 | # Start the background thread 39 | self.stop_run_continuously = self.run_continuously() 40 | # 41 | # Do some other things... 42 | # time.sleep(10) 43 | # 44 | # Stop the background thread 45 | # stop_run_continuously.set() 46 | 47 | def run_continuously(self, interval=1): 48 | """Continuously run, while executing pending jobs at each 49 | elapsed time interval. 50 | @return cease_continuous_run: threading. Event which can 51 | be set to cease continuous run. Please note that it is 52 | *intended behavior that run_continuously() does not run 53 | missed jobs*. For example, if you've registered a job that 54 | should run every minute and you set a continuous run 55 | interval of one hour then your job won't be run 60 times 56 | at each interval but only once. 57 | """ 58 | cease_continuous_run = threading.Event() 59 | 60 | class ScheduleThread(threading.Thread): 61 | @classmethod 62 | def run(cls): 63 | while not cease_continuous_run.is_set(): 64 | schedule.run_pending() 65 | time.sleep(interval) 66 | 67 | continuous_thread = ScheduleThread() 68 | continuous_thread.start() 69 | return cease_continuous_run 70 | 71 | def myFunc(self, e): 72 | return e.title == "" 73 | 74 | def _remove_blank_rows(self): 75 | number_of_collections = self.notion_ai.mind_structure.get_number_of_collections() 76 | for i in range(0, number_of_collections): 77 | collection_id, id = self.notion_ai.mind_structure.get_collection_by_index( 78 | i) # collection is our database or "mind" in notion, as be have multiple, if not suplied, it will get the first one as the priority one. 79 | collection = self.client.get_collection(collection_id=collection_id) 80 | 81 | cv = collection.parent.views[0] 82 | self.logging.info("Analysing Collection #{0}".format(i)) 83 | # Run a "filtered" query (inspect network tab in browser for examples, on queryCollection calls) 84 | filter_params = { 85 | "filters": [{ 86 | "filter": { 87 | "operator": "is_empty" 88 | }, 89 | "property": "title" 90 | }], 91 | "operator": "and" 92 | } 93 | result = cv.build_query(filter=filter_params).execute() 94 | 95 | self.logging.info("Analyzing this elements #{0}".format(result)) 96 | 97 | self._remove_blank_blocks_from_list(result) 98 | 99 | self.logging.info("Background work remove blank rows finished at {0}".format(datetime.now())) 100 | 101 | def _remove_blank_blocks_from_list(self, list): 102 | for block in list: 103 | bl = self.client.get_block(block.id) 104 | title = bl.title 105 | url = bl.url 106 | multi_tag = self.notion_ai.property_manager.get_properties(bl, multi_tag_property=1) 107 | ai_tags = self.notion_ai.property_manager.get_properties(bl, ai_tags_property=1) 108 | mind_extension = self.notion_ai.property_manager.get_properties(bl, mind_extension_property=1) 109 | 110 | if len(title) == 0 and len(url) == 0 and len(multi_tag) == 0 and len(ai_tags) == 0 and len( 111 | mind_extension) == 0: 112 | self.logging.info("Removing block with id: {0} as it is blank , title: {1}".format(bl.id, bl.title)) 113 | self.logging.info("Have a look at this if you feel something bad was deleted") 114 | bl.remove() 115 | 116 | def _do_assigned_temporal_tasks(self): 117 | now = datetime.now() 118 | for task in self.task_manager.tasks: 119 | minutes_diff = (task.datetime_to_run - now).total_seconds() / 60.0 120 | if 1 < minutes_diff < 5: 121 | print("Doing temporal tasks at {0}".format(now)) 122 | task.run_task() 123 | self.task_manager.remove_task(task) 124 | 125 | def background_job(self): 126 | if len(self.task_manager.tasks) > 0: 127 | self._do_assigned_temporal_tasks() 128 | 129 | time.sleep(2) 130 | 131 | try: 132 | self._remove_blank_rows() 133 | except HTTPError as e: 134 | logging.info(str(e)) 135 | 136 | -------------------------------------------------------------------------------- /Python-Server/app/background_worker/task/remove_task.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from background_worker.task.task import Task 4 | 5 | 6 | class RemoveTask(Task): 7 | def __init__(self, block_id_to_remove= None, datetime_to_run= None, client= None, dic = None): 8 | if client: 9 | self.notion_client = client 10 | if dic: 11 | super().__init__(type="RemoveTask", dic = dic) 12 | elif block_id_to_remove and datetime_to_run: 13 | super().__init__(type="RemoveTask", task_id=block_id_to_remove, datetime_to_run=datetime_to_run) 14 | self.block_id_to_remove = block_id_to_remove 15 | 16 | 17 | def __str__(self): 18 | print("Task of type {0} at this time: {1}".format(self.type, self.datetime_to_run)) 19 | 20 | def run_task(self): 21 | print("Removing block with id {0}".format(self.block_id_to_remove)) 22 | block = self.notion_client.get_block(self.block_id_to_remove) 23 | block.remove() 24 | print("Block removed succesfully!") 25 | 26 | def _from_dic(self, dic): 27 | self.type = dic['type'] 28 | self.task_id = dic['task_id'] 29 | self.datetime_to_run = datetime.strptime(dic['datetime_to_run'], "%Y-%m-%d %H:%M:%S") 30 | self.block_id_to_remove = dic['block_id_to_remove'] 31 | 32 | def to_dic(self): 33 | dic = { 34 | "type": self.type, 35 | "task_id": self.task_id, 36 | "block_id_to_remove": self.block_id_to_remove, 37 | "datetime_to_run": str(self.datetime_to_run) 38 | } 39 | self.task_dictionary = dic 40 | return self.task_dictionary 41 | -------------------------------------------------------------------------------- /Python-Server/app/background_worker/task/task.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | class Task: 5 | 6 | def __init__(self, type="Task", task_id = None, datetime_to_run= None, dic= None): 7 | if dic: 8 | self._from_dic(dic) 9 | elif task_id and datetime_to_run and type: 10 | self.type = type 11 | self.task_id = task_id 12 | self.datetime_to_run = datetime_to_run 13 | self.task_dictionary = {} 14 | 15 | def _from_dic(self, dic): 16 | self.type = dic['type'] 17 | self.task_id = dic['task_id'] 18 | self.datetime_to_run = datetime.strptime(dic['datetime_to_run'], "%Y-%m-%d %H:%M") 19 | 20 | def to_dic(self): 21 | dic = { 22 | "type": self.type, 23 | "task_id": self.task_id, 24 | "datetime_to_run": str(self.datetime_to_run) 25 | } 26 | self.task_dictionary = dic 27 | return self.task_dictionary 28 | 29 | def __str__(self): 30 | print("Task of type {0} at this time: {1}".format(self.type, self.datetime_to_run)) 31 | 32 | def run_task(self): 33 | print("Running task of type {0} at this time: {1}".format(self.type, self.datetime_to_run)) 34 | 35 | -------------------------------------------------------------------------------- /Python-Server/app/background_worker/task/task_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pickle 3 | import os 4 | from collections import namedtuple 5 | 6 | from background_worker.task.remove_task import RemoveTask 7 | from background_worker.task.task import Task 8 | from server_utils.utils import SETTINGS_FOLDER 9 | import sys 10 | 11 | 12 | class TaskManager: 13 | def __init__(self, logging, notion_ai): 14 | self.tasks_path = SETTINGS_FOLDER + 'tasks.json' 15 | self.logging = logging 16 | self.notion_ai = notion_ai 17 | self.tasks_json = [] 18 | self.tasks = self._init_tasks() 19 | 20 | def _init_tasks(self): 21 | if os.path.isfile(self.tasks_path): 22 | print("Tasks json file found.") 23 | tasks = [] 24 | with open(self.tasks_path, 'r') as json_file: 25 | self.tasks_json = json.load(json_file) 26 | 27 | for task in self.tasks_json: 28 | if task["type"] == "RemoveTask": 29 | t = RemoveTask(dic=task, client=self.notion_ai.client) 30 | elif task["type"] == "Task": 31 | t = Task(dic=task) 32 | 33 | tasks.append(t) 34 | 35 | return tasks 36 | else: 37 | print("No tasks assigned yet.") 38 | return [] 39 | 40 | def find_task(self, task_to_find): 41 | for index, task in enumerate(self.tasks): 42 | if task.task_id == task_to_find.task_id: 43 | return index 44 | return -1 45 | 46 | def add_task(self, task): 47 | index = self.find_task(task) 48 | if index == -1: 49 | self.tasks_json.append(task.to_dic()) 50 | self.tasks.append(task) 51 | else: 52 | self.tasks_json[index] = task.to_dic() 53 | self.tasks[index] = task 54 | 55 | self.save_tasks_to_json() 56 | 57 | def remove_task(self, task): 58 | self.tasks.remove(task) 59 | self.save_tasks_to_json() 60 | 61 | def save_tasks_to_json(self): 62 | if len(self.tasks) == 0: 63 | os.remove(self.tasks_path) 64 | else: 65 | with open(self.tasks_path, 'w') as outfile: 66 | json.dump(self.tasks_json, outfile) 67 | -------------------------------------------------------------------------------- /Python-Server/app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | notionai-mymind: 5 | image: elblogbruno/notion-ai-mymind:latest 6 | ports: 7 | - 8000:5000 8 | -------------------------------------------------------------------------------- /Python-Server/app/image_tagging/clarifai_tagging/clarifai_tagging.py: -------------------------------------------------------------------------------- 1 | from clarifai_grpc.grpc.api import service_pb2_grpc, service_pb2, resources_pb2 2 | from clarifai_grpc.grpc.api.status import status_code_pb2 3 | from clarifai_grpc.channel.clarifai_channel import ClarifaiChannel 4 | 5 | 6 | class ClarifaiAI: 7 | 8 | def __init__(self, key): 9 | # Construct one of the channels you want to use 10 | self.channel = ClarifaiChannel.get_json_channel() 11 | self.key = key 12 | 13 | def get_tags(self, image_url, is_local_image, treshold): 14 | stub = service_pb2_grpc.V2Stub(self.channel) 15 | file_bytes = {} 16 | file = "" 17 | 18 | if is_local_image: 19 | file = "./uploads/" + image_url.split("/")[-1] 20 | with open(file, "rb") as f: 21 | file_bytes = f.read() 22 | else: 23 | file = image_url 24 | 25 | if is_local_image: 26 | request = service_pb2.PostModelOutputsRequest( 27 | model_id='aaa03c23b3724a16a56b629203edc62c', 28 | inputs=[ 29 | resources_pb2.Input(data=resources_pb2.Data(image=resources_pb2.Image(base64=file_bytes))) 30 | ]) 31 | else: 32 | request = service_pb2.PostModelOutputsRequest( 33 | model_id='aaa03c23b3724a16a56b629203edc62c', 34 | inputs=[ 35 | resources_pb2.Input(data=resources_pb2.Data(image=resources_pb2.Image(url=file))) 36 | ]) 37 | 38 | metadata = (('authorization', 'Key {0}'.format(self.key)),) 39 | 40 | response = stub.PostModelOutputs(request, metadata=metadata) 41 | 42 | if response.status.code != status_code_pb2.SUCCESS: 43 | raise Exception("Request failed, status code: " + str(response.status.code)) 44 | 45 | tags = [] 46 | for concept in response.outputs[0].data.concepts: 47 | if concept.value > treshold: 48 | tags.append(str(concept.name)) 49 | 50 | print('Predicted:', tags) 51 | 52 | return tags 53 | -------------------------------------------------------------------------------- /Python-Server/app/image_tagging/image_tagging.py: -------------------------------------------------------------------------------- 1 | from image_tagging.clarifai_tagging.clarifai_tagging import ClarifaiAI 2 | from image_tagging.tensorflow_tagging.tensorflow_tagging import TensorFlowTag 3 | from server_utils.utils import SETTINGS_FOLDER 4 | from notion_ai.property_manager.tag_object import TagObject 5 | from os.path import join 6 | 7 | import json 8 | 9 | 10 | # This class holds the image tagging system, and it acts as a parent for the Clarifai and Tensorflow tagging 11 | # mechanisms. 12 | 13 | class ImageTagging: 14 | 15 | def __init__(self, data, logging): 16 | options = {} 17 | self.logging = logging 18 | filename = SETTINGS_FOLDER + 'tagging_options.json' 19 | with open(filename) as json_file: 20 | options = json.load(json_file) 21 | try: 22 | self.options = options 23 | self.treshold = 0.20 24 | if 'confidence_treshold' in options: 25 | self.treshold = float(options['confidence_treshold']) 26 | if options['use_clarifai']: 27 | self.predictor = ClarifaiAI(data['clarifai_key']) 28 | self.logging.info("Using clarifai predictor") 29 | else: 30 | self.predictor = TensorFlowTag(options['delete_after_tagging']) 31 | self.logging.info("Using tensorflow predictor, with delete_after_tagging = {}".format( 32 | options['delete_after_tagging'])) 33 | except FileNotFoundError: 34 | self.logging.info("options.json not found") 35 | print("Wrong file or file path") 36 | 37 | def get_tags(self, image_url, is_image_local): 38 | self.print_current_detector() 39 | return self.predictor.get_tags(image_url, is_image_local, self.treshold) 40 | 41 | def print_current_detector(self): 42 | if self.options['use_clarifai']: 43 | self.logging.info("Using clarifai predictor with treshold: {}".format(self.treshold)) 44 | else: 45 | self.logging.info("Using tensorflow predictor, with delete_after_tagging = {0} and treshold: {1}".format( 46 | self.options['delete_after_tagging'], self.treshold)) 47 | 48 | def remove_duplicated_tags(self, lists_of_tags): 49 | res = [] 50 | for l in lists_of_tags: 51 | for i in l: 52 | if i not in res: 53 | res.append(i) 54 | return res 55 | 56 | def count_duplicated_tags(self, lists_of_tags): 57 | res = {} 58 | for tags in lists_of_tags: 59 | for tag in tags: 60 | if tag: 61 | if tag not in res: 62 | res[tag] = 1 63 | else: 64 | res[tag] = res[tag] + 1 65 | return res 66 | 67 | def get_most_used_ai_tags(self, notion_ai, collection_index, number_of_tags=10): 68 | print("Getting most tags") 69 | #notion_ai.mind_structure.set_current_collection(collection_index) 70 | 71 | rows = notion_ai.mind_structure.collection.get_rows() 72 | 73 | all_tags = [] 74 | 75 | for row in rows: 76 | ai_tags = notion_ai.property_manager.get_properties(row, ai_tags_property=1).split(",") 77 | if len(ai_tags) > 0: 78 | all_tags.append(ai_tags) 79 | 80 | freq = self.count_duplicated_tags(all_tags) 81 | 82 | sorted_freq = sorted(freq, key=freq.get, reverse=True) 83 | 84 | if len(sorted_freq) > 0: 85 | if number_of_tags > len(sorted_freq): 86 | number_of_tags = len(sorted_freq) 87 | 88 | for i in range(0, number_of_tags): 89 | sorted_freq[i] = TagObject(tag_value=sorted_freq[i]).to_dict() 90 | 91 | return sorted_freq 92 | -------------------------------------------------------------------------------- /Python-Server/app/image_tagging/tensorflow_tagging/tensorflow_tagging.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import division 3 | from __future__ import print_function 4 | 5 | import numpy as np 6 | from PIL import UnidentifiedImageError 7 | 8 | from tensorflow.python.keras.applications.inception_v3 import InceptionV3, preprocess_input, decode_predictions 9 | from tensorflow.python.keras.preprocessing import image 10 | import os 11 | from server_utils.utils import download_image_from_url, createFolder 12 | 13 | 14 | class TensorFlowTag: 15 | 16 | def __init__(self, delete_image_after_tagging=True): 17 | self.delete_after_tagging = delete_image_after_tagging 18 | self.model = InceptionV3(weights='imagenet') 19 | 20 | def get_tags(self, image_url, is_local_image, treshold): 21 | if is_local_image: 22 | file = "./uploads/" + image_url.split("/")[-1] 23 | else: 24 | file = download_image_from_url(image_url) 25 | 26 | if file: 27 | try: 28 | img = image.load_img(file, target_size=(299, 299)) 29 | x = image.img_to_array(img) 30 | x = np.expand_dims(x, axis=0) 31 | x = preprocess_input(x) 32 | 33 | preds = self.model.predict(x) 34 | prediction_decoded = decode_predictions(preds, top=20)[0] 35 | 36 | print('Predicted:', prediction_decoded) 37 | 38 | tags = [] 39 | for element in prediction_decoded: 40 | if element[2] > treshold: 41 | tags.append(element[1]) 42 | 43 | if self.delete_after_tagging: 44 | os.remove(file) 45 | 46 | except UnidentifiedImageError as e: 47 | self.logging.info(str(e)) 48 | 49 | return tags 50 | else: 51 | return [] 52 | -------------------------------------------------------------------------------- /Python-Server/app/notion/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elblogbruno/NotionAI-MyMind/f09bad87d508e15d999f5b96866c86d4962e41f0/Python-Server/app/notion/__init__.py -------------------------------------------------------------------------------- /Python-Server/app/notion/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from .settings import LOG_FILE 5 | 6 | 7 | NOTIONPY_LOG_LEVEL = os.environ.get("NOTIONPY_LOG_LEVEL", "warning").lower() 8 | 9 | logger = logging.getLogger("notion") 10 | 11 | 12 | def enable_debugging(): 13 | set_log_level(logging.DEBUG) 14 | 15 | 16 | def set_log_level(level): 17 | logger.setLevel(level) 18 | handler.setLevel(level) 19 | 20 | 21 | if NOTIONPY_LOG_LEVEL == "disabled": 22 | handler = logging.NullHandler() 23 | logger.addHandler(handler) 24 | else: 25 | handler = logging.FileHandler(LOG_FILE) 26 | formatter = logging.Formatter("\n%(asctime)s - %(levelname)s - %(message)s") 27 | handler.setFormatter(formatter) 28 | logger.addHandler(handler) 29 | 30 | if NOTIONPY_LOG_LEVEL == "debug": 31 | set_log_level(logging.DEBUG) 32 | elif NOTIONPY_LOG_LEVEL == "info": 33 | set_log_level(logging.INFO) 34 | elif NOTIONPY_LOG_LEVEL == "warning": 35 | set_log_level(logging.WARNING) 36 | elif NOTIONPY_LOG_LEVEL == "error": 37 | set_log_level(logging.ERROR) 38 | else: 39 | raise Exception( 40 | "Invalid value for environment variable NOTIONPY_LOG_LEVEL: {}".format( 41 | NOTIONPY_LOG_LEVEL 42 | ) 43 | ) 44 | -------------------------------------------------------------------------------- /Python-Server/app/notion/maps.py: -------------------------------------------------------------------------------- 1 | from inspect import signature 2 | 3 | from .logger import logger 4 | from .markdown import markdown_to_notion, notion_to_markdown 5 | 6 | 7 | class mapper(property): 8 | def __init__(self, path, python_to_api, api_to_python, *args, **kwargs): 9 | self.python_to_api = python_to_api 10 | self.api_to_python = api_to_python 11 | self.path = ( 12 | ".".join(map(str, path)) 13 | if isinstance(path, list) or isinstance(path, tuple) 14 | else path 15 | ) 16 | super().__init__(*args, **kwargs) 17 | 18 | 19 | def field_map(path, python_to_api=lambda x: x, api_to_python=lambda x: x): 20 | """ 21 | Returns a property that maps a Block attribute onto a field in the API data structures. 22 | 23 | - `path` can either be a top-level field-name, a list that specifies the key names to traverse, 24 | or a dot-delimited string representing the same traversal. 25 | 26 | - `python_to_api` is a function that converts values as given in the Python layer into the 27 | internal representation to be sent along in the API request. 28 | 29 | - `api_to_python` is a function that converts what is received from the API into an internal 30 | representation to be returned to the Python layer. 31 | """ 32 | 33 | if isinstance(path, str): 34 | path = path.split(".") 35 | 36 | def fget(self): 37 | kwargs = {} 38 | if ( 39 | "client" in signature(api_to_python).parameters 40 | and "id" in signature(api_to_python).parameters 41 | ): 42 | kwargs["client"] = self._client 43 | kwargs["id"] = self.id 44 | return api_to_python(self.get(path), **kwargs) 45 | 46 | def fset(self, value): 47 | kwargs = {} 48 | if "client" in signature(python_to_api).parameters: 49 | kwargs["client"] = self._client 50 | self.set(path, python_to_api(value, **kwargs)) 51 | 52 | return mapper( 53 | fget=fget, 54 | fset=fset, 55 | path=path, 56 | python_to_api=python_to_api, 57 | api_to_python=api_to_python, 58 | ) 59 | 60 | 61 | def property_map( 62 | name, python_to_api=lambda x: x, api_to_python=lambda x: x, markdown=True 63 | ): 64 | """ 65 | Similar to `field_map`, except it works specifically with the data under the "properties" field 66 | in the API's block table, and just takes a single name to specify which subkey to reference. 67 | Also, these properties all seem to use a special "embedded list" format that breaks the text 68 | up into a sequence of chunks and associated format metadata. If `markdown` is True, we convert 69 | this representation into commonmark-compatible markdown, and back again when saving. 70 | """ 71 | 72 | def py2api(x, client=None): 73 | kwargs = {} 74 | if "client" in signature(python_to_api).parameters: 75 | kwargs["client"] = client 76 | x = python_to_api(x, **kwargs) 77 | if markdown: 78 | x = markdown_to_notion(x) 79 | return x 80 | 81 | def api2py(x, client=None, id=""): 82 | x = x or [[""]] 83 | if markdown: 84 | x = notion_to_markdown(x) 85 | kwargs = {} 86 | params = signature(api_to_python).parameters 87 | if "client" in params: 88 | kwargs["client"] = client 89 | if "id" in params: 90 | kwargs["id"] = id 91 | return api_to_python(x, **kwargs) 92 | 93 | return field_map(["properties", name], python_to_api=py2api, api_to_python=api2py) 94 | 95 | 96 | def joint_map(*mappings): 97 | """ 98 | Combine multiple `field_map` and `property_map` instances together to map an attribute to multiple API fields. 99 | Note: when "getting", the first one will be used. When "setting", they will all be set in parallel. 100 | """ 101 | 102 | def fget(self): 103 | return mappings[0].fget(self) 104 | 105 | def fset(self, value): 106 | for m in mappings: 107 | m.fset(self, value) 108 | 109 | return property(fget=fget, fset=fset) 110 | -------------------------------------------------------------------------------- /Python-Server/app/notion/monitor.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import requests 4 | import threading 5 | import time 6 | import uuid 7 | 8 | from collections import defaultdict 9 | from inspect import signature 10 | from requests import HTTPError 11 | 12 | from .collection import Collection 13 | from .logger import logger 14 | from .records import Record 15 | 16 | 17 | class Monitor(object): 18 | 19 | thread = None 20 | 21 | def __init__(self, client, root_url="https://msgstore.www.notion.so/primus/"): 22 | self.client = client 23 | self.session_id = str(uuid.uuid4()) 24 | self.root_url = root_url 25 | self._subscriptions = set() 26 | self.initialize() 27 | 28 | def _decode_numbered_json_thing(self, thing): 29 | 30 | thing = thing.decode().strip() 31 | 32 | for ping in re.findall('\d+:\d+"primus::ping::\d+"', thing): 33 | logger.debug("Received ping: {}".format(ping)) 34 | self.post_data(ping.replace("::ping::", "::pong::")) 35 | 36 | results = [] 37 | for blob in re.findall("\d+:\d+(\{.*?\})(?=\d|$)", thing): 38 | results.append(json.loads(blob)) 39 | if thing and not results and "::ping::" not in thing: 40 | logger.debug("Could not parse monitoring response: {}".format(thing)) 41 | return results 42 | 43 | def _encode_numbered_json_thing(self, data): 44 | assert isinstance(data, list) 45 | results = "" 46 | for obj in data: 47 | msg = str(len(obj)) + json.dumps(obj, separators=(",", ":")) 48 | msg = "{}:{}".format(len(msg), msg) 49 | results += msg 50 | return results.encode() 51 | 52 | def initialize(self): 53 | 54 | logger.debug("Initializing new monitoring session.") 55 | 56 | response = self.client.session.get( 57 | "{}?sessionId={}&EIO=3&transport=polling".format( 58 | self.root_url, self.session_id 59 | ) 60 | ) 61 | 62 | self.sid = self._decode_numbered_json_thing(response.content)[0]["sid"] 63 | 64 | logger.debug("New monitoring session ID is: {}".format(self.sid)) 65 | 66 | # resubscribe to any existing subscriptions if we're reconnecting 67 | old_subscriptions, self._subscriptions = self._subscriptions, set() 68 | self.subscribe(old_subscriptions) 69 | 70 | def subscribe(self, records): 71 | 72 | if isinstance(records, set): 73 | records = list(records) 74 | 75 | if not isinstance(records, list): 76 | records = [records] 77 | 78 | sub_data = [] 79 | 80 | for record in records: 81 | 82 | if record not in self._subscriptions: 83 | 84 | logger.debug( 85 | "Subscribing new record to the monitoring watchlist: {}/{}".format( 86 | record._table, record.id 87 | ) 88 | ) 89 | 90 | # add the record to the list of records to restore if we're disconnected 91 | self._subscriptions.add(record) 92 | 93 | # subscribe to changes to the record itself 94 | sub_data.append( 95 | { 96 | "type": "/api/v1/registerSubscription", 97 | "requestId": str(uuid.uuid4()), 98 | "key": "versions/{}:{}".format(record.id, record._table), 99 | "version": record.get("version", -1), 100 | } 101 | ) 102 | 103 | # if it's a collection, subscribe to changes to its children too 104 | if isinstance(record, Collection): 105 | sub_data.append( 106 | { 107 | "type": "/api/v1/registerSubscription", 108 | "requestId": str(uuid.uuid4()), 109 | "key": "collection/{}".format(record.id), 110 | "version": -1, 111 | } 112 | ) 113 | 114 | data = self._encode_numbered_json_thing(sub_data) 115 | 116 | self.post_data(data) 117 | 118 | def post_data(self, data): 119 | 120 | if not data: 121 | return 122 | 123 | logger.debug("Posting monitoring data: {}".format(data)) 124 | 125 | self.client.session.post( 126 | "{}?sessionId={}&transport=polling&sid={}".format( 127 | self.root_url, self.session_id, self.sid 128 | ), 129 | data=data, 130 | ) 131 | 132 | def poll(self, retries=10): 133 | logger.debug("Starting new long-poll request") 134 | try: 135 | response = self.client.session.get( 136 | "{}?sessionId={}&EIO=3&transport=polling&sid={}".format( 137 | self.root_url, self.session_id, self.sid 138 | ) 139 | ) 140 | response.raise_for_status() 141 | except HTTPError as e: 142 | try: 143 | message = "{} / {}".format(response.content, e) 144 | except: 145 | message = "{}".format(e) 146 | logger.warn( 147 | "Problem with submitting polling request: {} (will retry {} more times)".format( 148 | message, retries 149 | ) 150 | ) 151 | time.sleep(0.1) 152 | if retries <= 0: 153 | raise 154 | if retries <= 5: 155 | logger.error( 156 | "Persistent error submitting polling request: {} (will retry {} more times)".format( 157 | message, retries 158 | ) 159 | ) 160 | # if we're close to giving up, also try reinitializing the session 161 | self.initialize() 162 | self.poll(retries=retries - 1) 163 | 164 | self._refresh_updated_records( 165 | self._decode_numbered_json_thing(response.content) 166 | ) 167 | 168 | def _refresh_updated_records(self, events): 169 | 170 | records_to_refresh = defaultdict(list) 171 | 172 | for event in events: 173 | 174 | logger.debug( 175 | "Received the following event from the remote server: {}".format(event) 176 | ) 177 | 178 | if not isinstance(event, dict): 179 | continue 180 | 181 | if event.get("type", "") == "notification": 182 | 183 | key = event.get("key") 184 | 185 | if key.startswith("versions/"): 186 | 187 | match = re.match("versions/([^\:]+):(.+)", key) 188 | if not match: 189 | continue 190 | 191 | record_id, record_table = match.groups() 192 | 193 | local_version = self.client._store.get_current_version( 194 | record_table, record_id 195 | ) 196 | if event["value"] > local_version: 197 | logger.debug( 198 | "Record {}/{} has changed; refreshing to update from version {} to version {}".format( 199 | record_table, record_id, local_version, event["value"] 200 | ) 201 | ) 202 | records_to_refresh[record_table].append(record_id) 203 | else: 204 | logger.debug( 205 | "Record {}/{} already at version {}, not trying to update to version {}".format( 206 | record_table, record_id, local_version, event["value"] 207 | ) 208 | ) 209 | 210 | if key.startswith("collection/"): 211 | 212 | match = re.match("collection/(.+)", key) 213 | if not match: 214 | continue 215 | 216 | collection_id = match.groups()[0] 217 | 218 | self.client.refresh_collection_rows(collection_id) 219 | row_ids = self.client._store.get_collection_rows(collection_id) 220 | 221 | logger.debug( 222 | "Something inside collection {} has changed; refreshing all {} rows inside it".format( 223 | collection_id, len(row_ids) 224 | ) 225 | ) 226 | 227 | records_to_refresh["block"] += row_ids 228 | 229 | self.client.refresh_records(**records_to_refresh) 230 | 231 | def poll_async(self): 232 | if self.thread: 233 | # Already polling async; no need to have two threads 234 | return 235 | self.thread = threading.Thread(target=self.poll_forever, daemon=True) 236 | self.thread.start() 237 | 238 | def poll_forever(self): 239 | while True: 240 | try: 241 | self.poll() 242 | except Exception as e: 243 | logger.error("Encountered error during polling!") 244 | logger.error(e, exc_info=True) 245 | time.sleep(1) 246 | -------------------------------------------------------------------------------- /Python-Server/app/notion/operations.py: -------------------------------------------------------------------------------- 1 | from .utils import now 2 | 3 | 4 | def build_operation(id, path, args, command="set", table="block"): 5 | """ 6 | Data updates sent to the submitTransaction endpoint consist of a sequence of "operations". This is a helper 7 | function that constructs one of these operations. 8 | """ 9 | 10 | if isinstance(path, str): 11 | path = path.split(".") 12 | 13 | return {"id": id, "path": path, "args": args, "command": command, "table": table} 14 | 15 | 16 | def operation_update_last_edited(user_id, block_id): 17 | """ 18 | When transactions are submitted from the web UI, it also includes an operation to update the "last edited" 19 | fields, so we want to send those too, for consistency -- this convenience function constructs the operation. 20 | """ 21 | return { 22 | "args": { 23 | "last_edited_by_id": user_id, 24 | "last_edited_by_table": "notion_user", 25 | "last_edited_time": now(), 26 | }, 27 | "command": "update", 28 | "id": block_id, 29 | "path": [], 30 | "table": "block", 31 | } 32 | -------------------------------------------------------------------------------- /Python-Server/app/notion/records.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from .logger import logger 4 | from .operations import build_operation 5 | from .utils import extract_id, get_by_path 6 | 7 | 8 | class Record(object): 9 | 10 | # if a subclass has a list of ids that should be update when child records are removed, it should specify the key here 11 | child_list_key = None 12 | 13 | def __init__(self, client, id, *args, **kwargs): 14 | self._client = client 15 | self._id = extract_id(id) 16 | self._callbacks = [] 17 | if self._client._monitor is not None: 18 | self._client._monitor.subscribe(self) 19 | 20 | @property 21 | def id(self): 22 | return self._id 23 | 24 | @property 25 | def role(self): 26 | return self._client._store.get_role(self._table, self.id) 27 | 28 | def _str_fields(self): 29 | """ 30 | Determines the list of fields to include in the __str__ representation. Override and extend this in subclasses. 31 | """ 32 | return ["id"] 33 | 34 | def __str__(self): 35 | return ", ".join( 36 | [ 37 | "{}={}".format(field, repr(getattr(self, field))) 38 | for field in self._str_fields() 39 | if getattr(self, field, "") 40 | ] 41 | ) 42 | 43 | def __repr__(self): 44 | return "<{} ({})>".format(self.__class__.__name__, self) 45 | 46 | def refresh(self): 47 | """ 48 | Update the cached data for this record from the server (data for other records may be updated as a side effect). 49 | """ 50 | self._get_record_data(force_refresh=True) 51 | 52 | def _convert_diff_to_changelist(self, difference, old_val, new_val): 53 | changed_values = set() 54 | for operation, path, values in deepcopy(difference): 55 | path = path.split(".") if isinstance(path, str) else path 56 | if operation in ["add", "remove"]: 57 | path.append(values[0][0]) 58 | while isinstance(path[-1], int): 59 | path.pop() 60 | changed_values.add(".".join(map(str, path))) 61 | return [ 62 | ( 63 | "changed_value", 64 | path, 65 | (get_by_path(path, old_val), get_by_path(path, new_val)), 66 | ) 67 | for path in changed_values 68 | ] 69 | 70 | def add_callback(self, callback, callback_id=None, extra_kwargs={}): 71 | assert callable( 72 | callback 73 | ), "The callback must be a 'callable' object, such as a function." 74 | callback_obj = self._client._store.add_callback( 75 | self, callback, callback_id=callback_id, extra_kwargs=extra_kwargs 76 | ) 77 | self._callbacks.append(callback_obj) 78 | return callback_obj 79 | 80 | def remove_callbacks(self, callback_or_callback_id_prefix=None): 81 | print("removing callback " + callback_or_callback_id_prefix) 82 | if callback_or_callback_id_prefix is None: 83 | for callback_obj in list(self._callbacks): 84 | self._client._store.remove_callbacks( 85 | self._table, self.id, callback_or_callback_id_prefix=callback_obj 86 | ) 87 | self._callbacks = [] 88 | else: 89 | self._client._store.remove_callbacks( 90 | self._table, 91 | self.id, 92 | callback_or_callback_id_prefix=callback_or_callback_id_prefix, 93 | ) 94 | if callback_or_callback_id_prefix in self._callbacks: 95 | self._callbacks.remove(callback_or_callback_id_prefix) 96 | 97 | def _get_record_data(self, force_refresh=False): 98 | return self._client.get_record_data( 99 | self._table, self.id, force_refresh=force_refresh 100 | ) 101 | 102 | def get(self, path=[], default=None, force_refresh=False): 103 | """ 104 | Retrieve cached data for this record. The `path` is a list (or dot-delimited string) the specifies the field 105 | to retrieve the value for. If no path is supplied, return the entire cached data structure for this record. 106 | If `force_refresh` is set to True, we force_refresh the data cache from the server before reading the values. 107 | """ 108 | return get_by_path( 109 | path, self._get_record_data(force_refresh=force_refresh), default=default 110 | ) 111 | 112 | def set(self, path, value): 113 | """ 114 | Set a specific `value` (under the specific `path`) on the record's data structure on the server. 115 | """ 116 | self._client.submit_transaction( 117 | build_operation(id=self.id, path=path, args=value, table=self._table) 118 | ) 119 | 120 | def __eq__(self, other): 121 | return self.id == other.id 122 | 123 | def __ne__(self, other): 124 | return self.id != other.id 125 | 126 | def __hash__(self): 127 | return hash(self.id) 128 | -------------------------------------------------------------------------------- /Python-Server/app/notion/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | BASE_URL = "https://www.notion.so/" 5 | API_BASE_URL = BASE_URL + "api/v3/" 6 | SIGNED_URL_PREFIX = "https://www.notion.so/signed/" 7 | S3_URL_PREFIX = "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/" 8 | S3_URL_PREFIX_ENCODED = "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/" 9 | DATA_DIR = os.environ.get( 10 | "NOTION_DATA_DIR", str(Path(os.path.expanduser("~")).joinpath(".notion-py")) 11 | ) 12 | CACHE_DIR = str(Path(DATA_DIR).joinpath("cache")) 13 | LOG_FILE = str(Path(DATA_DIR).joinpath("notion.log")) 14 | 15 | try: 16 | os.makedirs(DATA_DIR) 17 | except FileExistsError: 18 | pass 19 | 20 | try: 21 | os.makedirs(CACHE_DIR) 22 | except FileExistsError: 23 | pass 24 | -------------------------------------------------------------------------------- /Python-Server/app/notion/smoke_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from .client import * 4 | from .block import * 5 | from .collection import NotionDate 6 | 7 | 8 | def run_live_smoke_test(token_v2, parent_page_url_or_id): 9 | 10 | client = NotionClient(token_v2=token_v2) 11 | 12 | parent_page = client.get_block(parent_page_url_or_id) 13 | 14 | page = parent_page.children.add_new( 15 | PageBlock, 16 | title="Smoke test at {}".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S")), 17 | ) 18 | 19 | print("Created base smoke test page at:", page.get_browseable_url()) 20 | 21 | col_list = page.children.add_new(ColumnListBlock) 22 | col1 = col_list.children.add_new(ColumnBlock) 23 | col2 = col_list.children.add_new(ColumnBlock) 24 | col1kid = col1.children.add_new( 25 | TextBlock, title="Some formatting: *italic*, **bold**, ***both***!" 26 | ) 27 | assert ( 28 | col1kid.title.replace("_", "*") 29 | == "Some formatting: *italic*, **bold**, ***both***!" 30 | ) 31 | assert col1kid.title_plaintext == "Some formatting: italic, bold, both!" 32 | col2.children.add_new(TodoBlock, title="I should be unchecked") 33 | col2.children.add_new(TodoBlock, title="I should be checked", checked=True) 34 | 35 | page.children.add_new(HeaderBlock, title="The finest music:") 36 | video = page.children.add_new(VideoBlock, width=100) 37 | video.set_source_url("https://www.youtube.com/watch?v=oHg5SJYRHA0") 38 | 39 | assert video in page.children 40 | assert col_list in page.children 41 | assert video in page.children.filter(VideoBlock) 42 | assert col_list not in page.children.filter(VideoBlock) 43 | 44 | # check that the parent does not yet consider this page to be backlinking 45 | assert page not in parent_page.get_backlinks() 46 | 47 | page.children.add_new(SubheaderBlock, title="A link back to where I came from:") 48 | alias = page.children.add_alias(parent_page) 49 | assert alias.is_alias 50 | assert not page.is_alias 51 | page.children.add_new( 52 | QuoteBlock, 53 | title="Clicking [here]({}) should take you to the same place...".format( 54 | page.parent.get_browseable_url() 55 | ), 56 | ) 57 | 58 | # check that the parent now knows about the backlink 59 | assert page in parent_page.get_backlinks() 60 | 61 | # ensure __repr__ methods are not breaking 62 | repr(page) 63 | repr(page.children) 64 | for child in page.children: 65 | repr(child) 66 | 67 | page.children.add_new( 68 | SubheaderBlock, title="The order of the following should be alphabetical:" 69 | ) 70 | 71 | B = page.children.add_new(BulletedListBlock, title="B") 72 | D = page.children.add_new(BulletedListBlock, title="D") 73 | C2 = page.children.add_new(BulletedListBlock, title="C2") 74 | C1 = page.children.add_new(BulletedListBlock, title="C1") 75 | C = page.children.add_new(BulletedListBlock, title="C") 76 | A = page.children.add_new(BulletedListBlock, title="A") 77 | 78 | D.move_to(C, "after") 79 | A.move_to(B, "before") 80 | C2.move_to(C) 81 | C1.move_to(C, "first-child") 82 | 83 | page.children.add_new(CalloutBlock, title="I am a callout", icon="🤞") 84 | 85 | cvb = page.children.add_new(CollectionViewBlock) 86 | cvb.collection = client.get_collection( 87 | client.create_record("collection", parent=cvb, schema=get_collection_schema()) 88 | ) 89 | cvb.title = "My data!" 90 | view = cvb.views.add_new(view_type="table") 91 | 92 | special_code = uuid.uuid4().hex[:8] 93 | 94 | # add a row 95 | row1 = cvb.collection.add_row() 96 | assert row1.person == [] 97 | row1.name = "Just some data" 98 | row1.title = "Can reference 'title' field too! " + special_code 99 | assert row1.name == row1.title 100 | row1.check_yo_self = True 101 | row1.estimated_value = None 102 | row1.estimated_value = 42 103 | row1.files = [ 104 | "https://www.birdlife.org/sites/default/files/styles/1600/public/slide.jpg" 105 | ] 106 | row1.tags = None 107 | row1.tags = [] 108 | row1.tags = ["A", "C"] 109 | row1.where_to = "https://learningequality.org" 110 | row1.category = "A" 111 | row1.category = "" 112 | row1.category = None 113 | row1.category = "B" 114 | 115 | start = datetime.strptime("2020-01-01 09:30", "%Y-%m-%d %H:%M") 116 | end = datetime.strptime("2020-01-05 20:45", "%Y-%m-%d %H:%M") 117 | timezone = "America/Los_Angeles" 118 | reminder = {"unit": "minute", "value": 30} 119 | row1.some_date = NotionDate(start, end=end, timezone=timezone, reminder=reminder) 120 | 121 | # add another row 122 | row2 = cvb.collection.add_row(person=client.current_user, title="Metallic penguins") 123 | assert row2.person == [client.current_user] 124 | assert row2.name == "Metallic penguins" 125 | row2.check_yo_self = False 126 | row2.estimated_value = 22 127 | row2.files = [ 128 | "https://www.picclickimg.com/d/l400/pict/223603662103_/Vintage-Small-Monet-and-Jones-JNY-Enamel-Metallic.jpg" 129 | ] 130 | row2.tags = ["A", "B"] 131 | row2.where_to = "https://learningequality.org" 132 | row2.category = "C" 133 | 134 | # check that options "C" have been added to the schema 135 | for prop in ["=d{|", "=d{q"]: 136 | assert cvb.collection.get("schema.{}.options.2.value".format(prop)) == "C" 137 | 138 | # check that existing options "A" haven't been affected 139 | for prop in ["=d{|", "=d{q"]: 140 | assert ( 141 | cvb.collection.get("schema.{}.options.0.id".format(prop)) 142 | == get_collection_schema()[prop]["options"][0]["id"] 143 | ) 144 | 145 | # Run a filtered/sorted query using the view's default parameters 146 | result = view.default_query().execute() 147 | assert row1 == result[0] 148 | assert row2 == result[1] 149 | assert len(result) == 2 150 | 151 | # query the collection directly 152 | assert row1 in cvb.collection.get_rows(search=special_code) 153 | assert row2 not in cvb.collection.get_rows(search=special_code) 154 | assert row1 not in cvb.collection.get_rows(search="penguins") 155 | assert row2 in cvb.collection.get_rows(search="penguins") 156 | 157 | # search the entire space 158 | assert row1 in client.search_blocks(search=special_code) 159 | assert row1 not in client.search_blocks(search="penguins") 160 | assert row2 not in client.search_blocks(search=special_code) 161 | assert row2 in client.search_blocks(search="penguins") 162 | 163 | # Run an "aggregation" query 164 | aggregations = [ 165 | {"property": "estimated_value", "aggregator": "sum", "id": "total_value"} 166 | ] 167 | result = view.build_query(aggregations=aggregations).execute() 168 | assert result.get_aggregate("total_value") == 64 169 | 170 | # Run a "filtered" query 171 | filter_params = { 172 | "filters": [ 173 | { 174 | "filter": { 175 | "value": { 176 | "type": "exact", 177 | "value": {"table": "notion_user", "id": client.current_user.id}, 178 | }, 179 | "operator": "person_does_not_contain", 180 | }, 181 | "property": "person", 182 | } 183 | ], 184 | "operator": "and", 185 | } 186 | result = view.build_query(filter=filter_params).execute() 187 | assert row1 in result 188 | assert row2 not in result 189 | 190 | # Run a "sorted" query 191 | sort_params = [{"direction": "ascending", "property": "estimated_value"}] 192 | result = view.build_query(sort=sort_params).execute() 193 | assert row1 == result[1] 194 | assert row2 == result[0] 195 | 196 | # Test that reminders and time zone's work properly 197 | row1.refresh() 198 | assert row1.some_date.start == start 199 | assert row1.some_date.end == end 200 | assert row1.some_date.timezone == timezone 201 | assert row1.some_date.reminder == reminder 202 | 203 | print( 204 | "Check it out and make sure it looks good, then press any key here to delete it..." 205 | ) 206 | input() 207 | 208 | _delete_page_fully(page) 209 | 210 | 211 | def _delete_page_fully(page): 212 | 213 | id = page.id 214 | 215 | parent_page = page.parent 216 | 217 | assert page.get("alive") == True 218 | assert page in parent_page.children 219 | page.remove() 220 | assert page.get("alive") == False 221 | assert page not in parent_page.children 222 | 223 | assert ( 224 | page.space_info 225 | ), "Page {} was fully deleted prematurely, as we can't get space info about it anymore".format( 226 | id 227 | ) 228 | 229 | page.remove(permanently=True) 230 | 231 | time.sleep(1) 232 | 233 | assert ( 234 | not page.space_info 235 | ), "Page {} was not really fully deleted, as we can still get space info about it".format( 236 | id 237 | ) 238 | 239 | 240 | def get_collection_schema(): 241 | return { 242 | "%9:q": {"name": "Check Yo'self", "type": "checkbox"}, 243 | "=d{|": { 244 | "name": "Tags", 245 | "type": "multi_select", 246 | "options": [ 247 | { 248 | "color": "orange", 249 | "id": "79560dab-c776-43d1-9420-27f4011fcaec", 250 | "value": "A", 251 | }, 252 | { 253 | "color": "default", 254 | "id": "002c7016-ac57-413a-90a6-64afadfb0c44", 255 | "value": "B", 256 | }, 257 | ], 258 | }, 259 | "=d{q": { 260 | "name": "Category", 261 | "type": "select", 262 | "options": [ 263 | { 264 | "color": "orange", 265 | "id": "59560dab-c776-43d1-9420-27f4011fcaec", 266 | "value": "A", 267 | }, 268 | { 269 | "color": "default", 270 | "id": "502c7016-ac57-413a-90a6-64afadfb0c44", 271 | "value": "B", 272 | }, 273 | ], 274 | }, 275 | "LL[(": {"name": "Person", "type": "person"}, 276 | "4Jv$": {"name": "Estimated value", "type": "number"}, 277 | "OBcJ": {"name": "Where to?", "type": "url"}, 278 | "TwR:": {"name": "Some Date", "type": "date"}, 279 | "dV$q": {"name": "Files", "type": "file"}, 280 | "title": {"name": "Name", "type": "title"}, 281 | } 282 | -------------------------------------------------------------------------------- /Python-Server/app/notion/space.py: -------------------------------------------------------------------------------- 1 | from .logger import logger 2 | from .maps import property_map, field_map 3 | from .records import Record 4 | 5 | 6 | class Space(Record): 7 | 8 | _table = "space" 9 | 10 | child_list_key = "pages" 11 | 12 | name = field_map("name") 13 | domain = field_map("domain") 14 | icon = field_map("icon") 15 | 16 | @property 17 | def pages(self): 18 | # The page list includes pages the current user might not have permissions on, so it's slow to query. 19 | # Instead, we just filter for pages with the space as the parent. 20 | return self._client.search_pages_with_parent(self.id) 21 | 22 | @property 23 | def users(self): 24 | user_ids = [permission["user_id"] for permission in self.get("permissions")] 25 | self._client.refresh_records(notion_user=user_ids) 26 | return [self._client.get_user(user_id) for user_id in user_ids] 27 | 28 | def _str_fields(self): 29 | return super()._str_fields() + ["name", "domain"] 30 | 31 | def add_page(self, title, type="page", shared=False): 32 | assert type in [ 33 | "page", 34 | "collection_view_page", 35 | ], "'type' must be one of 'page' or 'collection_view_page'" 36 | if shared: 37 | permissions = [{"role": "editor", "type": "space_permission"}] 38 | else: 39 | permissions = [ 40 | { 41 | "role": "editor", 42 | "type": "user_permission", 43 | "user_id": self._client.current_user.id, 44 | } 45 | ] 46 | page_id = self._client.create_record( 47 | "block", self, type=type, permissions=permissions 48 | ) 49 | page = self._client.get_block(page_id) 50 | page.title = title 51 | return page 52 | -------------------------------------------------------------------------------- /Python-Server/app/notion/user.py: -------------------------------------------------------------------------------- 1 | from .logger import logger 2 | from .maps import property_map, field_map 3 | from .records import Record 4 | 5 | 6 | class User(Record): 7 | 8 | _table = "notion_user" 9 | 10 | given_name = field_map("given_name") 11 | family_name = field_map("family_name") 12 | email = field_map("email") 13 | locale = field_map("locale") 14 | time_zone = field_map("time_zone") 15 | 16 | @property 17 | def full_name(self): 18 | return " ".join([self.given_name or "", self.family_name or ""]).strip() 19 | 20 | def _str_fields(self): 21 | return super()._str_fields() + ["email", "full_name"] 22 | -------------------------------------------------------------------------------- /Python-Server/app/notion/utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import uuid 3 | 4 | from bs4 import BeautifulSoup 5 | from urllib.parse import urlparse, parse_qs, quote_plus, unquote_plus 6 | from datetime import datetime 7 | from slugify import slugify as _dash_slugify 8 | 9 | from .settings import BASE_URL, SIGNED_URL_PREFIX, S3_URL_PREFIX, S3_URL_PREFIX_ENCODED 10 | 11 | 12 | class InvalidNotionIdentifier(Exception): 13 | pass 14 | 15 | 16 | def now(): 17 | return int(datetime.now().timestamp() * 1000) 18 | 19 | 20 | def extract_id(url_or_id): 21 | """ 22 | Extract the block/page ID from a Notion.so URL -- if it's a bare page URL, it will be the 23 | ID of the page. If there's a hash with a block ID in it (from clicking "Copy Link") on a 24 | block in a page), it will instead be the ID of that block. If it's already in ID format, 25 | it will be passed right through. 26 | """ 27 | input_value = url_or_id 28 | if url_or_id.startswith(BASE_URL): 29 | url_or_id = ( 30 | url_or_id.split("#")[-1] 31 | .split("/")[-1] 32 | .split("&p=")[-1] 33 | .split("?")[0] 34 | .split("-")[-1] 35 | ) 36 | try: 37 | return str(uuid.UUID(url_or_id)) 38 | except ValueError: 39 | raise InvalidNotionIdentifier(input_value) 40 | 41 | 42 | def get_embed_data(source_url): 43 | 44 | return requests.get( 45 | "https://api.embed.ly/1/oembed?key=421626497c5d4fc2ae6b075189d602a2&url={}".format( 46 | source_url 47 | ) 48 | ).json() 49 | 50 | 51 | def get_embed_link(source_url): 52 | 53 | data = get_embed_data(source_url) 54 | 55 | if "html" not in data: 56 | return source_url 57 | 58 | url = list(BeautifulSoup(data["html"], "html.parser").children)[0]["src"] 59 | 60 | return parse_qs(urlparse(url).query)["src"][0] 61 | 62 | 63 | def add_signed_prefix_as_needed(url, client=None, id=""): 64 | 65 | if url is None: 66 | return 67 | 68 | if url.startswith(S3_URL_PREFIX): 69 | url = SIGNED_URL_PREFIX + quote_plus(url) + "?table=block&id=" + id 70 | if client: 71 | url = client.session.head(url).headers.get("Location") 72 | 73 | return url 74 | 75 | 76 | def remove_signed_prefix_as_needed(url): 77 | if url is None: 78 | return 79 | if url.startswith(SIGNED_URL_PREFIX): 80 | return unquote_plus(url[len(S3_URL_PREFIX) :]) 81 | elif url.startswith(S3_URL_PREFIX_ENCODED): 82 | parsed = urlparse(url.replace(S3_URL_PREFIX_ENCODED, S3_URL_PREFIX)) 83 | return "{}://{}{}".format(parsed.scheme, parsed.netloc, parsed.path) 84 | else: 85 | return url 86 | 87 | 88 | def slugify(original): 89 | return _dash_slugify(original).replace("-", "_") 90 | 91 | 92 | def get_by_path(path, obj, default=None): 93 | 94 | if isinstance(path, str): 95 | path = path.split(".") 96 | 97 | value = obj 98 | 99 | # try to traverse down the sequence of keys defined in the path, to get the target value if it exists 100 | try: 101 | for key in path: 102 | if isinstance(value, list): 103 | key = int(key) 104 | value = value[key] 105 | except (KeyError, TypeError, IndexError): 106 | value = default 107 | 108 | return value 109 | -------------------------------------------------------------------------------- /Python-Server/app/notion_ai/custom_errors.py: -------------------------------------------------------------------------------- 1 | class OnImageNotFound(Exception): 2 | def __init__(self, *args): 3 | if args: 4 | self.message = args[0] 5 | else: 6 | self.message = None 7 | # args[1].imageStatusCode = 409 8 | args[1].statusCode = 200 9 | 10 | def __str__(self): 11 | if self.message: 12 | return 'OnImageNotFound, {0} '.format(self.message) 13 | else: 14 | return 'OnImageNotFound has been raised' 15 | 16 | 17 | class OnUrlNotValid(Exception): 18 | def __init__(self, *args): 19 | if args: 20 | self.message = args[0] 21 | else: 22 | self.message = None 23 | args[1].statusCode = 500 24 | 25 | def __str__(self): 26 | if self.message: 27 | return 'OnImageUrlNotValid, {0} '.format(self.message) 28 | else: 29 | return 'OnImageUrlNotValid has been raised' 30 | 31 | 32 | class OnTokenV2NotValid(Exception): 33 | def __init__(self, *args): 34 | if args: 35 | self.message = args[0] 36 | else: 37 | self.message = None 38 | 39 | def __str__(self): 40 | if self.message: 41 | return 'OnTokenV2NotValid, {0} '.format(self.message) 42 | else: 43 | return 'OnTokenV2NotValid has been raised' 44 | 45 | 46 | class OnCollectionNotAvailable(Exception): 47 | def __init__(self, *args): 48 | if args: 49 | self.message = args[0] 50 | else: 51 | self.message = None 52 | 53 | def __str__(self): 54 | if self.message: 55 | return 'OnCollectionNotAvailable, {0} '.format(self.message) 56 | else: 57 | return 'OnCollectionNotAvailable has been raised' 58 | 59 | 60 | class OnServerNotConfigured(Exception): 61 | def __init__(self, *args): 62 | if args: 63 | self.message = args[0] 64 | else: 65 | self.message = None 66 | self.status_code = 404 67 | 68 | def __str__(self): 69 | if self.message: 70 | return 'OnServerNotConfigured, {0} '.format(self.message) 71 | else: 72 | return 'OnServerNotConfigured has been raised' 73 | 74 | 75 | class OnWebClipperError(Exception): 76 | def __init__(self, *args): 77 | if args: 78 | message = args[0] 79 | self.message = message['message'] 80 | else: 81 | self.message = None 82 | 83 | def __str__(self): 84 | if self.message: 85 | return '{0}'.format(self.message) 86 | else: 87 | return 'OnWebClipperError has been raised' -------------------------------------------------------------------------------- /Python-Server/app/notion_ai/mind_structure.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from notion_ai.custom_errors import OnCollectionNotAvailable, OnServerNotConfigured 4 | from notion_ai.utils import get_joined_url, create_json_response 5 | import json 6 | import os 7 | from server_utils.utils import SETTINGS_FOLDER 8 | import urllib.parse 9 | 10 | from notion.block import CollectionViewPageBlock 11 | 12 | 13 | class MindStructure: 14 | def __init__(self, notion_ai, client, data, logging): 15 | self.client = client 16 | self.data = data 17 | self.collection_index = -1 18 | self.logging = logging 19 | self.notion_ai = notion_ai 20 | 21 | self.loaded = False 22 | 23 | def set_current_collection(self, index=0, id=None): 24 | print("Getting collection at index {0}".format(index)) 25 | try: 26 | if hasattr(self, 'collection_index') and index == self.collection_index: 27 | print("Collection is the same as requested before. {0} {1}".format(self.collection_index, index)) 28 | return self.collection 29 | else: 30 | print("Collection is not the same as requested before. {0} {1}".format(self.collection_index, index)) 31 | collection_id, id = self.get_collection_by_index(int(index)) 32 | # collection is our database or "mind" in notion, as be have multiple, if not suplied, it will get 33 | # the first one as the priority one. 34 | self.collection = self.client.get_collection(collection_id=collection_id) 35 | self.current_mind_id = id 36 | self.collection_index = index 37 | 38 | return self.collection 39 | except requests.exceptions.HTTPError as e: 40 | print("Http error : " + str(e)) 41 | except OnCollectionNotAvailable as e: 42 | print("Mind error: " + str(e)) 43 | 44 | def get_collection_by_index(self, index=0): 45 | structure = self.get_mind_structure_from_json() 46 | if len(structure) > 0 and int(index) >= 0: 47 | try: 48 | collection_id = structure[index]["collection_id"] 49 | collection_block_page_id = structure[index]["collection_block_page_id"] 50 | return collection_id, collection_block_page_id 51 | except TypeError as e: 52 | self.logging.info(str(e)) 53 | else: 54 | self.logging.info("No structure or collection was found") 55 | raise OnCollectionNotAvailable 56 | 57 | def get_mind_structure(self, notion_ai, structure_url=None, return_response=True): 58 | if structure_url is None: 59 | try: 60 | structure_url = self.data['url'] 61 | except AttributeError as e: 62 | raise OnServerNotConfigured(str(e)) 63 | 64 | structure = self.client.get_block(structure_url) 65 | structure.refresh() 66 | available_connections = [] 67 | 68 | for child in structure.children: 69 | try: 70 | if self._is_correct_collection(child): 71 | x = { 72 | "collection_name": child.title, 73 | "collection_id": child.collection.id, 74 | "collection_url": get_joined_url(child.id), 75 | "collection_block_page_id": child.id, 76 | "collection_cover": self._process_cover(child.collection.cover, child.collection.id) 77 | } 78 | available_connections.append(x) 79 | else: 80 | self.logging.info(str("Other type of not collections found on page {0}".format(type(child)))) 81 | except AttributeError as e: 82 | self.logging.info(str(e)) 83 | 84 | if len(available_connections) > 0: 85 | self._save_mind_structure(available_connections) 86 | self.loaded = True 87 | else: 88 | self.loaded = False 89 | 90 | if return_response: 91 | return create_json_response(notion_ai, status_code=300, append_content=available_connections) 92 | return available_connections 93 | 94 | def _is_correct_collection(self, child): 95 | return isinstance(child, CollectionViewPageBlock) and hasattr(child, "title") and len(child.title) > 0 96 | 97 | # https://github.com/jamalex/notion-py/issues/324 98 | def _process_cover(self, cover, collection_id): 99 | url = cover 100 | if url: 101 | if "http" not in cover or "https" not in cover: 102 | url = "https://www.notion.so" + cover 103 | else: 104 | cover = urllib.parse.quote(cover) 105 | cover = cover.replace("/", "%2F") 106 | url = "https://www.notion.so/image/{0}?table=collection&id={1}&width=300&userId={2}&cache=v2".format( 107 | cover, collection_id, self.client.current_user.id) 108 | else: 109 | url = "https://images.unsplash.com/photo-1579546929662-711aa81148cf?ixlib=rb-1.2.1&ixid=MXwxMjA3fDB8MHxleHBsb3JlLWZlZWR8MXx8fGVufDB8fHw%3D&w=1000&q=80" 110 | return url 111 | 112 | def _save_mind_structure(self, available_connection): 113 | try: 114 | with open(SETTINGS_FOLDER + 'mind_structure.json', 'w') as f: 115 | json.dump(available_connection, f) 116 | except FileNotFoundError as e: 117 | raise OnServerNotConfigured(e) 118 | 119 | def get_number_of_collections(self): 120 | structure = self.get_mind_structure_from_json() 121 | return len(structure) 122 | 123 | def get_mind_structure_from_json(self): 124 | if os.path.isfile(SETTINGS_FOLDER + 'mind_structure.json'): 125 | print("Mind json file found.") 126 | structure = {} 127 | with open(SETTINGS_FOLDER + 'mind_structure.json') as json_file: 128 | structure = json.load(json_file) 129 | return structure 130 | else: 131 | print("Mind json file not found. Creating...") 132 | return self.get_mind_structure(notion_ai=self.notion_ai, return_response=False) 133 | -------------------------------------------------------------------------------- /Python-Server/app/notion_ai/property_manager/multi_tag_manager.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | from uuid import uuid1 3 | 4 | import json 5 | import requests 6 | 7 | from notion_ai.property_manager.tag_object import TagObject 8 | from notion_ai.utils import create_json_response 9 | from server_utils.utils import SETTINGS_FOLDER, DEFAULT_COLOR 10 | 11 | 12 | ## This class manages the multi-choice tag property on a mind element. We can get current tags and add tags. 13 | class MultiTagManager: 14 | def __init__(self, logging, client, mind_structure, options): 15 | self.logging = logging 16 | 17 | self.client = client 18 | self.mind_structure = mind_structure 19 | self.multi_tag_property = options['multi_tag_property'] 20 | 21 | def get_multi_select_tags(self,notion_ai, append_tags, collection_index=None): 22 | #self.mind_structure.set_current_collection(int(collection_index)) 23 | prop = self.multi_tag_property 24 | print(prop) 25 | collection_schema = self.mind_structure.collection.get("schema") 26 | prop_schema = next( 27 | (v for k, v in collection_schema.items() if v["name"] == prop), None 28 | ) 29 | if not prop_schema: 30 | raise ValueError( 31 | f'"{prop}" property does not exist on the collection!' 32 | ) 33 | if prop_schema["type"] != "multi_select": 34 | raise ValueError(f'"{prop}" is not a multi select property!') 35 | l = [] 36 | 37 | if "options" in prop_schema: 38 | for element in prop_schema["options"]: 39 | color = DEFAULT_COLOR 40 | print(element) 41 | if "color" in element: 42 | color = self._notion_color_to_hex(element["color"]) 43 | else: 44 | element["color"] = color 45 | 46 | x = TagObject().parse_from_notion_element(element=element, tag_color=color) 47 | 48 | l.append(x) 49 | 50 | l = l + append_tags 51 | return create_json_response(notion_ai, status_code=200, append_content=l) 52 | else: 53 | if len(append_tags) > 0: 54 | return create_json_response(notion_ai, status_code=200, append_content=append_tags) 55 | else: 56 | raise ValueError(f'"{prop}" property has no tags on it.') 57 | 58 | def _get_multi_select_tags_as_list(self, collection_index=0): 59 | self.mind_structure.set_current_collection(int(collection_index)) 60 | prop = self.multi_tag_property 61 | collection_schema = self.mind_structure.collection.get("schema") 62 | prop_schema = next( 63 | (v for k, v in collection_schema.items() if v["name"] == prop), None 64 | ) 65 | if not prop_schema: 66 | raise ValueError( 67 | f'"{prop}" property does not exist on the collection!' 68 | ) 69 | if prop_schema["type"] != "multi_select": 70 | raise ValueError(f'"{prop}" is not a multi select property!') 71 | 72 | l = [] 73 | 74 | if "options" in prop_schema: 75 | for element in prop_schema["options"]: 76 | l.append(element["value"]) 77 | return l 78 | else: 79 | return l 80 | 81 | def update_multi_select_tags(self,notion_ai, id, tags_json, collection_index=0, color=None): 82 | try: 83 | # self.mind_structure.set_current_collection(int(collection_index)) 84 | self.logging.info("Updating multi select tags for row {0} {1} {2}".format(id, tags_json, collection_index)) 85 | print("Updating multi select tags for row {0} {1} {2}".format(id, tags_json, collection_index)) 86 | block = self.client.get_block(id) 87 | 88 | current_tags_notion = self._get_multi_select_tags_as_list(collection_index) 89 | tag_to_add = [] 90 | 91 | for tag in tags_json: 92 | 93 | if tag['option_name'] not in current_tags_notion or len(current_tags_notion) == 0: 94 | print(tag['option_name'] + " is new") 95 | value = self.add_new_multi_select_value(self.multi_tag_property, tag['option_name'], tag['option_color']) 96 | else: 97 | print(tag['option_name'] + " already exists") 98 | value = tag['option_name'] 99 | tag_to_add.append(value) 100 | 101 | block.set_property(self.multi_tag_property, tag_to_add) 102 | 103 | if len(block.get_property(self.multi_tag_property)) > 0: 104 | return create_json_response(notion_ai, status_code=205, rowId=id) 105 | else: 106 | return create_json_response(notion_ai, status_code=206, rowId=id) 107 | 108 | except ValueError as e: 109 | self.logging.info(e) 110 | print(e) 111 | except requests.exceptions.HTTPError as e: 112 | print(e) 113 | self.logging.info(e) 114 | return create_json_response(notion_ai, status_code=e.response.status_code, rowId=id) 115 | 116 | def add_new_multi_select_value(self, prop, value, color=None): 117 | colors = [ 118 | "default", 119 | "gray", 120 | "brown", 121 | "orange", 122 | "yellow", 123 | "green", 124 | "blue", 125 | "purple", 126 | "pink", 127 | "red", 128 | ] 129 | """`prop` is the name of the multi select property.""" 130 | if color is None: 131 | color = choice(colors) 132 | else: 133 | color = self._hex_to_notion_color(color) 134 | 135 | print("Tag color for {0} will be {1}".format(value,color)) 136 | 137 | collection_schema = self.mind_structure.collection.get("schema") 138 | prop_schema = next( 139 | (v for k, v in collection_schema.items() if v["name"] == prop), None 140 | ) 141 | if not prop_schema: 142 | raise ValueError( 143 | f'"{prop}" property does not exist on the collection!' 144 | ) 145 | if prop_schema["type"] != "multi_select": 146 | raise ValueError(f'"{prop}" is not a multi select property!') 147 | 148 | if "options" in prop_schema: # if there are no options in it, means there's no tags. 149 | dupe = next( 150 | (o for o in prop_schema["options"] if o["value"] == value), None 151 | ) 152 | if dupe: 153 | print(f'"{value}" already exists in the schema!') 154 | print(dupe['value']) 155 | return dupe['value'] 156 | 157 | prop_schema["options"].append( 158 | {"id": str(uuid1()), "value": value, "color": color} 159 | ) 160 | self.mind_structure.collection.set("schema", collection_schema) 161 | return value 162 | else: 163 | prop_schema["options"] = [] 164 | prop_schema["options"].append({"id": str(uuid1()), "value": value, "color": color}) 165 | 166 | self.mind_structure.collection.set("schema", collection_schema) 167 | return value 168 | 169 | def _notion_color_to_hex(self,color_name): 170 | if color_name == None: 171 | return "#505558" 172 | elif color_name == "default": 173 | return "#505558" 174 | elif color_name == "gray": 175 | return "#6B6F71" 176 | elif color_name == "brown": 177 | return "#695B55" 178 | elif color_name == "orange": 179 | return "#9F7445" 180 | elif color_name == "yellow": 181 | return "#9F9048" 182 | elif color_name == "green": 183 | return "#467870" 184 | elif color_name == "blue": 185 | return "#487088" 186 | elif color_name == "purple": 187 | return "#6C598F" 188 | elif color_name == "pink": 189 | return "#904D74" 190 | elif color_name == "red": 191 | return "#9F5C58" 192 | 193 | def _hex_to_notion_color(self,color_name): 194 | if color_name == None: 195 | return "default" 196 | if color_name == "#505558": 197 | return "default" 198 | elif color_name == "#6B6F71": 199 | return "gray" 200 | elif color_name == "#695B55": 201 | return "brown" 202 | elif color_name == "#9F7445": 203 | return "orange" 204 | elif color_name == "#9F9048": 205 | return "yellow" 206 | elif color_name == "#467870": 207 | return "green" 208 | elif color_name == "#487088": 209 | return "blue" 210 | elif color_name == "#6C598F": 211 | return "purple" 212 | elif color_name == "#904D74": 213 | return "pink" 214 | elif color_name == "#9F5C58": 215 | return "red" 216 | 217 | -------------------------------------------------------------------------------- /Python-Server/app/notion_ai/property_manager/property_manager.py: -------------------------------------------------------------------------------- 1 | from notion_ai.property_manager.multi_tag_manager import MultiTagManager 2 | from server_utils.utils import SETTINGS_FOLDER 3 | import json 4 | 5 | 6 | class PropertyManager: 7 | def __init__(self, logging, client, mind_structure): 8 | self.logging = logging 9 | 10 | self.client = client 11 | self.mind_structure = mind_structure 12 | 13 | current_properties = {} 14 | with open(SETTINGS_FOLDER + 'properties.json') as json_file: 15 | current_properties = json.load(json_file) 16 | self.current_properties = current_properties 17 | self.multi_tag_manager = MultiTagManager(logging, self.client, self.mind_structure, current_properties) 18 | 19 | def update_properties(self, block, **kwargs): 20 | self.logging.info("Updating propertios {0} for this block {1}".format(kwargs.keys(), block.id)) 21 | for key, value in kwargs.items(): 22 | block.set_property(self.current_properties[str(key)], value) 23 | 24 | def get_properties(self, block, debug=False, **kwargs): 25 | if debug: 26 | self.logging.info("Getting properties {0} for this block {1}".format(kwargs.keys(), block.id)) 27 | for key, value in kwargs.items(): 28 | return block.get_property(self.current_properties[str(key)]) 29 | 30 | def get_property_name(self, block, **kwargs): 31 | self.logging.info("Getting properties {0} for this block {1}".format(kwargs.items().encode('utf8'), block.id)) 32 | for key, value in kwargs.items(): 33 | return self.current_properties[str(key)] 34 | -------------------------------------------------------------------------------- /Python-Server/app/notion_ai/property_manager/tag_object.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import random 3 | 4 | class TagObject: 5 | def __init__(self, tag_value=None, tag_id=None, tag_color=None): 6 | self.option_name = tag_value 7 | 8 | if tag_id is None: 9 | tag_id = str(uuid.uuid1()) 10 | if tag_color is None: 11 | tag_color = "#"+''.join([random.choice('0123456789ABCDEF') for j in range(6)]) 12 | 13 | x = { 14 | "option_name": tag_value, 15 | "option_id": tag_id, 16 | "option_color": tag_color, 17 | } 18 | 19 | self.dic = x 20 | 21 | def __str__(self): 22 | return self.dic 23 | 24 | def to_dict(self): 25 | return self.dic 26 | 27 | def value(self): 28 | return self.dic["option_name"] 29 | 30 | def id(self): 31 | return self.dic["option_id"] 32 | 33 | def color(self): 34 | return self.dic["option_color"] 35 | 36 | def parse_from_notion_element(self, element, tag_color): 37 | print("Parsing Notion Element Tag {0}".format(element)) 38 | 39 | x = { 40 | "option_name": element["value"], 41 | "option_id": element["id"], 42 | "option_color": tag_color, 43 | } 44 | 45 | return x 46 | -------------------------------------------------------------------------------- /Python-Server/app/notion_ai/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from translation.translation_manager import TranslationManager 4 | from .custom_errors import OnUrlNotValid, OnImageNotFound, OnServerNotConfigured, OnWebClipperError 5 | from server_utils.utils import open_website 6 | 7 | from notion.block import ImageBlock 8 | from time import sleep 9 | 10 | import requests 11 | import validators 12 | import socket 13 | 14 | 15 | 16 | 17 | ##Makes a web request to the notion web clipper API to add url's and returns the rowId 18 | 19 | def web_clipper_request(self, url, title, current_mind_id, logging): 20 | try: 21 | cookies = { 22 | 'token_v2': self.token_v2, 23 | } 24 | 25 | headers = { 26 | 'Content-Type': 'application/json', 27 | } 28 | if title == None: 29 | title = url 30 | 31 | is_well_formed = validators.url(url) 32 | 33 | if is_well_formed: 34 | url_object = { 35 | "url": url, 36 | "title": title 37 | } 38 | data_dict = { 39 | "type": "block", 40 | "blockId": "{}".format(current_mind_id), 41 | "property": "P#~d", 42 | "items": [url_object], 43 | "from": "chrome" 44 | } 45 | data = json.dumps(data_dict) 46 | 47 | self.logging.info(data) 48 | response = requests.post('https://www.notion.so/api/v3/addWebClipperURLs', headers=headers, cookies=cookies, 49 | data=data) 50 | response_text = response.text 51 | 52 | json_response = json.loads(response_text) 53 | 54 | if 'createdBlockIds' in json_response: 55 | rowId = json_response['createdBlockIds'][0] 56 | return rowId 57 | else: 58 | raise OnWebClipperError(json_response) 59 | else: 60 | raise OnUrlNotValid("Invalid url was sent", self) 61 | except KeyError as e: 62 | raise OnServerNotConfigured(e) 63 | 64 | 65 | ##Extracts all images from content as a list of url's 66 | def extract_image_from_content(self, page_content, row_id): 67 | print("Will look for images on {}".format(page_content)) 68 | list_of_img_url = [] 69 | ##This loop looks at all the page content and finds every image on it. 70 | for element in page_content: 71 | im = self.client.get_block(element) 72 | block_type = im.get('type') 73 | if block_type == 'image': 74 | list_of_img_url.append(im.source) 75 | elif len(im.children) > 0: 76 | for child in im.children: 77 | if isinstance(child, ImageBlock): 78 | list_of_img_url.append(child.source) 79 | 80 | if len(list_of_img_url) == 0 and self.counter == self.times_to_retry: 81 | self.counter = 0 82 | print("Thumbnail Image URL not found. Value is None") 83 | raise OnImageNotFound("Thumbnail Image URL not found. Value is None", self) 84 | elif len(list_of_img_url) > 0: 85 | self.counter = 0 86 | self.logging.info("These images were found: " + str(list_of_img_url)) 87 | return list_of_img_url 88 | else: 89 | row = self.client.get_block(row_id) 90 | row.refresh() 91 | content = row.get('content') 92 | sleep(0.15) 93 | self.counter += 1 94 | return extract_image_from_content(self, content, row_id) 95 | return list_of_img_url 96 | 97 | 98 | def create_json_response(notion_ai, status_code = None, error_sentence=None, rowId=None, custom_sentence=None, append_content=None, port=None, custom_url="https://github.com/elblogbruno/NotionAI-MyMind/wiki/Common-Issues"): 99 | notion_ai.logging.info("Creating json response.") 100 | # if error_sentence is None: 101 | # error_sentence = "No translation found" 102 | 103 | if hasattr(notion_ai, 'loaded') and notion_ai.loaded: 104 | url = custom_url 105 | 106 | block_title = "-1" 107 | block_attached_url = "-1" 108 | 109 | if status_code is None: 110 | if hasattr(notion_ai, "status_code"): 111 | status_code = notion_ai.status_code 112 | else: 113 | status_code = 404 114 | else: 115 | notion_ai.status_code = status_code 116 | 117 | if rowId is not None: 118 | url = get_joined_url(rowId) 119 | row = notion_ai.client.get_block(rowId) 120 | block_title = row.title 121 | block_attached_url = row.url 122 | 123 | text_response = error_sentence 124 | status_text = "error" 125 | 126 | if text_response is None: 127 | text_response, status_text = notion_ai.translation_manager.get_response_text(status_code) 128 | 129 | if text_response == "error": 130 | text_response = error_sentence 131 | 132 | if len(block_attached_url) == 0: 133 | block_attached_url = "-1" 134 | if notion_ai.counter <= notion_ai.times_to_retry: 135 | notion_ai.counter = notion_ai.counter + 1 136 | return create_json_response(notion_ai=notion_ai, error_sentence=error_sentence, status_code=status_code, rowId=rowId, custom_sentence=custom_sentence, append_content=append_content, port=port, custom_url=custom_url) 137 | 138 | if len(block_title) == 0: 139 | block_title = "-1" 140 | 141 | if custom_sentence: 142 | text_response = custom_sentence 143 | 144 | x = { 145 | "status_code": status_code, 146 | "text_response": text_response, 147 | "status_text": status_text, 148 | "block_url": url, 149 | "block_title": block_title, 150 | "block_attached_url": block_attached_url, 151 | "extra_content": "null", 152 | } 153 | 154 | if append_content: 155 | x["extra_content"] = append_content 156 | # convert into JSON: 157 | json_response = json.dumps(x, ensure_ascii=False) 158 | print(json_response) 159 | return json_response 160 | else: 161 | notion_ai.translation_manager = TranslationManager(notion_ai.logging, notion_ai.static_folder) # we initialize the translation manager 162 | text_response, status_text = notion_ai.translation_manager.get_response_text(status_code) 163 | 164 | x = { 165 | "status_code": status_code, 166 | "text_response": text_response, 167 | "status_text": status_text, 168 | "block_url": get_server_url(port), 169 | "block_title": "-1", 170 | "block_attached_url": "-1", 171 | "extra_content": "null", 172 | } 173 | # convert into JSON: 174 | json_response = json.dumps(x) 175 | return json_response 176 | 177 | 178 | # based on the machine doing the request we know which extension is being used 179 | def get_current_extension_name(platform): 180 | print("Extension request platform is {}".format(platform)) 181 | if platform is None or len(platform) == 0: 182 | print("Final platform is unknown") 183 | return "Unknown mind extension" 184 | elif 'dart' in platform: 185 | print("Final platform is android") 186 | return "phone-app-extension" 187 | else: 188 | print("Final platform is browser") 189 | return "browser-extension" 190 | 191 | 192 | def open_browser_at_start(self, port): 193 | final_url = get_server_url(port) 194 | 195 | print("You should go to the homepage and set the credentials. The url will open in your browser now. If can't " 196 | "access a browser you can access {0}".format(final_url)) 197 | 198 | self.logging.info("You should go to the homepage and set the credentials. The url will open in your browser " 199 | "now. If can't access a browser you can access {0}".format(final_url)) 200 | 201 | open_website(final_url) 202 | 203 | 204 | def get_server_url(port): 205 | hostname = socket.gethostname() 206 | local_ip = socket.gethostbyname(hostname) 207 | final_url = "http://{0}:{1}/".format(str(local_ip), str(port)) 208 | return final_url 209 | 210 | 211 | def get_joined_url(rowId): 212 | rowIdExtracted = rowId.split("-") 213 | str1 = ''.join(str(e) for e in rowIdExtracted) 214 | url = "https://www.notion.so/" + str1 215 | return url 216 | -------------------------------------------------------------------------------- /Python-Server/app/requirements.txt: -------------------------------------------------------------------------------- 1 | # To ensure app dependencies are ported from your virtual environment/host machine into your container, run 'pip freeze > requirements.txt' in the terminal to overwrite this file 2 | clarifai-grpc==6.7.1 3 | #git+https://github.com/elblogbruno/notion-py.git 4 | requests==2.24.0 5 | urllib3==1.25.10 6 | validators==0.17.1 7 | Werkzeug==1.0.1 8 | tensorflow>=2.4.0 9 | keras 10 | pillow 11 | Quart~=0.14.1 12 | numpy~=1.19.2 13 | schedule 14 | #notion-py requirements 15 | commonmark 16 | bs4 17 | tzlocal 18 | python-slugify 19 | dictdiffer 20 | cached-property 21 | -------------------------------------------------------------------------------- /Python-Server/app/server_utils/check_update.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from server_utils.utils import open_website,get_path_file 3 | from notion_ai.utils import create_json_response 4 | import configparser 5 | from os.path import join 6 | 7 | 8 | def replace_version(version): 9 | '''Replace version in file with new version''' 10 | config = configparser.ConfigParser() 11 | config.read('static/version.cfg') 12 | config['version']['server_version'] = version 13 | 14 | 15 | def get_version(static_folder): 16 | print(static_folder) 17 | config = configparser.ConfigParser() 18 | config.read(get_path_file('static/version.cfg')) 19 | return config['version']['server_version'] 20 | 21 | 22 | def check_update(logging, static_folder, notion=None, port=None, return_response=False): 23 | api_url = "https://api.github.com/repos/elblogbruno/NotionAI-MyMind/releases/latest" 24 | response = requests.get(api_url) 25 | 26 | json_response = response.json() 27 | try: 28 | curr_version = get_version(static_folder) 29 | new_version = json_response['tag_name'] 30 | print("Checking server update...") 31 | print("Current version {0} - New Version Found {1}".format(curr_version, new_version)) 32 | if new_version == curr_version or curr_version > new_version: 33 | print("Server is up to date") 34 | if return_response: 35 | url = "https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/{0}".format(curr_version) 36 | return create_json_response(notion, port=port, status_code=0, custom_url=url) 37 | else: 38 | print("New version available: " + new_version) 39 | url = "https://github.com/elblogbruno/NotionAI-MyMind/releases/tag/{0}".format(new_version) 40 | if return_response: 41 | return create_json_response(notion, port=port, status_code=1, custom_url=url) 42 | else: 43 | open_website(url) 44 | except KeyError as e: 45 | logging.error("Key error checking for update, maybe file does not exist? " + str(e)) 46 | except requests.exceptions.HTTPError as e: 47 | logging.error("HTTP Error checking for update, maybe github is down or you don't have any internet?" + str(e)) 48 | -------------------------------------------------------------------------------- /Python-Server/app/server_utils/handle_options_data.py: -------------------------------------------------------------------------------- 1 | from .utils import save_data, save_options, save_tagging_options, save_properties_name 2 | 3 | # This handles the data from the options formulary and saves it to .json files. 4 | 5 | 6 | def process_formulary(logging, data): 7 | use_clarifai = _process_data(logging, data) 8 | _process_tagging_options(logging, data, use_clarifai) 9 | _process_properties(logging, data) 10 | 11 | return data 12 | 13 | 14 | def _process_data(logging, data): 15 | notion_url = data['notion_url'] 16 | 17 | notion_token = data['notion_token'] 18 | 19 | if data['clarifai_key']: 20 | clarifai_key = data['clarifai_key'] 21 | save_data(logging, url=notion_url, token=notion_token, clarifai_key=clarifai_key) 22 | use_clarifai = True 23 | else: 24 | save_data(logging, url=notion_url, token=notion_token) 25 | use_clarifai = False 26 | 27 | save_options(logging, language_code=data['language_code']) 28 | 29 | return use_clarifai 30 | 31 | 32 | def _process_tagging_options(logging, data, use_clarifai): 33 | if "delete_after_tagging" in data: 34 | delete_after_tagging = data['delete_after_tagging'] 35 | else: 36 | delete_after_tagging = False 37 | 38 | confidence_treshold = 0.20 39 | 40 | if "confidence_treshold" in data: 41 | if data['confidence_treshold'] == "": 42 | confidence_treshold = 0.20 43 | else: 44 | confidence_treshold = data['confidence_treshold'] 45 | 46 | logging.info( 47 | "Current Tagging Options --> Delete after Tagging: {0} ,Confidence Treshold: {1} , Use Clarifai: {2}".format( 48 | delete_after_tagging, confidence_treshold, use_clarifai)) 49 | 50 | save_tagging_options(logging, use_clarifai=use_clarifai, delete_after_tagging=delete_after_tagging, 51 | confidence_treshold=confidence_treshold) 52 | 53 | 54 | def _process_properties(logging, data): 55 | multi_tag_property = 'Tags' 56 | if data['multi_tag_property']: 57 | multi_tag_property = data['multi_tag_property'] 58 | 59 | mind_extension_property = 'mind_extension' 60 | if data['mind_extension_property']: 61 | mind_extension_property = data['mind_extension_property'] 62 | 63 | ai_tags_property = 'AITagsText' 64 | if data['ai_tags_property']: 65 | ai_tags_property = data['ai_tags_property'] 66 | 67 | notion_date_property = 'reminder' 68 | if data['notion_date_property']: 69 | notion_date_property = data['notion_date_property'] 70 | 71 | logging.info( 72 | "Current properties --> Multi Tag Property: {0} , Mind extension Property: {1} , AI Tags Property: {2}, " 73 | "Notion Date Reminder Property: {3}".format( 74 | multi_tag_property, mind_extension_property, ai_tags_property, notion_date_property)) 75 | 76 | save_properties_name(logging, multi_tag_property=multi_tag_property, 77 | mind_extension_property=mind_extension_property, ai_tags_property=ai_tags_property, 78 | notion_date_property=notion_date_property) 79 | -------------------------------------------------------------------------------- /Python-Server/app/server_utils/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests # to get image from the web 3 | import shutil # to save it locally 4 | import uuid 5 | import webbrowser 6 | import os, re 7 | 8 | path = "/proc/self/cgroup" 9 | 10 | DEFAULT_COLOR = "#505558" 11 | UPLOAD_FOLDER = os.getcwd()+'/uploads/' 12 | SETTINGS_FOLDER = os.getcwd()+'/settings/' 13 | 14 | ALLOWED_EXTENSIONS = set( 15 | ['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'wav', 'ogg', 'mp4', 'mov', 'avi', 'm4v', '3gp']) 16 | ALLOWED_AUDIO_EXTENSIONS = set(['mp3', 'wav', 'ogg']) 17 | ALLOWED_VIDEO_EXTENSIONS = set(['mp4', 'mov', 'avi', 'm4v', '3gp']) 18 | 19 | 20 | def get_path_file(filename): 21 | import os 22 | import sys 23 | from os import chdir 24 | from os.path import join 25 | from os.path import dirname 26 | from os import environ 27 | 28 | if hasattr(sys, '_MEIPASS'): 29 | # PyInstaller >= 1.6 30 | chdir(sys._MEIPASS) 31 | filename = join(sys._MEIPASS, filename) 32 | elif '_MEIPASS2' in environ: 33 | # PyInstaller < 1.6 (tested on 1.5 only) 34 | chdir(environ['_MEIPASS2']) 35 | filename = join(environ['_MEIPASS2'], filename) 36 | # else: 37 | # chdir(dirname(sys.argv[0])) 38 | # filename = join(dirname(sys.argv[0]), filename) 39 | 40 | return filename 41 | 42 | 43 | def is_docker(): 44 | if not os.path.isfile(path): return False 45 | with open(path) as f: 46 | for line in f: 47 | if re.match("\d+:[\w=]+:/docker(-[ce]e)?/\w+", line): 48 | return True 49 | return False 50 | 51 | 52 | def ask_server_port(logging): 53 | if is_docker(): 54 | print("running on docker") 55 | return int("5000") 56 | else: 57 | filename = SETTINGS_FOLDER + 'port.json' 58 | if os.path.isfile(filename): 59 | logging.info("Initiating with a found port.json file.") 60 | 61 | with open(filename, 'r') as json_file: 62 | options = json.load(json_file) 63 | logging.info("Using {} port".format(options['port'])) 64 | return options['port'] 65 | 66 | else: 67 | print("Asking initially for a port.") 68 | 69 | logging.info("Asking initially for a port.") 70 | 71 | port = input("Which port you'd like to run the server on: ") 72 | 73 | logging.info("Using {} port".format(port)) 74 | 75 | options = { 76 | 'port': port 77 | } 78 | 79 | with open(filename, 'w') as outfile: 80 | json.dump(options, outfile) 81 | 82 | logging.info("Port saved succesfully!") 83 | 84 | return options['port'] 85 | 86 | 87 | def save_tagging_options(logging, **kwargs): 88 | logging.info("Saving options.") 89 | 90 | data = {} 91 | for key, value in kwargs.items(): 92 | if key == "confidence_treshold": 93 | data[key] = value 94 | elif isinstance(value, bool): 95 | data[key] = value 96 | else: 97 | data[key] = True if len(value) > 0 else False 98 | 99 | with open(SETTINGS_FOLDER + 'tagging_options.json', 'w') as outfile: 100 | json.dump(data, outfile) 101 | 102 | logging.info("Options saved succesfully!") 103 | 104 | 105 | def save_options(logging, **kwargs): 106 | logging.info("Saving options.") 107 | 108 | data = {} 109 | for key, value in kwargs.items(): 110 | data[key] = value 111 | 112 | with open(SETTINGS_FOLDER + 'options.json', 'w') as outfile: 113 | json.dump(data, outfile) 114 | 115 | logging.info("Options saved succesfully!") 116 | 117 | 118 | def save_properties_name(logging, **kwargs): 119 | logging.info("Saving properties.") 120 | data = {} 121 | 122 | for key, value in kwargs.items(): 123 | data[key] = value 124 | 125 | with open(SETTINGS_FOLDER + 'properties.json', 'w') as outfile: 126 | json.dump(data, outfile) 127 | 128 | logging.info("Properties saved succesfully!") 129 | 130 | 131 | def save_data(logging, **kwargs): 132 | logging.info("Saving data.") 133 | data = {} 134 | 135 | for key, value in kwargs.items(): 136 | data[key] = value 137 | 138 | with open(SETTINGS_FOLDER + 'data.json', 'w') as outfile: 139 | json.dump(data, outfile) 140 | 141 | logging.info("Data saved succesfully!") 142 | 143 | 144 | def append_data(logging, **kwargs): 145 | logging.info("Appending data.") 146 | with open(SETTINGS_FOLDER + "data.json", "r+") as file: 147 | current_data = json.load(file) 148 | data = {} 149 | for key, value in kwargs.items(): 150 | data[key] = value 151 | current_data.update(data) 152 | file.seek(0) 153 | json.dump(current_data, file) 154 | logging.info("Appending data succesfully!") 155 | 156 | 157 | def download_audio_from_url(audio_url): 158 | print("Downloading this {} audio".format(audio_url)) 159 | filename = "./uploads/" + str(uuid.uuid4()) 160 | if 'mp3' in audio_url: 161 | filename = filename + ".mp3" 162 | elif 'wav' in audio_url: 163 | filename = filename + ".wav" 164 | elif 'ogg' in audio_url: 165 | filename = filename + ".ogg" 166 | 167 | # Open the url audio, set stream to True, this will return the stream content. 168 | r = requests.get(audio_url, stream=True) 169 | 170 | # Check if the audio was retrieved successfully 171 | if r.status_code == 200: 172 | # Set decode_content value to True, otherwise the downloaded audio file's size will be zero. 173 | r.raw.decode_content = True 174 | 175 | # Open a local file with wb ( write binary ) permission. 176 | with open(filename, 'wb') as f: 177 | shutil.copyfileobj(r.raw, f) 178 | 179 | print('Audio sucessfully Downloaded: ', filename) 180 | else: 181 | print('Audio Couldn\'t be retreived') 182 | return filename 183 | 184 | 185 | def download_image_from_url(image_url): 186 | filename = UPLOAD_FOLDER + str(uuid.uuid4()) 187 | 188 | if 'png' in image_url: 189 | filename = filename + ".png" 190 | elif 'jpg' in image_url: 191 | filename = filename + ".jpg" 192 | elif 'gif' in image_url: 193 | filename = filename + ".gif" 194 | 195 | print("Downloading this {} image".format(image_url)) 196 | 197 | # Open the url image, set stream to True, this will return the stream content. 198 | r = requests.get(image_url, stream=True) 199 | 200 | # Check if the image was retrieved successfully 201 | if r.status_code == 200: 202 | # Set decode_content value to True, otherwise the downloaded image file's size will be zero. 203 | r.raw.decode_content = True 204 | 205 | # Open a local file with wb ( write binary ) permission. 206 | with open(filename, 'wb') as f: 207 | shutil.copyfileobj(r.raw, f) 208 | 209 | print('Image sucessfully Downloaded: ', filename) 210 | else: 211 | print('Image Couldn\'t be retreived') 212 | return None 213 | return filename 214 | 215 | 216 | def createFolder(directory): 217 | try: 218 | if not os.path.exists(directory): 219 | os.makedirs(directory) 220 | except OSError: 221 | print('Error: Creating directory. ' + directory) 222 | 223 | 224 | def open_website(final_url): 225 | webbrowser.open(final_url) 226 | 227 | 228 | def get_file_extension(filename): 229 | return filename.rsplit('.', 1)[1].lower() 230 | 231 | 232 | def is_a_video_file(filename): 233 | extension = get_file_extension(filename) 234 | return extension in ALLOWED_VIDEO_EXTENSIONS 235 | 236 | 237 | def is_a_sound_file(filename): 238 | extension = get_file_extension(filename) 239 | return extension in ALLOWED_AUDIO_EXTENSIONS 240 | 241 | 242 | def allowed_file(filename): 243 | return '.' in filename and \ 244 | filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS 245 | -------------------------------------------------------------------------------- /Python-Server/app/static/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 |Notion Credentials are incorrect. Please check you have enter the correct tokenV2 or email and password.
19 |21 | Having trouble? Post an issue on github 22 |
23 |24 | Go back to settings 25 |
26 |27 | Continue to repo's homepage 28 |
29 |You have now a fully 100% open source server with AI.
19 |Server URL is:
20 | 21 |Set this url on the browser extension and phone app.
22 |If you have downloaded the iphone app, you can scan this QR Code, so you configure the app in seconds.
23 |26 | Don't have any of these? Download the browser extension and app right now 27 |
28 |29 | Having trouble? Post an issue on github 30 |
31 |32 | Go back to settings 33 |
34 |35 | Continue to repo's homepage 36 |
37 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
13 | This repo uses AI and the wonderful Notion to enable you to add anything on the web to your "Mind" and forget about everything else.
14 |
15 | Explore the docs »
16 |
17 |
18 | View Demo
19 | ·
20 | Report Bug
21 | ·
22 | Request Feature
23 |
4 |
5 |
6 |
7 |
8 |
11 | Este repo utiliza la IA y el maravilloso Notion para permitirte añadir cualquier cosa de la web a tu "Mente" y olvidarte de todo lo demás.
12 |
13 | Explorar la documentación »
14 |
15 |
16 | Ver demostración
17 | ·
18 | Informar de un error
19 | ·
20 | Solicitar una función
21 |