From 2c5dc34ea2d97908b2c60dadad7efcedb9b16c10 Mon Sep 17 00:00:00 2001 From: Francesco Siddi Date: Fri, 19 Aug 2016 09:19:06 +0200 Subject: [PATCH] Introducing Pillar Framework Refactor of pillar-server and pillar-web into a single python package. This simplifies the overall architecture of pillar applications. Special thanks @sybren and @venomgfx --- .gitignore | 18 +- deploy.sh | 57 - docker/build.sh | 17 - docker/dev/Dockerfile | 48 - docker/dev/runserver.sh | 3 - docker/pro/000-default.conf | 47 - docker/pro/Dockerfile | 61 - gulpfile.js | 104 + {pillar => old-src}/manage.py | 430 +- package.json | 24 + pillar/__init__.py | 374 + pillar/api/__init__.py | 15 + .../{application/utils => api}/activities.py | 28 +- .../modules => api}/blender_cloud/__init__.py | 0 .../blender_cloud/home_project.py | 40 +- .../blender_cloud/texture_libs.py | 11 +- .../modules => api}/blender_id.py | 24 +- pillar/api/custom_field_validation.py | 82 + .../{application/modules => api}/encoding.py | 18 +- pillar/{settings.py => api/eve_settings.py} | 16 +- .../modules => api}/file_storage.py | 177 +- pillar/{application/modules => api}/latest.py | 27 +- .../modules => api}/local_auth.py | 18 +- .../node_types/__init__.py | 0 .../{manage_extra => api}/node_types/act.py | 0 .../{manage_extra => api}/node_types/asset.py | 2 +- .../{manage_extra => api}/node_types/blog.py | 0 .../node_types/comment.py | 0 .../{manage_extra => api}/node_types/group.py | 0 .../node_types/group_hdri.py | 0 .../node_types/group_texture.py | 0 .../{manage_extra => api}/node_types/hdri.py | 2 +- .../{manage_extra => api}/node_types/page.py | 2 +- .../{manage_extra => api}/node_types/post.py | 2 +- .../node_types/project.py | 2 +- .../{manage_extra => api}/node_types/scene.py | 0 .../{manage_extra => api}/node_types/shot.py | 0 .../node_types/storage.py | 0 .../{manage_extra => api}/node_types/task.py | 0 .../{manage_extra => api}/node_types/text.py | 0 .../node_types/texture.py | 2 +- .../modules => api}/nodes/__init__.py | 21 +- .../modules => api}/nodes/custom/__init__.py | 0 .../modules => api}/nodes/custom/comment.py | 4 +- .../modules => api}/nodes/patch.py | 10 +- pillar/api/projects/__init__.py | 22 + pillar/api/projects/hooks.py | 246 + pillar/api/projects/routes.py | 138 + pillar/api/projects/utils.py | 92 + .../{application/modules => api}/service.py | 15 +- pillar/api/users/__init__.py | 15 + .../modules/users.py => api/users/hooks.py} | 65 +- pillar/api/users/routes.py | 19 + pillar/{application => api}/utils/__init__.py | 10 + pillar/api/utils/algolia.py | 98 + .../utils/authentication.py | 54 +- .../utils/authorization.py | 0 pillar/{application => api}/utils/cdn.py | 0 pillar/{application => api}/utils/encoding.py | 16 +- pillar/{application => api}/utils/gcs.py | 2 - pillar/{application => api}/utils/imaging.py | 0 pillar/{application => api}/utils/mongo.py | 0 pillar/{application => api}/utils/storage.py | 4 +- pillar/application/__init__.py | 268 - pillar/application/modules/projects.py | 472 - .../static/.webassets-cache/.gitignore | 3 - pillar/application/utils/algolia.py | 98 - pillar/auth/__init__.py | 104 + pillar/cli.py | 354 + pillar/config.py | 44 +- pillar/extension.py | 64 + pillar/manage_extra/import_data.py | 182 - pillar/runserver.wsgi | 11 - pillar/sdk.py | 100 + .../tests/__init__.py | 94 +- {tests => pillar/tests}/common_test_data.py | 0 {tests => pillar/tests}/config_testing.py | 0 .../tests/eve_test_settings.py | 7 +- pillar/web/__init__.py | 8 + pillar/web/main/__init__.py | 5 + pillar/web/main/routes.py | 324 + pillar/web/nodes/__init__.py | 5 + pillar/web/nodes/custom/__init__.py | 2 + pillar/web/nodes/custom/comments.py | 189 + pillar/web/nodes/custom/groups.py | 36 + pillar/web/nodes/custom/posts.py | 168 + pillar/web/nodes/custom/storage.py | 31 + pillar/web/nodes/forms.py | 289 + pillar/web/nodes/routes.py | 688 + pillar/web/notifications/__init__.py | 126 + pillar/web/projects/__init__.py | 5 + pillar/web/projects/forms.py | 63 + pillar/web/projects/routes.py | 771 + pillar/web/redirects/__init__.py | 64 + .../assets/css/vendor/bootstrap.min.css | 14 + pillar/web/static/assets/font/config.json | 1062 + .../assets/js/vendor/jquery.fileupload.min.js | 1 + .../js/vendor/jquery.iframe-transport.min.js | 1 + .../assets/js/vendor/jquery.montage.min.js | 1 + .../assets/js/vendor/jquery.select2.min.js | 3 + .../assets/js/vendor/jquery.ui.widget.min.js | 1 + pillar/web/system_util.py | 54 + pillar/web/users/__init__.py | 6 + pillar/web/users/forms.py | 66 + pillar/web/users/routes.py | 239 + pillar/web/utils/__init__.py | 135 + pillar/web/utils/caching.py | 25 + pillar/web/utils/exceptions.py | 4 + pillar/web/utils/forms.py | 187 + pillar/web/utils/jstree.py | 133 + setup.py | 27 +- src/scripts/algolia_search.js | 359 + src/scripts/file_upload.js | 162 + src/scripts/markdown/01_markdown-converter.js | 1622 + src/scripts/markdown/02_markdown-sanitizer.js | 114 + src/scripts/markdown/03_showdown.js | 2295 + src/scripts/markdown/04_pagedown-extra.js | 874 + src/scripts/project-edit.js | 238 + src/scripts/tutti/0_navbar.js | 70 + src/scripts/tutti/1_project-navigation.js | 182 + src/scripts/tutti/2_comments.js | 109 + src/scripts/tutti/3_project-utils.js | 15 + src/scripts/tutti/4_search.js | 100 + src/scripts/tutti/5_notifications.js | 329 + src/scripts/vrview-analytics.js | 44285 ++++++++++++++++ src/styles/_base.sass | 1078 + src/styles/_comments.sass | 599 + src/styles/_config.sass | 93 + src/styles/_error.sass | 113 + src/styles/_font-pillar.sass | 609 + src/styles/_homepage.sass | 1029 + src/styles/_join.sass | 176 + src/styles/_normalize.scss | 427 + src/styles/_notifications.sass | 284 + src/styles/_pages.sass | 506 + src/styles/_project-dashboard.sass | 323 + src/styles/_project-sharing.sass | 164 + src/styles/_project.sass | 2580 + src/styles/_search.sass | 784 + src/styles/_stats.sass | 2 + src/styles/_user.sass | 202 + src/styles/_utils.sass | 566 + src/styles/attract/_main.sass | 617 + src/styles/blog.sass | 650 + src/styles/main.sass | 27 + src/styles/plugins/_flowplayer.sass | 1046 + src/styles/plugins/_js_perfectscrollbar.sass | 147 + src/styles/plugins/_js_select2.sass | 449 + src/styles/plugins/_jstree.sass | 267 + src/styles/project-main.sass | 19 + src/styles/theatre.sass | 213 + src/styles/vrview.sass | 75 + src/templates/_macros/_add_new_menu.jade | 27 + src/templates/_macros/_file_uploader.jade | 18 + .../_macros/_file_uploader_form.jade | 70 + .../_macros/_file_uploader_javascript.jade | 133 + src/templates/_macros/_navigation.jade | 15 + src/templates/_macros/_node_edit_form.jade | 34 + src/templates/_modal.jade | 13 + src/templates/_notifications.jade | 4 + src/templates/errors/403.jade | 4 + src/templates/errors/403_embed.jade | 25 + src/templates/errors/404.jade | 17 + src/templates/errors/404_embed.jade | 12 + src/templates/errors/500.jade | 42 + src/templates/errors/layout.jade | 27 + src/templates/homepage.jade | 378 + src/templates/join.jade | 537 + src/templates/layout.jade | 436 + src/templates/nodes/custom/_comments.jade | 445 + src/templates/nodes/custom/_scripts.jade | 189 + .../nodes/custom/asset/file/view_embed.jade | 128 + .../nodes/custom/asset/image/view.jade | 4 + .../nodes/custom/asset/image/view_embed.jade | 128 + .../nodes/custom/asset/video/view.jade | 6 + .../nodes/custom/asset/video/view_embed.jade | 158 + .../custom/asset/view_theatre_embed.jade | 127 + src/templates/nodes/custom/blog/index.jade | 130 + .../nodes/custom/group/view_embed.jade | 254 + .../nodes/custom/group_hdri/view_embed.jade | 159 + .../custom/group_texture/view_embed.jade | 190 + .../nodes/custom/hdri/view_embed.jade | 133 + src/templates/nodes/custom/post/create.jade | 175 + src/templates/nodes/custom/post/edit.jade | 168 + src/templates/nodes/custom/post/view.jade | 4 + .../nodes/custom/post/view_embed.jade | 128 + .../nodes/custom/storage/index_embed.jade | 53 + .../nodes/custom/storage/view_embed.jade | 33 + .../nodes/custom/texture/view_embed.jade | 195 + src/templates/nodes/edit.jade | 11 + src/templates/nodes/edit_embed.jade | 347 + src/templates/nodes/search.jade | 301 + src/templates/projects/_scripts.jade | 22 + src/templates/projects/edit.jade | 250 + src/templates/projects/edit_node_type.jade | 88 + src/templates/projects/edit_node_types.jade | 110 + src/templates/projects/home_images.jade | 139 + src/templates/projects/home_index.jade | 43 + src/templates/projects/home_layout.jade | 79 + src/templates/projects/index_collection.jade | 87 + src/templates/projects/index_dashboard.jade | 232 + src/templates/projects/sharing.jade | 266 + src/templates/projects/view.jade | 586 + src/templates/projects/view_embed.jade | 188 + src/templates/projects/view_theatre.jade | 57 + src/templates/services.jade | 218 + src/templates/stats.jade | 120 + src/templates/upload.jade | 25 + src/templates/upload_embed.jade | 4 + src/templates/users/edit_embed.jade | 78 + src/templates/users/index.jade | 120 + src/templates/users/login.jade | 45 + src/templates/users/settings/_sidebar.jade | 20 + src/templates/users/settings/billing.jade | 55 + src/templates/users/settings/emails.jade | 27 + src/templates/users/settings/profile.jade | 45 + src/templates/users/tasks.jade | 170 + src/templates/vrview.jade | 16 + tests/{ => test_api}/test_auth.py | 95 +- .../test_bcloud_home_project.py | 113 +- .../test_blender_id_subclient.py | 13 +- tests/{ => test_api}/test_encoding.py | 8 +- tests/{ => test_api}/test_file_caching.py | 12 +- tests/{ => test_api}/test_file_storage.py | 19 +- tests/{ => test_api}/test_link_refresh.py | 6 +- tests/{ => test_api}/test_local_auth.py | 16 +- tests/{ => test_api}/test_nodes.py | 59 +- tests/{ => test_api}/test_patch.py | 38 +- .../{ => test_api}/test_project_management.py | 75 +- tests/{ => test_api}/test_service_badger.py | 8 +- tests/{ => test_api}/test_utils.py | 7 +- tests/test_sdk.py | 108 + 232 files changed, 79508 insertions(+), 2232 deletions(-) delete mode 100755 deploy.sh delete mode 100755 docker/build.sh delete mode 100644 docker/dev/Dockerfile delete mode 100644 docker/dev/runserver.sh delete mode 100644 docker/pro/000-default.conf delete mode 100644 docker/pro/Dockerfile create mode 100644 gulpfile.js rename {pillar => old-src}/manage.py (64%) mode change 100755 => 100644 create mode 100644 package.json create mode 100644 pillar/__init__.py create mode 100644 pillar/api/__init__.py rename pillar/{application/utils => api}/activities.py (87%) rename pillar/{application/modules => api}/blender_cloud/__init__.py (100%) rename pillar/{application/modules => api}/blender_cloud/home_project.py (92%) rename pillar/{application/modules => api}/blender_cloud/texture_libs.py (95%) rename pillar/{application/modules => api}/blender_id.py (94%) create mode 100644 pillar/api/custom_field_validation.py rename pillar/{application/modules => api}/encoding.py (94%) rename pillar/{settings.py => api/eve_settings.py} (97%) rename pillar/{application/modules => api}/file_storage.py (88%) rename pillar/{application/modules => api}/latest.py (79%) rename pillar/{application/modules => api}/local_auth.py (89%) rename pillar/{manage_extra => api}/node_types/__init__.py (100%) rename pillar/{manage_extra => api}/node_types/act.py (100%) rename pillar/{manage_extra => api}/node_types/asset.py (97%) rename pillar/{manage_extra => api}/node_types/blog.py (100%) rename pillar/{manage_extra => api}/node_types/comment.py (100%) rename pillar/{manage_extra => api}/node_types/group.py (100%) rename pillar/{manage_extra => api}/node_types/group_hdri.py (100%) rename pillar/{manage_extra => api}/node_types/group_texture.py (100%) rename pillar/{manage_extra => api}/node_types/hdri.py (97%) rename pillar/{manage_extra => api}/node_types/page.py (96%) rename pillar/{manage_extra => api}/node_types/post.py (96%) rename pillar/{manage_extra => api}/node_types/project.py (98%) rename pillar/{manage_extra => api}/node_types/scene.py (100%) rename pillar/{manage_extra => api}/node_types/shot.py (100%) rename pillar/{manage_extra => api}/node_types/storage.py (100%) rename pillar/{manage_extra => api}/node_types/task.py (100%) rename pillar/{manage_extra => api}/node_types/text.py (100%) rename pillar/{manage_extra => api}/node_types/texture.py (97%) rename pillar/{application/modules => api}/nodes/__init__.py (96%) rename pillar/{application/modules => api}/nodes/custom/__init__.py (100%) rename pillar/{application/modules => api}/nodes/custom/comment.py (98%) rename pillar/{application/modules => api}/nodes/patch.py (87%) create mode 100644 pillar/api/projects/__init__.py create mode 100644 pillar/api/projects/hooks.py create mode 100644 pillar/api/projects/routes.py create mode 100644 pillar/api/projects/utils.py rename pillar/{application/modules => api}/service.py (94%) create mode 100644 pillar/api/users/__init__.py rename pillar/{application/modules/users.py => api/users/hooks.py} (72%) create mode 100644 pillar/api/users/routes.py rename pillar/{application => api}/utils/__init__.py (92%) create mode 100644 pillar/api/utils/algolia.py rename pillar/{application => api}/utils/authentication.py (82%) rename pillar/{application => api}/utils/authorization.py (100%) rename pillar/{application => api}/utils/cdn.py (100%) rename pillar/{application => api}/utils/encoding.py (75%) rename pillar/{application => api}/utils/gcs.py (99%) rename pillar/{application => api}/utils/imaging.py (100%) rename pillar/{application => api}/utils/mongo.py (100%) rename pillar/{application => api}/utils/storage.py (98%) delete mode 100644 pillar/application/__init__.py delete mode 100644 pillar/application/modules/projects.py delete mode 100644 pillar/application/static/.webassets-cache/.gitignore delete mode 100644 pillar/application/utils/algolia.py create mode 100644 pillar/auth/__init__.py create mode 100644 pillar/cli.py create mode 100644 pillar/extension.py delete mode 100644 pillar/manage_extra/import_data.py delete mode 100644 pillar/runserver.wsgi create mode 100644 pillar/sdk.py rename tests/common_test_class.py => pillar/tests/__init__.py (82%) rename {tests => pillar/tests}/common_test_data.py (100%) rename {tests => pillar/tests}/config_testing.py (100%) rename tests/common_test_settings.py => pillar/tests/eve_test_settings.py (57%) create mode 100644 pillar/web/__init__.py create mode 100644 pillar/web/main/__init__.py create mode 100644 pillar/web/main/routes.py create mode 100644 pillar/web/nodes/__init__.py create mode 100644 pillar/web/nodes/custom/__init__.py create mode 100644 pillar/web/nodes/custom/comments.py create mode 100644 pillar/web/nodes/custom/groups.py create mode 100644 pillar/web/nodes/custom/posts.py create mode 100644 pillar/web/nodes/custom/storage.py create mode 100644 pillar/web/nodes/forms.py create mode 100644 pillar/web/nodes/routes.py create mode 100644 pillar/web/notifications/__init__.py create mode 100644 pillar/web/projects/__init__.py create mode 100644 pillar/web/projects/forms.py create mode 100644 pillar/web/projects/routes.py create mode 100644 pillar/web/redirects/__init__.py create mode 100644 pillar/web/static/assets/css/vendor/bootstrap.min.css create mode 100644 pillar/web/static/assets/font/config.json create mode 100644 pillar/web/static/assets/js/vendor/jquery.fileupload.min.js create mode 100644 pillar/web/static/assets/js/vendor/jquery.iframe-transport.min.js create mode 100644 pillar/web/static/assets/js/vendor/jquery.montage.min.js create mode 100644 pillar/web/static/assets/js/vendor/jquery.select2.min.js create mode 100644 pillar/web/static/assets/js/vendor/jquery.ui.widget.min.js create mode 100644 pillar/web/system_util.py create mode 100644 pillar/web/users/__init__.py create mode 100644 pillar/web/users/forms.py create mode 100644 pillar/web/users/routes.py create mode 100644 pillar/web/utils/__init__.py create mode 100644 pillar/web/utils/caching.py create mode 100644 pillar/web/utils/exceptions.py create mode 100644 pillar/web/utils/forms.py create mode 100644 pillar/web/utils/jstree.py create mode 100644 src/scripts/algolia_search.js create mode 100644 src/scripts/file_upload.js create mode 100644 src/scripts/markdown/01_markdown-converter.js create mode 100644 src/scripts/markdown/02_markdown-sanitizer.js create mode 100644 src/scripts/markdown/03_showdown.js create mode 100644 src/scripts/markdown/04_pagedown-extra.js create mode 100644 src/scripts/project-edit.js create mode 100644 src/scripts/tutti/0_navbar.js create mode 100644 src/scripts/tutti/1_project-navigation.js create mode 100644 src/scripts/tutti/2_comments.js create mode 100644 src/scripts/tutti/3_project-utils.js create mode 100644 src/scripts/tutti/4_search.js create mode 100644 src/scripts/tutti/5_notifications.js create mode 100644 src/scripts/vrview-analytics.js create mode 100644 src/styles/_base.sass create mode 100644 src/styles/_comments.sass create mode 100644 src/styles/_config.sass create mode 100644 src/styles/_error.sass create mode 100644 src/styles/_font-pillar.sass create mode 100644 src/styles/_homepage.sass create mode 100644 src/styles/_join.sass create mode 100644 src/styles/_normalize.scss create mode 100644 src/styles/_notifications.sass create mode 100644 src/styles/_pages.sass create mode 100644 src/styles/_project-dashboard.sass create mode 100644 src/styles/_project-sharing.sass create mode 100644 src/styles/_project.sass create mode 100644 src/styles/_search.sass create mode 100644 src/styles/_stats.sass create mode 100644 src/styles/_user.sass create mode 100644 src/styles/_utils.sass create mode 100644 src/styles/attract/_main.sass create mode 100644 src/styles/blog.sass create mode 100644 src/styles/main.sass create mode 100644 src/styles/plugins/_flowplayer.sass create mode 100644 src/styles/plugins/_js_perfectscrollbar.sass create mode 100644 src/styles/plugins/_js_select2.sass create mode 100644 src/styles/plugins/_jstree.sass create mode 100644 src/styles/project-main.sass create mode 100644 src/styles/theatre.sass create mode 100644 src/styles/vrview.sass create mode 100644 src/templates/_macros/_add_new_menu.jade create mode 100644 src/templates/_macros/_file_uploader.jade create mode 100644 src/templates/_macros/_file_uploader_form.jade create mode 100644 src/templates/_macros/_file_uploader_javascript.jade create mode 100644 src/templates/_macros/_navigation.jade create mode 100644 src/templates/_macros/_node_edit_form.jade create mode 100644 src/templates/_modal.jade create mode 100644 src/templates/_notifications.jade create mode 100644 src/templates/errors/403.jade create mode 100644 src/templates/errors/403_embed.jade create mode 100644 src/templates/errors/404.jade create mode 100644 src/templates/errors/404_embed.jade create mode 100644 src/templates/errors/500.jade create mode 100644 src/templates/errors/layout.jade create mode 100644 src/templates/homepage.jade create mode 100644 src/templates/join.jade create mode 100644 src/templates/layout.jade create mode 100644 src/templates/nodes/custom/_comments.jade create mode 100644 src/templates/nodes/custom/_scripts.jade create mode 100644 src/templates/nodes/custom/asset/file/view_embed.jade create mode 100644 src/templates/nodes/custom/asset/image/view.jade create mode 100644 src/templates/nodes/custom/asset/image/view_embed.jade create mode 100644 src/templates/nodes/custom/asset/video/view.jade create mode 100644 src/templates/nodes/custom/asset/video/view_embed.jade create mode 100644 src/templates/nodes/custom/asset/view_theatre_embed.jade create mode 100644 src/templates/nodes/custom/blog/index.jade create mode 100644 src/templates/nodes/custom/group/view_embed.jade create mode 100644 src/templates/nodes/custom/group_hdri/view_embed.jade create mode 100644 src/templates/nodes/custom/group_texture/view_embed.jade create mode 100644 src/templates/nodes/custom/hdri/view_embed.jade create mode 100644 src/templates/nodes/custom/post/create.jade create mode 100644 src/templates/nodes/custom/post/edit.jade create mode 100644 src/templates/nodes/custom/post/view.jade create mode 100644 src/templates/nodes/custom/post/view_embed.jade create mode 100644 src/templates/nodes/custom/storage/index_embed.jade create mode 100644 src/templates/nodes/custom/storage/view_embed.jade create mode 100644 src/templates/nodes/custom/texture/view_embed.jade create mode 100644 src/templates/nodes/edit.jade create mode 100644 src/templates/nodes/edit_embed.jade create mode 100644 src/templates/nodes/search.jade create mode 100644 src/templates/projects/_scripts.jade create mode 100644 src/templates/projects/edit.jade create mode 100644 src/templates/projects/edit_node_type.jade create mode 100644 src/templates/projects/edit_node_types.jade create mode 100644 src/templates/projects/home_images.jade create mode 100644 src/templates/projects/home_index.jade create mode 100644 src/templates/projects/home_layout.jade create mode 100644 src/templates/projects/index_collection.jade create mode 100644 src/templates/projects/index_dashboard.jade create mode 100644 src/templates/projects/sharing.jade create mode 100644 src/templates/projects/view.jade create mode 100644 src/templates/projects/view_embed.jade create mode 100644 src/templates/projects/view_theatre.jade create mode 100644 src/templates/services.jade create mode 100644 src/templates/stats.jade create mode 100644 src/templates/upload.jade create mode 100644 src/templates/upload_embed.jade create mode 100644 src/templates/users/edit_embed.jade create mode 100644 src/templates/users/index.jade create mode 100644 src/templates/users/login.jade create mode 100644 src/templates/users/settings/_sidebar.jade create mode 100644 src/templates/users/settings/billing.jade create mode 100644 src/templates/users/settings/emails.jade create mode 100644 src/templates/users/settings/profile.jade create mode 100644 src/templates/users/tasks.jade create mode 100644 src/templates/vrview.jade rename tests/{ => test_api}/test_auth.py (88%) rename tests/{ => test_api}/test_bcloud_home_project.py (85%) rename tests/{ => test_api}/test_blender_id_subclient.py (92%) rename tests/{ => test_api}/test_encoding.py (88%) rename tests/{ => test_api}/test_file_caching.py (85%) rename tests/{ => test_api}/test_file_storage.py (95%) rename tests/{ => test_api}/test_link_refresh.py (96%) rename tests/{ => test_api}/test_local_auth.py (84%) rename tests/{ => test_api}/test_nodes.py (88%) rename tests/{ => test_api}/test_patch.py (84%) rename tests/{ => test_api}/test_project_management.py (91%) rename tests/{ => test_api}/test_service_badger.py (95%) rename tests/{ => test_api}/test_utils.py (81%) create mode 100644 tests/test_sdk.py diff --git a/.gitignore b/.gitignore index eba6677a..0cf72cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,14 +6,24 @@ *.ropeproject* *.swp -/pillar/config_local.py +config_local.py .ropeproject/* -/pillar/application/static/storage/ /build /.cache -/pillar/pillar.egg-info/ -/pillar/google_app.json +/*.egg-info/ profile.stats /dump/ +/.eggs + +/node_modules +/.sass-cache +*.css.map +*.js.map + +pillar/web/static/assets/css/*.css +pillar/web/static/assets/js/*.min.js +pillar/web/static/storage/ +pillar/web/static/uploads/ +pillar/web/templates/ diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 426e6b59..00000000 --- a/deploy.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -e - -# Deploys the current production branch to the production machine. - -PROJECT_NAME="pillar" -DOCKER_NAME="pillar" -REMOTE_ROOT="/data/git/${PROJECT_NAME}" - -SSH="ssh -o ClearAllForwardings=yes cloud.blender.org" -ROOT="$(dirname "$(readlink -f "$0")")" -cd ${ROOT} - -# Check that we're on production branch. -if [ $(git rev-parse --abbrev-ref HEAD) != "production" ]; then - echo "You are NOT on the production branch, refusing to deploy." >&2 - exit 1 -fi - -# Check that production branch has been pushed. -if [ -n "$(git log origin/production..production --oneline)" ]; then - echo "WARNING: not all changes to the production branch have been pushed." - echo "Press [ENTER] to continue deploying current origin/production, CTRL+C to abort." - read dummy -fi - -# SSH to cloud to pull all files in -echo "===================================================================" -echo "UPDATING FILES ON ${PROJECT_NAME}" -${SSH} git -C ${REMOTE_ROOT} fetch origin production -${SSH} git -C ${REMOTE_ROOT} log origin/production..production --oneline -${SSH} git -C ${REMOTE_ROOT} merge --ff-only origin/production - -# Update the virtualenv -${SSH} -t docker exec ${DOCKER_NAME} /data/venv/bin/pip install -U -r ${REMOTE_ROOT}/requirements.txt --exists-action w - -# Notify Bugsnag of this new deploy. -echo -echo "===================================================================" -GIT_REVISION=$(${SSH} git -C ${REMOTE_ROOT} describe --always) -echo "Notifying Bugsnag of this new deploy of revision ${GIT_REVISION}." -BUGSNAG_API_KEY=$(${SSH} python -c "\"import sys; sys.path.append('${REMOTE_ROOT}/${PROJECT_NAME}'); import config_local; print(config_local.BUGSNAG_API_KEY)\"") -curl --data "apiKey=${BUGSNAG_API_KEY}&revision=${GIT_REVISION}" https://notify.bugsnag.com/deploy -echo - -# Wait for [ENTER] to restart the server -echo -echo "===================================================================" -echo "NOTE: If you want to edit config_local.py on the server, do so now." -echo "NOTE: Press [ENTER] to continue and restart the server process." -read dummy -${SSH} docker exec ${DOCKER_NAME} kill -HUP 1 -echo "Server process restarted" - -echo -echo "===================================================================" -echo "Deploy of ${PROJECT_NAME} is done." -echo "===================================================================" diff --git a/docker/build.sh b/docker/build.sh deleted file mode 100755 index 17e3a908..00000000 --- a/docker/build.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -echo $DIR - -if [[ $1 == 'pro' || $1 == 'dev' ]]; then - # Copy requirements.txt into pro folder - cp ../requirements.txt $1/requirements.txt - # Build image - docker build -t armadillica/pillar_$1 $1 - # Remove requirements.txt - rm $1/requirements.txt - -else - echo "POS. Your options are 'pro' or 'dev'" -fi diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile deleted file mode 100644 index 47731f50..00000000 --- a/docker/dev/Dockerfile +++ /dev/null @@ -1,48 +0,0 @@ -FROM ubuntu:14.04 -MAINTAINER Francesco Siddi - -RUN apt-get update && apt-get install -y \ -python \ -python-dev \ -python-pip \ -vim \ -nano \ -zlib1g-dev \ -libjpeg-dev \ -python-crypto \ -python-openssl \ -libssl-dev \ -libffi-dev \ -software-properties-common \ -git - -RUN add-apt-repository ppa:mc3man/trusty-media \ -&& apt-get update && apt-get install -y \ -ffmpeg - -RUN mkdir -p /data/git/pillar \ -&& mkdir -p /data/storage/shared \ -&& mkdir -p /data/storage/pillar \ -&& mkdir -p /data/config \ -&& mkdir -p /data/storage/logs - -RUN pip install virtualenv \ -&& virtualenv /data/venv - -ENV PIP_PACKAGES_VERSION = 2 -ADD requirements.txt /requirements.txt - -RUN . /data/venv/bin/activate && pip install -r /requirements.txt - -VOLUME /data/git/pillar -VOLUME /data/config -VOLUME /data/storage/shared -VOLUME /data/storage/pillar - -ENV MONGO_HOST mongo_pillar - -EXPOSE 5000 - -ADD runserver.sh /runserver.sh - -ENTRYPOINT ["bash", "/runserver.sh"] diff --git a/docker/dev/runserver.sh b/docker/dev/runserver.sh deleted file mode 100644 index 313a8bfe..00000000 --- a/docker/dev/runserver.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -. /data/venv/bin/activate && python /data/git/pillar/pillar/manage.py runserver diff --git a/docker/pro/000-default.conf b/docker/pro/000-default.conf deleted file mode 100644 index 6648bdd2..00000000 --- a/docker/pro/000-default.conf +++ /dev/null @@ -1,47 +0,0 @@ - - # The ServerName directive sets the request scheme, hostname and port that - # the server uses to identify itself. This is used when creating - # redirection URLs. In the context of virtual hosts, the ServerName - # specifies what hostname must appear in the request's Host: header to - # match this virtual host. For the default virtual host (this file) this - # value is not decisive as it is used as a last resort host regardless. - # However, you must set it for any further virtual host explicitly. - #ServerName 127.0.0.1 - - # EnableSendfile on - XSendFile on - XSendFilePath /data/storage/pillar - - ServerAdmin webmaster@localhost - DocumentRoot /var/www/html - - # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, - # error, crit, alert, emerg. - # It is also possible to configure the loglevel for particular - # modules, e.g. - #LogLevel info ssl:warn - - ErrorLog ${APACHE_LOG_DIR}/error.log - CustomLog ${APACHE_LOG_DIR}/access.log combined - - # For most configuration files from conf-available/, which are - # enabled or disabled at a global level, it is possible to - # include a line for only one particular virtual host. For example the - # following line enables the CGI configuration for this host only - # after it has been globally disabled with "a2disconf". - #Include conf-available/serve-cgi-bin.conf - - WSGIDaemonProcess pillar - WSGIPassAuthorization On - - WSGIScriptAlias / /data/git/pillar/pillar/runserver.wsgi \ - process-group=pillar application-group=%{GLOBAL} - - - - Require all granted - - - - -# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/docker/pro/Dockerfile b/docker/pro/Dockerfile deleted file mode 100644 index 4ccd9bf9..00000000 --- a/docker/pro/Dockerfile +++ /dev/null @@ -1,61 +0,0 @@ -FROM ubuntu:14.04 -MAINTAINER Francesco Siddi - -RUN apt-get update && apt-get install -y \ -python \ -python-dev \ -python-pip \ -vim \ -nano \ -zlib1g-dev \ -libjpeg-dev \ -python-crypto \ -python-openssl \ -libssl-dev \ -libffi-dev \ -software-properties-common \ -apache2-mpm-event \ -libapache2-mod-wsgi \ -libapache2-mod-xsendfile \ -git - -RUN add-apt-repository ppa:mc3man/trusty-media \ -&& apt-get update && apt-get install -y \ -ffmpeg - -RUN mkdir -p /data/git/pillar \ -&& mkdir -p /data/storage/shared \ -&& mkdir -p /data/storage/pillar \ -&& mkdir -p /data/config \ -&& mkdir -p /data/storage/logs - -ENV APACHE_RUN_USER www-data -ENV APACHE_RUN_GROUP www-data -ENV APACHE_LOG_DIR /var/log/apache2 -ENV APACHE_PID_FILE /var/run/apache2.pid -ENV APACHE_RUN_DIR /var/run/apache2 -ENV APACHE_LOCK_DIR /var/lock/apache2 - -RUN mkdir -p $APACHE_RUN_DIR $APACHE_LOCK_DIR $APACHE_LOG_DIR - -RUN pip install virtualenv \ -&& virtualenv /data/venv - -ENV PIP_PACKAGES_VERSION = 2 -ADD requirements.txt /requirements.txt - -RUN . /data/venv/bin/activate \ -&& pip install -r /requirements.txt - -VOLUME /data/git/pillar -VOLUME /data/config -VOLUME /data/storage/shared -VOLUME /data/storage/pillar - -ENV MONGO_HOST mongo_pillar - -EXPOSE 80 - -ADD 000-default.conf /etc/apache2/sites-available/000-default.conf - -CMD ["/usr/sbin/apache2", "-D", "FOREGROUND"] diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..839e11d7 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,104 @@ +var argv = require('minimist')(process.argv.slice(2)); +var autoprefixer = require('gulp-autoprefixer'); +var chmod = require('gulp-chmod'); +var concat = require('gulp-concat'); +var gulp = require('gulp'); +var gulpif = require('gulp-if'); +var jade = require('gulp-jade'); +var livereload = require('gulp-livereload'); +var plumber = require('gulp-plumber'); +var rename = require('gulp-rename'); +var sass = require('gulp-sass'); +var sourcemaps = require('gulp-sourcemaps'); +var uglify = require('gulp-uglify'); + +var enabled = { + uglify: argv.production, + maps: argv.production, + failCheck: argv.production, + prettyPug: !argv.production, + liveReload: !argv.production +}; + +/* CSS */ +gulp.task('styles', function() { + gulp.src('src/styles/**/*.sass') + .pipe(gulpif(enabled.failCheck, plumber())) + .pipe(gulpif(enabled.maps, sourcemaps.init())) + .pipe(sass({ + outputStyle: 'compressed'} + )) + .pipe(autoprefixer("last 3 versions")) + .pipe(gulpif(enabled.maps, sourcemaps.write("."))) + .pipe(gulp.dest('pillar/web/static/assets/css')) + .pipe(gulpif(enabled.liveReload, livereload())); +}); + + +/* Templates - Jade */ +gulp.task('templates', function() { + gulp.src('src/templates/**/*.jade') + .pipe(gulpif(enabled.failCheck, plumber())) + .pipe(jade({ + pretty: enabled.prettyPug + })) + .pipe(gulp.dest('pillar/web/templates/')) + .pipe(gulpif(enabled.liveReload, livereload())); +}); + + +/* Individual Uglified Scripts */ +gulp.task('scripts', function() { + gulp.src('src/scripts/*.js') + .pipe(gulpif(enabled.failCheck, plumber())) + .pipe(gulpif(enabled.maps, sourcemaps.init())) + .pipe(gulpif(enabled.uglify, uglify())) + .pipe(rename({suffix: '.min'})) + .pipe(gulpif(enabled.maps, sourcemaps.write("."))) + .pipe(chmod(644)) + .pipe(gulp.dest('pillar/web/static/assets/js/')) + .pipe(gulpif(enabled.liveReload, livereload())); +}); + + +/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */ +/* Since it's always loaded, it's only for functions that we want site-wide */ +gulp.task('scripts_concat_tutti', function() { + gulp.src('src/scripts/tutti/**/*.js') + .pipe(gulpif(enabled.failCheck, plumber())) + .pipe(gulpif(enabled.maps, sourcemaps.init())) + .pipe(concat("tutti.min.js")) + .pipe(gulpif(enabled.uglify, uglify())) + .pipe(gulpif(enabled.maps, sourcemaps.write("."))) + .pipe(chmod(644)) + .pipe(gulp.dest('pillar/web/static/assets/js/')) + .pipe(gulpif(enabled.liveReload, livereload())); +}); + +gulp.task('scripts_concat_markdown', function() { + gulp.src('src/scripts/markdown/**/*.js') + .pipe(gulpif(enabled.failCheck, plumber())) + .pipe(gulpif(enabled.maps, sourcemaps.init())) + .pipe(concat("markdown.min.js")) + .pipe(gulpif(enabled.uglify, uglify())) + .pipe(gulpif(enabled.maps, sourcemaps.write("."))) + .pipe(chmod(644)) + .pipe(gulp.dest('pillar/web/static/assets/js/')) + .pipe(gulpif(enabled.liveReload, livereload())); +}); + + +// While developing, run 'gulp watch' +gulp.task('watch',function() { + livereload.listen(); + + gulp.watch('src/styles/**/*.sass',['styles']); + gulp.watch('src/templates/**/*.jade',['templates']); + gulp.watch('src/scripts/*.js',['scripts']); + gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']); + gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']); +}); + + +// Run 'gulp' to build everything at once +gulp.task('default', ['styles', 'templates', 'scripts', 'scripts_concat_tutti', 'scripts_concat_markdown']); diff --git a/pillar/manage.py b/old-src/manage.py old mode 100755 new mode 100644 similarity index 64% rename from pillar/manage.py rename to old-src/manage.py index a4bfb2dd..e2117c9f --- a/pillar/manage.py +++ b/old-src/manage.py @@ -1,36 +1,33 @@ #!/usr/bin/env python -from __future__ import print_function from __future__ import division +from __future__ import print_function import copy -import os import logging -from bson.objectid import ObjectId, InvalidId -from eve.methods.put import put_internal +import os +from bson.objectid import ObjectId from eve.methods.post import post_internal +from eve.methods.put import put_internal from flask.ext.script import Manager # Use a sensible default when running manage.py commands. if not os.environ.get('EVE_SETTINGS'): settings_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'settings.py') + 'pillar', 'eve_settings.py') os.environ['EVE_SETTINGS'] = settings_path -from application import app -from application.utils.gcs import GoogleCloudStorageBucket -from manage_extra.node_types.asset import node_type_asset -from manage_extra.node_types.blog import node_type_blog -from manage_extra.node_types.comment import node_type_comment -from manage_extra.node_types.group import node_type_group -from manage_extra.node_types.post import node_type_post -from manage_extra.node_types.project import node_type_project -from manage_extra.node_types.storage import node_type_storage -from manage_extra.node_types.texture import node_type_texture -from manage_extra.node_types.group_texture import node_type_group_texture +# from pillar import app +from pillar.api.node_types.asset import node_type_asset +from pillar.api.node_types import node_type_blog +from pillar.api.node_types.comment import node_type_comment +from pillar.api.node_types.group import node_type_group +from pillar.api.node_types.post import node_type_post +from pillar.api.node_types import node_type_storage +from pillar.api.node_types.texture import node_type_texture -manager = Manager(app) +manager = Manager() log = logging.getLogger('manage') log.setLevel(logging.INFO) @@ -132,7 +129,7 @@ def setup_db(admin_email): # Create a default project by faking a POST request. with app.test_request_context(data={'project_name': u'Default Project'}): from flask import g - from application.modules import projects + from pillar.api import projects g.current_user = {'user_id': user['_id'], 'groups': user['groups'], @@ -141,29 +138,6 @@ def setup_db(admin_email): projects.create_project(overrides={'url': 'default-project', 'is_private': False}) - -@manager.command -def setup_db_indices(): - """Adds missing database indices.""" - - from application import setup_db_indices - - import pymongo - - log.info('Adding missing database indices.') - log.warning('This does NOT drop and recreate existing indices, ' - 'nor does it reconfigure existing indices. ' - 'If you want that, drop them manually first.') - - setup_db_indices() - - coll_names = db.collection_names(include_system_collections=False) - for coll_name in sorted(coll_names): - stats = db.command('collStats', coll_name) - log.info('Collection %25s takes up %.3f MiB index space', - coll_name, stats['totalIndexSize'] / 2 ** 20) - - def _default_permissions(): """Returns a dict of default permissions. @@ -172,7 +146,7 @@ def _default_permissions(): :rtype: dict """ - from application.modules.projects import DEFAULT_ADMIN_GROUP_PERMISSIONS + from pillar.api.projects import DEFAULT_ADMIN_GROUP_PERMISSIONS groups_collection = app.data.driver.db['groups'] admin_group = groups_collection.find_one({'name': 'admin'}) @@ -200,9 +174,9 @@ def setup_for_attract(project_uuid, replace=False): :type replace: bool """ - from manage_extra.node_types.act import node_type_act - from manage_extra.node_types.scene import node_type_scene - from manage_extra.node_types.shot import node_type_shot + from pillar.api.node_types import node_type_act + from pillar.api.node_types.scene import node_type_scene + from pillar.api.node_types import node_type_shot # Copy permissions from the project, then give everyone with PUT # access also DELETE access. @@ -274,7 +248,7 @@ def _update_project(project_uuid, project): :rtype: dict """ - from application.utils import remove_private_keys + from pillar.api.utils import remove_private_keys project_id = ObjectId(project_uuid) project = remove_private_keys(project) @@ -289,7 +263,7 @@ def _update_project(project_uuid, project): def refresh_project_permissions(): """Replaces the admin group permissions of each project with the defaults.""" - from application.modules.projects import DEFAULT_ADMIN_GROUP_PERMISSIONS + from pillar.api.projects import DEFAULT_ADMIN_GROUP_PERMISSIONS proj_coll = app.data.driver.db['projects'] result = proj_coll.update_many({}, {'$set': { @@ -306,8 +280,8 @@ def refresh_home_project_permissions(): proj_coll = app.data.driver.db['projects'] - from application.modules.blender_cloud import home_project - from application.modules import service + from pillar.api.blender_cloud import home_project + from pillar.api import service service.fetch_role_to_group_id_map() @@ -398,7 +372,7 @@ def set_attachment_names(): """Loop through all existing nodes and assign proper ContentDisposition metadata to referenced files that are using GCS. """ - from application.utils.gcs import update_file_name + from pillar.api.utils.gcs import update_file_name nodes_collection = app.data.driver.db['nodes'] for n in nodes_collection.find(): print("Updating node {0}".format(n['_id'])) @@ -496,7 +470,7 @@ def test_post_internal(node_id): @manager.command def algolia_push_users(): """Loop through all users and push them to Algolia""" - from application.utils.algolia import algolia_index_user_save + from pillar.api.utils.algolia import algolia_index_user_save users_collection = app.data.driver.db['users'] for user in users_collection.find(): print("Pushing {0}".format(user['username'])) @@ -506,7 +480,7 @@ def algolia_push_users(): @manager.command def algolia_push_nodes(): """Loop through all nodes and push them to Algolia""" - from application.utils.algolia import algolia_index_node_save + from pillar.api.utils.algolia import algolia_index_node_save nodes_collection = app.data.driver.db['nodes'] for node in nodes_collection.find(): print(u"Pushing {0}: {1}".format(node['_id'], node['name'].encode( @@ -520,7 +494,7 @@ def files_make_public_t(): public """ from gcloud.exceptions import InternalServerError - from application.utils.gcs import GoogleCloudStorageBucket + from pillar.api.utils.gcs import GoogleCloudStorageBucket files_collection = app.data.driver.db['files'] for f in files_collection.find({'backend': 'gcs'}): @@ -550,7 +524,7 @@ def subscribe_node_owners(): """Automatically subscribe node owners to notifications for items created in the past. """ - from application.modules.nodes import after_inserting_nodes + from pillar.api.nodes import after_inserting_nodes nodes_collection = app.data.driver.db['nodes'] for n in nodes_collection.find(): if 'parent' in n: @@ -563,65 +537,19 @@ def refresh_project_links(project, chunk_size=50, quiet=False): if quiet: import logging - from application import log + from pillar import log logging.getLogger().setLevel(logging.WARNING) log.setLevel(logging.WARNING) chunk_size = int(chunk_size) # CLI parameters are passed as strings - from application.modules import file_storage + from pillar.api import file_storage file_storage.refresh_links_for_project(project, chunk_size, 2 * 3600) -@manager.command -@manager.option('-c', '--chunk', dest='chunk_size', default=50) -@manager.option('-q', '--quiet', dest='quiet', action='store_true', default=False) -@manager.option('-w', '--window', dest='window', default=12) -def refresh_backend_links(backend_name, chunk_size=50, quiet=False, window=12): - """Refreshes all file links that are using a certain storage backend.""" - - chunk_size = int(chunk_size) - window = int(window) - - if quiet: - import logging - from application import log - - logging.getLogger().setLevel(logging.WARNING) - log.setLevel(logging.WARNING) - - chunk_size = int(chunk_size) # CLI parameters are passed as strings - from application.modules import file_storage - - file_storage.refresh_links_for_backend(backend_name, chunk_size, window * 3600) - - -@manager.command -def expire_all_project_links(project_uuid): - """Expires all file links for a certain project without refreshing. - - This is just for testing. - """ - - import datetime - import bson.tz_util - - files_collection = app.data.driver.db['files'] - - now = datetime.datetime.now(tz=bson.tz_util.utc) - expires = now - datetime.timedelta(days=1) - - result = files_collection.update_many( - {'project': ObjectId(project_uuid)}, - {'$set': {'link_expires': expires}} - ) - - print('Expired %i links' % result.matched_count) - - @manager.command def register_local_user(email, password): - from application.modules.local_auth import create_local_user + from pillar.api.local_auth import create_local_user create_local_user(email, password) @@ -687,7 +615,7 @@ def add_license_props(): def refresh_file_sizes(): """Computes & stores the 'length_aggregate_in_bytes' fields of all files.""" - from application.modules import file_storage + from pillar.api import file_storage matched = 0 unmatched = 0 @@ -721,7 +649,7 @@ def project_stats(): from collections import defaultdict from functools import partial - from application.modules import projects + from pillar.api import projects proj_coll = app.data.driver.db['projects'] nodes = app.data.driver.db['nodes'] @@ -794,9 +722,9 @@ def project_stats(): @manager.command def add_node_types(): """Add texture and group_texture node types to all projects""" - from manage_extra.node_types.texture import node_type_texture - from manage_extra.node_types.group_texture import node_type_group_texture - from application.utils import project_get_node_type + from pillar.api.node_types.texture import node_type_texture + from pillar.api.node_types.group_texture import node_type_group_texture + from pillar.api.utils import project_get_node_type projects_collections = app.data.driver.db['projects'] for project in projects_collections.find(): print("Processing {}".format(project['_id'])) @@ -851,291 +779,5 @@ def update_texture_nodes_maps(): print("Skipping {}".format(v['map_type'])) nodes_collection.update({'_id': node['_id']}, node) - -def _create_service_account(email, service_roles, service_definition): - from application.modules import service - from application.utils import dumps - - account, token = service.create_service_account( - email, - service_roles, - service_definition - ) - - print('Account created:') - print(dumps(account, indent=4, sort_keys=True)) - print() - print('Access token: %s' % token['token']) - print(' expires on: %s' % token['expire_time']) - - -@manager.command -def create_badger_account(email, badges): - """ - Creates a new service account that can give badges (i.e. roles). - - :param email: email address associated with the account - :param badges: single space-separated argument containing the roles - this account can assign and revoke. - """ - - _create_service_account(email, [u'badger'], {'badger': badges.strip().split()}) - - -@manager.command -def create_urler_account(email): - """Creates a new service account that can fetch all project URLs.""" - - _create_service_account(email, [u'urler'], {}) - - -@manager.command -def find_duplicate_users(): - """Finds users that have the same BlenderID user_id.""" - - from collections import defaultdict - - users_coll = app.data.driver.db['users'] - nodes_coll = app.data.driver.db['nodes'] - projects_coll = app.data.driver.db['projects'] - - found_users = defaultdict(list) - - for user in users_coll.find(): - blender_ids = [auth['user_id'] for auth in user['auth'] - if auth['provider'] == 'blender-id'] - if not blender_ids: - continue - blender_id = blender_ids[0] - found_users[blender_id].append(user) - - for blender_id, users in found_users.iteritems(): - if len(users) == 1: - continue - - usernames = ', '.join(user['username'] for user in users) - print('Blender ID: %5s has %i users: %s' % ( - blender_id, len(users), usernames)) - - for user in users: - print(' %s owns %i nodes and %i projects' % ( - user['username'], - nodes_coll.count({'user': user['_id']}), - projects_coll.count({'user': user['_id']}), - )) - - -@manager.command -def sync_role_groups(do_revoke_groups): - """For each user, synchronizes roles and group membership. - - This ensures that everybody with the 'subscriber' role is also member of the 'subscriber' - group, and people without the 'subscriber' role are not member of that group. Same for - admin and demo groups. - - When do_revoke_groups=False (the default), people are only added to groups. - when do_revoke_groups=True, people are also removed from groups. - """ - - from application.modules import service - - if do_revoke_groups not in {'true', 'false'}: - print('Use either "true" or "false" as first argument.') - print('When passing "false", people are only added to groups.') - print('when passing "true", people are also removed from groups.') - raise SystemExit() - do_revoke_groups = do_revoke_groups == 'true' - - service.fetch_role_to_group_id_map() - - users_coll = app.data.driver.db['users'] - groups_coll = app.data.driver.db['groups'] - - group_names = {} - - def gname(gid): - try: - return group_names[gid] - except KeyError: - name = groups_coll.find_one(gid, projection={'name': 1})['name'] - name = str(name) - group_names[gid] = name - return name - - ok_users = bad_users = 0 - for user in users_coll.find(): - grant_groups = set() - revoke_groups = set() - current_groups = set(user.get('groups', [])) - user_roles = user.get('roles', set()) - - for role in service.ROLES_WITH_GROUPS: - action = 'grant' if role in user_roles else 'revoke' - groups = service.manage_user_group_membership(user, role, action) - - if groups is None: - # No changes required - continue - - if groups == current_groups: - continue - - grant_groups.update(groups.difference(current_groups)) - revoke_groups.update(current_groups.difference(groups)) - - if grant_groups or revoke_groups: - bad_users += 1 - - expected_groups = current_groups.union(grant_groups).difference(revoke_groups) - - print('Discrepancy for user %s/%s:' % (user['_id'], user['full_name'].encode('utf8'))) - print(' - actual groups :', sorted(gname(gid) for gid in user.get('groups'))) - print(' - expected groups:', sorted(gname(gid) for gid in expected_groups)) - print(' - will grant :', sorted(gname(gid) for gid in grant_groups)) - - if do_revoke_groups: - label = 'WILL REVOKE ' - else: - label = 'could revoke' - print(' - %s :' % label, sorted(gname(gid) for gid in revoke_groups)) - - if grant_groups and revoke_groups: - print(' ------ CAREFUL this one has BOTH grant AND revoke -----') - - # Determine which changes we'll apply - final_groups = current_groups.union(grant_groups) - if do_revoke_groups: - final_groups.difference_update(revoke_groups) - print(' - final groups :', sorted(gname(gid) for gid in final_groups)) - - # Perform the actual update - users_coll.update_one({'_id': user['_id']}, - {'$set': {'groups': list(final_groups)}}) - else: - ok_users += 1 - - print('%i bad and %i ok users seen.' % (bad_users, ok_users)) - - -@manager.command -def sync_project_groups(user_email, fix): - """Gives the user access to their self-created projects.""" - - if fix.lower() not in {'true', 'false'}: - print('Use either "true" or "false" as second argument.') - print('When passing "false", only a report is produced.') - print('when passing "true", group membership is fixed.') - raise SystemExit() - fix = fix.lower() == 'true' - - users_coll = app.data.driver.db['users'] - proj_coll = app.data.driver.db['projects'] - groups_coll = app.data.driver.db['groups'] - - # Find by email or by user ID - if '@' in user_email: - where = {'email': user_email} - else: - try: - where = {'_id': ObjectId(user_email)} - except InvalidId: - log.warning('Invalid ObjectID: %s', user_email) - return - - user = users_coll.find_one(where, projection={'_id': 1, 'groups': 1}) - if user is None: - log.error('User %s not found', where) - raise SystemExit() - - user_groups = set(user['groups']) - user_id = user['_id'] - log.info('Updating projects for user %s', user_id) - - ok_groups = missing_groups = 0 - for proj in proj_coll.find({'user': user_id}): - project_id = proj['_id'] - log.info('Investigating project %s (%s)', project_id, proj['name']) - - # Find the admin group - admin_group = groups_coll.find_one({'name': str(project_id)}, projection={'_id': 1}) - if admin_group is None: - log.warning('No admin group for project %s', project_id) - continue - group_id = admin_group['_id'] - - # Check membership - if group_id not in user_groups: - log.info('Missing group membership') - missing_groups += 1 - user_groups.add(group_id) - else: - ok_groups += 1 - - log.info('User %s was missing %i group memberships; %i projects were ok.', - user_id, missing_groups, ok_groups) - - if missing_groups > 0 and fix: - log.info('Updating database.') - result = users_coll.update_one({'_id': user_id}, - {'$set': {'groups': list(user_groups)}}) - log.info('Updated %i user.', result.modified_count) - - -@manager.command -def badger(action, user_email, role): - from application.modules import service - - with app.app_context(): - service.fetch_role_to_group_id_map() - response, status = service.do_badger(action, user_email, role) - - if status == 204: - log.info('Done.') - else: - log.info('Response: %s', response) - log.info('Status : %i', status) - - -@manager.command -def hdri_sort(project_url): - """Sorts HDRi images by image resolution.""" - - proj_coll = app.data.driver.db['projects'] - nodes_coll = app.data.driver.db['nodes'] - files_coll = app.data.driver.db['files'] - - proj = proj_coll.find_one({'url': project_url}) - if not proj: - log.warning('Project url=%r not found.' % project_url) - return - - proj_id = proj['_id'] - log.info('Processing project %r', proj_id) - - nodes = nodes_coll.find({'project': proj_id, 'node_type': 'hdri'}) - if nodes.count() == 0: - log.warning('Project has no hdri nodes') - return - - for node in nodes: - log.info('Processing node %s', node['name']) - - def sort_key(file_ref): - file_doc = files_coll.find_one(file_ref['file'], projection={'length': 1}) - return file_doc['length'] - - files = sorted(node['properties']['files'], key=sort_key) - - log.info('Files pre-sort: %s', - [file['resolution'] for file in node['properties']['files']]) - log.info('Files post-sort: %s', - [file['resolution'] for file in files]) - - result = nodes_coll.update_one({'_id': node['_id']}, - {'$set': {'properties.files': files}}) - if result.matched_count != 1: - log.warning('Matched count = %i, expected 1, aborting', result.matched_count) - return - if __name__ == '__main__': manager.run() diff --git a/package.json b/package.json new file mode 100644 index 00000000..e9756016 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "pillar", + "repository": { + "type": "git", + "url": "https://github.com/armadillica/pillar.git" + }, + "author": "Blender Institute", + "license": "GPL", + "devDependencies": { + "gulp": "~3.9.1", + "gulp-sass": "~2.3.1", + "gulp-autoprefixer": "~2.3.1", + "gulp-if": "^2.0.1", + "gulp-jade": "~1.1.0", + "gulp-sourcemaps": "~1.6.0", + "gulp-plumber": "~1.1.0", + "gulp-livereload": "~3.8.1", + "gulp-concat": "~2.6.0", + "gulp-uglify": "~1.5.3", + "gulp-rename": "~1.2.2", + "gulp-chmod": "~1.3.0", + "minimist": "^1.2.0" + } +} diff --git a/pillar/__init__.py b/pillar/__init__.py new file mode 100644 index 00000000..ebced276 --- /dev/null +++ b/pillar/__init__.py @@ -0,0 +1,374 @@ +"""Pillar server.""" + +import copy +import logging +import logging.config +import subprocess +import tempfile + +import jinja2 +import os +import os.path +from eve import Eve + +from pillar.api import custom_field_validation +from pillar.api.utils import authentication +from pillar.api.utils import gravatar +from pillar.web.utils import pretty_date +from pillar.web.nodes.routes import url_for_node + +from . import api +from . import web +from . import auth + +empty_settings = { + # Use a random URL prefix when booting Eve, to ensure that any + # Flask route that's registered *before* we load our own config + # won't interfere with Pillar itself. + 'URL_PREFIX': 'pieQui4vah9euwieFai6naivaV4thahchoochiiwazieBe5o', + 'DOMAIN': {}, +} + + +class PillarServer(Eve): + def __init__(self, app_root, **kwargs): + kwargs.setdefault('validator', custom_field_validation.ValidateCustomFields) + super(PillarServer, self).__init__(settings=empty_settings, **kwargs) + + self.app_root = os.path.abspath(app_root) + self._load_flask_config() + self._config_logging() + + self.log = logging.getLogger('%s.%s' % (__name__, self.__class__.__name__)) + self.log.info('Creating new instance from %r', self.app_root) + + self._config_tempdirs() + self._config_git() + self._config_bugsnag() + self._config_google_cloud_storage() + + self.algolia_index_users = None + self.algolia_index_nodes = None + self.algolia_client = None + self._config_algolia() + + self.encoding_service_client = None + self._config_encoding_backend() + + try: + self.settings = os.environ['EVE_SETTINGS'] + except KeyError: + self.settings = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'api', 'eve_settings.py') + # self.settings = self.config['EVE_SETTINGS_PATH'] + self.load_config() + + # Configure authentication + self._login_manager = auth.config_login_manager(self) + self.oauth_blender_id = auth.config_oauth_login(self) + + self._config_caching() + + self.before_first_request(self.setup_db_indices) + + def _load_flask_config(self): + # Load configuration from different sources, to make it easy to override + # settings with secrets, as well as for development & testing. + self.config.from_pyfile(os.path.join(os.path.dirname(__file__), 'config.py'), silent=False) + self.config.from_pyfile(os.path.join(self.app_root, 'config.py'), silent=True) + self.config.from_pyfile(os.path.join(self.app_root, 'config_local.py'), silent=True) + from_envvar = os.environ.get('PILLAR_CONFIG') + if from_envvar: + # Don't use from_envvar, as we want different behaviour. If the envvar + # is not set, it's fine (i.e. silent=True), but if it is set and the + # configfile doesn't exist, it should error out (i.e. silent=False). + self.config.from_pyfile(from_envvar, silent=False) + + def _config_logging(self): + # Configure logging + logging.config.dictConfig(self.config['LOGGING']) + log = logging.getLogger(__name__) + if self.config['DEBUG']: + log.info('Pillar starting, debug=%s', self.config['DEBUG']) + + def _config_tempdirs(self): + storage_dir = self.config['STORAGE_DIR'] + if not os.path.exists(storage_dir): + self.log.info('Creating storage directory %r', storage_dir) + os.makedirs(storage_dir) + + # Set the TMP environment variable to manage where uploads are stored. + # These are all used by tempfile.mkstemp(), but we don't knwow in whic + # order. As such, we remove all used variables but the one we set. + tempfile.tempdir = storage_dir + os.environ['TMP'] = storage_dir + os.environ.pop('TEMP', None) + os.environ.pop('TMPDIR', None) + + def _config_git(self): + # Get the Git hash + try: + git_cmd = ['git', '-C', self.app_root, 'describe', '--always'] + description = subprocess.check_output(git_cmd) + self.config['GIT_REVISION'] = description.strip() + except (subprocess.CalledProcessError, OSError) as ex: + self.log.warning('Unable to run "git describe" to get git revision: %s', ex) + self.config['GIT_REVISION'] = 'unknown' + self.log.info('Git revision %r', self.config['GIT_REVISION']) + + def _config_bugsnag(self): + # Configure Bugsnag + if self.config.get('TESTING') or not self.config.get('BUGSNAG_API_KEY'): + self.log.info('Bugsnag NOT configured.') + return + + import bugsnag + from bugsnag.flask import handle_exceptions + from bugsnag.handlers import BugsnagHandler + + bugsnag.configure( + api_key=self.config['BUGSNAG_API_KEY'], + project_root="/data/git/pillar/pillar", + ) + handle_exceptions(self) + + bs_handler = BugsnagHandler() + bs_handler.setLevel(logging.ERROR) + self.log.addHandler(bs_handler) + + def _config_google_cloud_storage(self): + # Google Cloud project + try: + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = \ + self.config['GCLOUD_APP_CREDENTIALS'] + except KeyError: + raise SystemExit('GCLOUD_APP_CREDENTIALS configuration is missing') + + # Storage backend (GCS) + try: + os.environ['GCLOUD_PROJECT'] = self.config['GCLOUD_PROJECT'] + except KeyError: + raise SystemExit('GCLOUD_PROJECT configuration value is missing') + + def _config_algolia(self): + # Algolia search + if self.config['SEARCH_BACKEND'] != 'algolia': + return + + from algoliasearch import algoliasearch + + client = algoliasearch.Client(self.config['ALGOLIA_USER'], + self.config['ALGOLIA_API_KEY']) + self.algolia_client = client + self.algolia_index_users = client.init_index(self.config['ALGOLIA_INDEX_USERS']) + self.algolia_index_nodes = client.init_index(self.config['ALGOLIA_INDEX_NODES']) + + def _config_encoding_backend(self): + # Encoding backend + if self.config['ENCODING_BACKEND'] != 'zencoder': + return + + from zencoder import Zencoder + self.encoding_service_client = Zencoder(self.config['ZENCODER_API_KEY']) + + def _config_caching(self): + from flask_cache import Cache + self.cache = Cache(self) + + def load_extension(self, pillar_extension, url_prefix): + from .extension import PillarExtension + + self.log.info('Initialising extension %r', pillar_extension) + assert isinstance(pillar_extension, PillarExtension) + + # Load extension Flask configuration + for key, value in pillar_extension.flask_config(): + self.config.setdefault(key, value) + + # Load extension blueprint(s) + for blueprint in pillar_extension.blueprints(): + self.register_blueprint(blueprint, url_prefix=url_prefix) + + # Load extension Eve settings + eve_settings = pillar_extension.eve_settings() + + for key, collection in eve_settings['DOMAIN'].items(): + source = '%s.%s' % (pillar_extension.name, key) + url = '%s/%s' % (pillar_extension.name, key) + + collection.setdefault('datasource', {}).setdefault('source', source) + collection.setdefault('url', url) + + self.config['DOMAIN'].update(eve_settings['DOMAIN']) + + def _config_jinja_env(self): + pillar_dir = os.path.dirname(os.path.realpath(__file__)) + parent_theme_path = os.path.join(pillar_dir, 'web', 'templates') + current_path = os.path.join(self.app_root, 'templates') + paths_list = [ + jinja2.FileSystemLoader(current_path), + jinja2.FileSystemLoader(parent_theme_path), + self.jinja_loader + ] + # Set up a custom loader, so that Jinja searches for a theme file first + # in the current theme dir, and if it fails it searches in the default + # location. + custom_jinja_loader = jinja2.ChoiceLoader(paths_list) + self.jinja_loader = custom_jinja_loader + + def format_pretty_date(d): + return pretty_date(d) + + def format_pretty_date_time(d): + return pretty_date(d, detail=True) + + self.jinja_env.filters['pretty_date'] = format_pretty_date + self.jinja_env.filters['pretty_date_time'] = format_pretty_date_time + self.jinja_env.globals['url_for_node'] = url_for_node + + def _config_static_dirs(self): + pillar_dir = os.path.dirname(os.path.realpath(__file__)) + # Setup static folder for the instanced app + self.static_folder = os.path.join(self.app_root, 'static') + # Setup static folder for Pillar + self.pillar_static_folder = os.path.join(pillar_dir, 'web', 'static') + + from flask.views import MethodView + from flask import send_from_directory + from flask import current_app + + class PillarStaticFile(MethodView): + def get(self, filename): + return send_from_directory(current_app.pillar_static_folder, + filename) + + self.add_url_rule('/static/pillar/', + view_func=PillarStaticFile.as_view('static_pillar')) + + def process_extensions(self): + # Re-initialise Eve after we allowed Pillar submodules to be loaded. + # EVIL STARTS HERE. It just copies part of the Eve.__init__() method. + self.set_defaults() + self.validate_config() + self.validate_domain_struct() + + self._init_url_rules() + self._init_media_endpoint() + self._init_schema_endpoint() + + if self.config['OPLOG'] is True: + self._init_oplog() + + domain_copy = copy.deepcopy(self.config['DOMAIN']) + for resource, settings in domain_copy.items(): + self.register_resource(resource, settings) + + self.register_error_handlers() + # EVIL ENDS HERE. No guarantees, though. + + self.finish_startup() + + def finish_startup(self): + self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME']) + + api.setup_app(self) + web.setup_app(self) + authentication.setup_app(self) + + self._config_jinja_env() + self._config_static_dirs() + + # Only enable this when debugging. + # self._list_routes() + + def setup_db_indices(self): + """Adds missing database indices. + + This does NOT drop and recreate existing indices, + nor does it reconfigure existing indices. + If you want that, drop them manually first. + """ + + self.log.debug('Adding any missing database indices.') + + import pymongo + + db = self.data.driver.db + + coll = db['tokens'] + coll.create_index([('user', pymongo.ASCENDING)]) + coll.create_index([('token', pymongo.ASCENDING)]) + + coll = db['notifications'] + coll.create_index([('user', pymongo.ASCENDING)]) + + coll = db['activities-subscriptions'] + coll.create_index([('context_object', pymongo.ASCENDING)]) + + coll = db['nodes'] + # This index is used for queries on project, and for queries on + # the combination (project, node type). + coll.create_index([('project', pymongo.ASCENDING), + ('node_type', pymongo.ASCENDING)]) + coll.create_index([('parent', pymongo.ASCENDING)]) + coll.create_index([('short_code', pymongo.ASCENDING)], + sparse=True, unique=True) + + def register_api_blueprint(self, blueprint, url_prefix): + # TODO: use Eve config variable instead of hard-coded '/api' + self.register_blueprint(blueprint, url_prefix='/api' + url_prefix) + + def make_header(self, username, subclient_id=''): + """Returns a Basic HTTP Authentication header value.""" + import base64 + + return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id)) + + def post_internal(self, resource, payl=None, skip_validation=False): + """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" + from eve.methods.post import post_internal + + with self.test_request_context(method='POST', path='%s/%s' % (self.api_prefix, resource)): + return post_internal(resource, payl=payl, skip_validation=skip_validation) + + def put_internal(self, resource, payload=None, concurrency_check=False, + skip_validation=False, **lookup): + """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" + from eve.methods.put import put_internal + + path = '%s/%s/%s' % (self.api_prefix, resource, lookup['_id']) + with self.test_request_context(method='PUT', path=path): + return put_internal(resource, payload=payload, concurrency_check=concurrency_check, + skip_validation=skip_validation, **lookup) + + def patch_internal(self, resource, payload=None, concurrency_check=False, + skip_validation=False, **lookup): + """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" + from eve.methods.patch import patch_internal + + path = '%s/%s/%s' % (self.api_prefix, resource, lookup['_id']) + with self.test_request_context(method='PATCH', path=path): + return patch_internal(resource, payload=payload, concurrency_check=concurrency_check, + skip_validation=skip_validation, **lookup) + + def _list_routes(self): + from pprint import pprint + from flask import url_for + + def has_no_empty_params(rule): + defaults = rule.defaults if rule.defaults is not None else () + arguments = rule.arguments if rule.arguments is not None else () + return len(defaults) >= len(arguments) + + links = [] + with self.test_request_context(): + for rule in self.url_map.iter_rules(): + # Filter out rules we can't navigate to in a browser + # and rules that require parameters + if "GET" in rule.methods and has_no_empty_params(rule): + url = url_for(rule.endpoint, **(rule.defaults or {})) + links.append((url, rule.endpoint)) + + links.sort(key=lambda t: len(t[0]) + 100 * ('/api/' in t[0])) + + pprint(links) diff --git a/pillar/api/__init__.py b/pillar/api/__init__.py new file mode 100644 index 00000000..5abf9594 --- /dev/null +++ b/pillar/api/__init__.py @@ -0,0 +1,15 @@ +def setup_app(app): + from . import encoding, blender_id, projects, local_auth, file_storage + from . import users, nodes, latest, blender_cloud, service, activities + + encoding.setup_app(app, url_prefix='/encoding') + blender_id.setup_app(app, url_prefix='/blender_id') + projects.setup_app(app, api_prefix='/p') + local_auth.setup_app(app, url_prefix='/auth') + file_storage.setup_app(app, url_prefix='/storage') + latest.setup_app(app, url_prefix='/latest') + blender_cloud.setup_app(app, url_prefix='/bcloud') + users.setup_app(app, api_prefix='/users') + service.setup_app(app, api_prefix='/service') + nodes.setup_app(app, url_prefix='/nodes') + activities.setup_app(app) diff --git a/pillar/application/utils/activities.py b/pillar/api/activities.py similarity index 87% rename from pillar/application/utils/activities.py rename to pillar/api/activities.py index 9c53de62..e1f528aa 100644 --- a/pillar/application/utils/activities.py +++ b/pillar/api/activities.py @@ -1,7 +1,5 @@ -from flask import g -from flask import current_app -from eve.methods.post import post_internal -from application.modules.users import gravatar +from flask import g, request, current_app +from pillar.api.utils import gravatar def notification_parse(notification): @@ -111,7 +109,7 @@ def activity_subscribe(user_id, context_object_type, context_object_id): # If no subscription exists, we create one if not subscription: - post_internal('activities-subscriptions', lookup) + current_app.post_internal('activities-subscriptions', lookup) def activity_object_add(actor_user_id, verb, object_type, object_id, @@ -143,7 +141,7 @@ def activity_object_add(actor_user_id, verb, object_type, object_id, context_object=context_object_id ) - activity = post_internal('activities', activity) + activity = current_app.post_internal('activities', activity) if activity[3] != 201: # If creation failed for any reason, do not create a any notifcation return @@ -151,4 +149,20 @@ def activity_object_add(actor_user_id, verb, object_type, object_id, notification = dict( user=subscription['user'], activity=activity[0]['_id']) - post_internal('notifications', notification) + current_app.post_internal('notifications', notification) + + +def before_returning_item_notifications(response): + if request.args.get('parse'): + notification_parse(response) + + +def before_returning_resource_notifications(response): + for item in response['_items']: + if request.args.get('parse'): + notification_parse(item) + + +def setup_app(app): + app.on_fetched_item_notifications += before_returning_item_notifications + app.on_fetched_resource_notifications += before_returning_resource_notifications diff --git a/pillar/application/modules/blender_cloud/__init__.py b/pillar/api/blender_cloud/__init__.py similarity index 100% rename from pillar/application/modules/blender_cloud/__init__.py rename to pillar/api/blender_cloud/__init__.py diff --git a/pillar/application/modules/blender_cloud/home_project.py b/pillar/api/blender_cloud/home_project.py similarity index 92% rename from pillar/application/modules/blender_cloud/home_project.py rename to pillar/api/blender_cloud/home_project.py index 61d80916..7c1566aa 100644 --- a/pillar/application/modules/blender_cloud/home_project.py +++ b/pillar/api/blender_cloud/home_project.py @@ -1,17 +1,15 @@ import copy import logging -import datetime +import datetime from bson import ObjectId, tz_util -from eve.methods.post import post_internal -from eve.methods.put import put_internal from eve.methods.get import get from flask import Blueprint, g, current_app, request +from pillar.api import utils +from pillar.api.utils import authentication, authorization from werkzeug import exceptions as wz_exceptions -from application.modules import projects -from application import utils -from application.utils import authentication, authorization +from pillar.api.projects import utils as proj_utils blueprint = Blueprint('blender_cloud.home_project', __name__) log = logging.getLogger(__name__) @@ -73,7 +71,7 @@ def create_blender_sync_node(project_id, admin_group_id, user_id): } } - r, _, _, status = post_internal('nodes', node) + r, _, _, status = current_app.post_internal('nodes', node) if status != 201: log.warning('Unable to create Blender Sync node for home project %s: %s', project_id, r) @@ -109,9 +107,9 @@ def create_home_project(user_id, write_access): project = deleted_proj else: log.debug('User %s does not have a deleted project', user_id) - project = projects.create_new_project(project_name='Home', - user_id=ObjectId(user_id), - overrides=overrides) + project = proj_utils.create_new_project(project_name='Home', + user_id=ObjectId(user_id), + overrides=overrides) # Re-validate the authentication token, so that the put_internal call sees the # new group created for the project. @@ -124,10 +122,10 @@ def create_home_project(user_id, write_access): # Set up the correct node types. No need to set permissions for them, # as the inherited project permissions are fine. - from manage_extra.node_types.group import node_type_group - from manage_extra.node_types.asset import node_type_asset - # from manage_extra.node_types.text import node_type_text - from manage_extra.node_types.comment import node_type_comment + from pillar.api.node_types.group import node_type_group + from pillar.api.node_types.asset import node_type_asset + # from pillar.api.node_types.text import node_type_text + from pillar.api.node_types.comment import node_type_comment # For non-subscribers: take away write access from the admin group, # and grant it to certain node types. @@ -147,8 +145,8 @@ def create_home_project(user_id, write_access): node_type_comment, ] - result, _, _, status = put_internal('projects', utils.remove_private_keys(project), - _id=project['_id']) + result, _, _, status = current_app.put_internal('projects', utils.remove_private_keys(project), + _id=project['_id']) if status != 200: log.error('Unable to update home project %s for user %s: %s', project['_id'], user_id, result) @@ -166,7 +164,7 @@ def create_home_project(user_id, write_access): def assign_permissions(node_type, subscriber_methods, world_methods): """Assigns permissions to the node type object. - :param node_type: a node type from manage_extra.node_types. + :param node_type: a node type from pillar.api.node_types. :type node_type: dict :param subscriber_methods: allowed HTTP methods for users of role 'subscriber', 'demo' and 'admin'. @@ -177,7 +175,7 @@ def assign_permissions(node_type, subscriber_methods, world_methods): :rtype: dict """ - from application.modules import service + from pillar.api import service nt_with_perms = copy.deepcopy(node_type) @@ -391,7 +389,7 @@ def user_changed_role(sender, user): user_id = user['_id'] if not has_home_project(user_id): - log.debug('User %s does not have a home project', user_id) + log.debug('User %s does not have a home project, not changing access permissions', user_id) return proj_coll = current_app.data.driver.db['projects'] @@ -414,12 +412,12 @@ def user_changed_role(sender, user): def setup_app(app, url_prefix): - app.register_blueprint(blueprint, url_prefix=url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) app.on_insert_nodes += check_home_project_nodes_permissions app.on_inserted_nodes += mark_parents_as_updated app.on_updated_nodes += mark_parent_as_updated app.on_replaced_nodes += mark_parent_as_updated - from application.modules import service + from pillar.api import service service.signal_user_changed_role.connect(user_changed_role) diff --git a/pillar/application/modules/blender_cloud/texture_libs.py b/pillar/api/blender_cloud/texture_libs.py similarity index 95% rename from pillar/application/modules/blender_cloud/texture_libs.py rename to pillar/api/blender_cloud/texture_libs.py index 3e24e8b5..ebf0b5a5 100644 --- a/pillar/application/modules/blender_cloud/texture_libs.py +++ b/pillar/api/blender_cloud/texture_libs.py @@ -1,16 +1,15 @@ import functools import logging -from flask import Blueprint, request, current_app, g from eve.methods.get import get from eve.utils import config as eve_config +from flask import Blueprint, request, current_app, g +from pillar.api import utils +from pillar.api.utils.authentication import current_user_id +from pillar.api.utils.authorization import require_login from werkzeug.datastructures import MultiDict from werkzeug.exceptions import InternalServerError -from application import utils -from application.utils.authentication import current_user_id -from application.utils.authorization import require_login - FIRST_ADDON_VERSION_WITH_HDRI = (1, 4, 0) TL_PROJECTION = utils.dumps({'name': 1, 'url': 1, 'permissions': 1,}) TL_SORT = utils.dumps([('name', 1)]) @@ -144,4 +143,4 @@ def setup_app(app, url_prefix): app.on_replace_nodes += sort_by_image_width app.on_insert_nodes += sort_nodes_by_image_width - app.register_blueprint(blueprint, url_prefix=url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/modules/blender_id.py b/pillar/api/blender_id.py similarity index 94% rename from pillar/application/modules/blender_id.py rename to pillar/api/blender_id.py index da53f429..c1d51241 100644 --- a/pillar/application/modules/blender_id.py +++ b/pillar/api/blender_id.py @@ -5,18 +5,15 @@ with Blender ID. """ import logging + import datetime - -from bson import tz_util import requests +from bson import tz_util +from flask import Blueprint, request, current_app, jsonify +from pillar.api.utils import authentication, remove_private_keys from requests.adapters import HTTPAdapter -from flask import Blueprint, request, current_app, abort, jsonify -from eve.methods.post import post_internal -from eve.methods.put import put_internal from werkzeug import exceptions as wz_exceptions -from application.utils import authentication, remove_private_keys - blender_id = Blueprint('blender_id', __name__) log = logging.getLogger(__name__) @@ -99,15 +96,15 @@ def upsert_user(db_user, blender_id_user_id): # Update the existing user attempted_eve_method = 'PUT' db_id = db_user['_id'] - r, _, _, status = put_internal('users', remove_private_keys(db_user), - _id=db_id) + r, _, _, status = current_app.put_internal('users', remove_private_keys(db_user), + _id=db_id) if status == 422: log.error('Status %i trying to PUT user %s with values %s, should not happen! %s', status, db_id, remove_private_keys(db_user), r) else: # Create a new user, retry for non-unique usernames. attempted_eve_method = 'POST' - r, _, _, status = post_internal('users', db_user) + r, _, _, status = current_app.post_internal('users', db_user) if status not in {200, 201}: log.error('Status %i trying to create user for BlenderID %s with values %s: %s', @@ -174,8 +171,7 @@ def validate_token(user_id, token, oauth_subclient_id): # POST to Blender ID, handling errors as negative verification results. try: - r = s.post(url, data=payload, timeout=5, - verify=current_app.config['TLS_CERT_FILE']) + r = s.post(url, data=payload, timeout=5) except requests.exceptions.ConnectionError as e: log.error('Connection error trying to POST to %s, handling as invalid token.', url) return None, None @@ -238,3 +234,7 @@ def find_user_in_db(blender_id_user_id, user_info): db_user['full_name'] = db_user['username'] return db_user + + +def setup_app(app, url_prefix): + app.register_api_blueprint(blender_id, url_prefix=url_prefix) diff --git a/pillar/api/custom_field_validation.py b/pillar/api/custom_field_validation.py new file mode 100644 index 00000000..c4f60a4a --- /dev/null +++ b/pillar/api/custom_field_validation.py @@ -0,0 +1,82 @@ +import logging + +from bson import ObjectId +from datetime import datetime +from eve.io.mongo import Validator +from flask import current_app + +log = logging.getLogger(__name__) + + +class ValidateCustomFields(Validator): + def convert_properties(self, properties, node_schema): + date_format = current_app.config['RFC1123_DATE_FORMAT'] + + for prop in node_schema: + if not prop in properties: + continue + schema_prop = node_schema[prop] + prop_type = schema_prop['type'] + if prop_type == 'dict': + properties[prop] = self.convert_properties( + properties[prop], schema_prop['schema']) + if prop_type == 'list': + if properties[prop] in ['', '[]']: + properties[prop] = [] + for k, val in enumerate(properties[prop]): + if not 'schema' in schema_prop: + continue + item_schema = {'item': schema_prop['schema']} + item_prop = {'item': properties[prop][k]} + properties[prop][k] = self.convert_properties( + item_prop, item_schema)['item'] + # Convert datetime string to RFC1123 datetime + elif prop_type == 'datetime': + prop_val = properties[prop] + properties[prop] = datetime.strptime(prop_val, date_format) + elif prop_type == 'objectid': + prop_val = properties[prop] + if prop_val: + properties[prop] = ObjectId(prop_val) + else: + properties[prop] = None + + return properties + + def _validate_valid_properties(self, valid_properties, field, value): + from pillar.api.utils import project_get_node_type + + projects_collection = current_app.data.driver.db['projects'] + lookup = {'_id': ObjectId(self.document['project'])} + + project = projects_collection.find_one(lookup, { + 'node_types.name': 1, + 'node_types.dyn_schema': 1, + }) + if project is None: + log.warning('Unknown project %s, declared by node %s', + lookup, self.document.get('_id')) + self._error(field, 'Unknown project') + return False + + node_type_name = self.document['node_type'] + node_type = project_get_node_type(project, node_type_name) + if node_type is None: + log.warning('Project %s has no node type %s, declared by node %s', + project, node_type_name, self.document.get('_id')) + self._error(field, 'Unknown node type') + return False + + try: + value = self.convert_properties(value, node_type['dyn_schema']) + except Exception as e: + log.warning("Error converting form properties", exc_info=True) + + v = Validator(node_type['dyn_schema']) + val = v.validate(value) + + if val: + return True + + log.warning('Error validating properties for node %s: %s', self.document, v.errors) + self._error(field, "Error validating properties") diff --git a/pillar/application/modules/encoding.py b/pillar/api/encoding.py similarity index 94% rename from pillar/application/modules/encoding.py rename to pillar/api/encoding.py index 7d5b9115..a33bf795 100644 --- a/pillar/application/modules/encoding.py +++ b/pillar/api/encoding.py @@ -2,16 +2,14 @@ import logging import datetime import os - from bson import ObjectId, tz_util -from eve.methods.put import put_internal from flask import Blueprint from flask import abort -from flask import request from flask import current_app -from application import utils -from application.utils import skip_when_testing -from application.utils.gcs import GoogleCloudStorageBucket +from flask import request +from pillar.api import utils +from pillar.api.utils.gcs import GoogleCloudStorageBucket +from pillar.api.utils import skip_when_testing encoding = Blueprint('encoding', __name__) log = logging.getLogger(__name__) @@ -115,7 +113,7 @@ def zencoder_notifications(): log.info(' %s: %s', key, output[key]) file_doc['status'] = 'failed' - put_internal('files', file_doc, _id=file_id) + current_app.put_internal('files', file_doc, _id=file_id) return "You failed, but that's okay.", 200 log.info('Zencoder job %s for file %s completed with status %s.', zencoder_job_id, file_id, @@ -171,6 +169,10 @@ def zencoder_notifications(): # Force an update of the links on the next load of the file. file_doc['link_expires'] = datetime.datetime.now(tz=tz_util.utc) - datetime.timedelta(days=1) - put_internal('files', file_doc, _id=file_id) + current_app.put_internal('files', file_doc, _id=file_id) return '', 204 + + +def setup_app(app, url_prefix): + app.register_api_blueprint(encoding, url_prefix=url_prefix) diff --git a/pillar/settings.py b/pillar/api/eve_settings.py similarity index 97% rename from pillar/settings.py rename to pillar/api/eve_settings.py index 8c02dec8..e128c2e1 100644 --- a/pillar/settings.py +++ b/pillar/api/eve_settings.py @@ -1,5 +1,7 @@ import os +URL_PREFIX = 'api' + # Enable reads (GET), inserts (POST) and DELETE for resources/collections # (if you omit this line, the API will default to ['GET'] and provide # read-only access to the endpoint). @@ -375,14 +377,15 @@ files_schema = { }, 'length_aggregate_in_bytes': { # Size of file + all variations 'type': 'integer', - 'required': False, # it's computed on the fly anyway, so clients don't need to provide it. + 'required': False, + # it's computed on the fly anyway, so clients don't need to provide it. }, 'md5': { 'type': 'string', 'required': True, }, - # Original filename as given by the user, possibly cleaned-up to make it safe. + # Original filename as given by the user, cleaned-up to make it safe. 'filename': { 'type': 'string', 'required': True, @@ -692,7 +695,7 @@ users = { 'cache_expires': 10, 'resource_methods': ['GET'], - 'item_methods': ['GET', 'PUT'], + 'item_methods': ['GET', 'PUT', 'PATCH'], 'public_item_methods': ['GET'], # By default don't include the 'auth' field. It can still be obtained @@ -713,6 +716,7 @@ tokens = { files = { 'resource_methods': ['GET', 'POST'], + 'item_methods': ['GET', 'PATCH'], 'public_methods': ['GET'], 'public_item_methods': ['GET'], 'schema': files_schema @@ -763,9 +767,9 @@ DOMAIN = { 'notifications': notifications } -MONGO_HOST = os.environ.get('MONGO_HOST', 'localhost') -MONGO_PORT = os.environ.get('MONGO_PORT', 27017) -MONGO_DBNAME = os.environ.get('MONGO_DBNAME', 'eve') +MONGO_HOST = os.environ.get('PILLAR_MONGO_HOST', 'localhost') +MONGO_PORT = int(os.environ.get('PILLAR_MONGO_PORT', 27017)) +MONGO_DBNAME = os.environ.get('PILLAR_MONGO_DBNAME', 'eve') CACHE_EXPIRES = 60 HATEOAS = False UPSET_ON_PUT = False # do not create new document on PUT of non-existant URL. diff --git a/pillar/application/modules/file_storage.py b/pillar/api/file_storage.py similarity index 88% rename from pillar/application/modules/file_storage.py rename to pillar/api/file_storage.py index 35d4c0d9..98b91e89 100644 --- a/pillar/application/modules/file_storage.py +++ b/pillar/api/file_storage.py @@ -1,37 +1,32 @@ -import datetime +import io import logging import mimetypes -import os import tempfile import uuid -import io from hashlib import md5 import bson.tz_util +import datetime import eve.utils +import os import pymongo +import werkzeug.exceptions as wz_exceptions from bson import ObjectId -from bson.errors import InvalidId -from eve.methods.patch import patch_internal -from eve.methods.post import post_internal -from eve.methods.put import put_internal from flask import Blueprint +from flask import current_app +from flask import g from flask import jsonify from flask import request from flask import send_from_directory from flask import url_for, helpers -from flask import current_app -from flask import g -from flask import make_response -import werkzeug.exceptions as wz_exceptions - -from application import utils -from application.utils import remove_private_keys, authentication -from application.utils.authorization import require_login, user_has_role, user_matches_roles -from application.utils.cdn import hash_file_path -from application.utils.encoding import Encoder -from application.utils.gcs import GoogleCloudStorageBucket -from application.utils.imaging import generate_local_thumbnails +from pillar.api import utils +from pillar.api.utils.imaging import generate_local_thumbnails +from pillar.api.utils import remove_private_keys, authentication +from pillar.api.utils.authorization import require_login, user_has_role, \ + user_matches_roles +from pillar.api.utils.cdn import hash_file_path +from pillar.api.utils.encoding import Encoder +from pillar.api.utils.gcs import GoogleCloudStorageBucket log = logging.getLogger(__name__) @@ -39,15 +34,9 @@ file_storage = Blueprint('file_storage', __name__, template_folder='templates', static_folder='../../static/storage', ) -# Overrides for browser-specified mimetypes -OVERRIDE_MIMETYPES = { - # We don't want to thumbnail EXR files right now, so don't handle as image/... - 'image/x-exr': 'application/x-exr', -} # Add our own extensions to the mimetypes package mimetypes.add_type('application/x-blender', '.blend') mimetypes.add_type('application/x-radiance-hdr', '.hdr') -mimetypes.add_type('application/x-exr', '.exr') @file_storage.route('/gcs///') @@ -93,7 +82,8 @@ def index(file_name=None): # Determine & create storage directory folder_name = file_name[:2] - file_folder_path = helpers.safe_join(current_app.config['STORAGE_DIR'], folder_name) + file_folder_path = helpers.safe_join(current_app.config['STORAGE_DIR'], + folder_name) if not os.path.exists(file_folder_path): log.info('Creating folder path %r', file_folder_path) os.mkdir(file_folder_path) @@ -121,8 +111,8 @@ def _process_image(gcs, file_id, local_file, src_file): local_file.name) # Send those previews to Google Cloud Storage. - log.info('Uploading %i thumbnails for file %s to Google Cloud Storage (GCS)', - len(src_file['variations']), file_id) + log.info('Uploading %i thumbnails for file %s to Google Cloud Storage ' + '(GCS)', len(src_file['variations']), file_id) # TODO: parallelize this at some point. for variation in src_file['variations']: @@ -141,8 +131,8 @@ def _process_image(gcs, file_id, local_file, src_file): try: os.unlink(variation['local_path']) except OSError: - log.warning('Unable to unlink %s, ignoring this but it will need cleanup later.', - variation['local_path']) + log.warning('Unable to unlink %s, ignoring this but it will need ' + 'cleanup later.', variation['local_path']) del variation['local_path'] @@ -177,17 +167,19 @@ def _process_video(gcs, file_id, local_file, src_file): src_file['variations'].append(file_variation) if current_app.config['TESTING']: - log.warning('_process_video: NOT sending out encoding job due to TESTING=%r', - current_app.config['TESTING']) + log.warning('_process_video: NOT sending out encoding job due to ' + 'TESTING=%r', current_app.config['TESTING']) j = type('EncoderJob', (), {'process_id': 'fake-process-id', 'backend': 'fake'}) else: j = Encoder.job_create(src_file) if j is None: - log.warning('_process_video: unable to create encoder job for file %s.', file_id) + log.warning('_process_video: unable to create encoder job for file ' + '%s.', file_id) return - log.info('Created asynchronous Zencoder job %s for file %s', j['process_id'], file_id) + log.info('Created asynchronous Zencoder job %s for file %s', + j['process_id'], file_id) # Add the processing status to the file object src_file['processing'] = { @@ -201,7 +193,8 @@ def process_file(gcs, file_id, local_file): :param file_id: '_id' key of the file :type file_id: ObjectId or str - :param local_file: locally stored file, or None if no local processing is needed. + :param local_file: locally stored file, or None if no local processing is + needed. :type local_file: file """ @@ -239,26 +232,30 @@ def process_file(gcs, file_id, local_file): try: processor = processors[mime_category] except KeyError: - log.info("POSTed file %s was of type %r, which isn't thumbnailed/encoded.", file_id, + log.info("POSTed file %s was of type %r, which isn't " + "thumbnailed/encoded.", file_id, mime_category) src_file['status'] = 'complete' else: - log.debug('process_file(%s): marking file status as "processing"', file_id) + log.debug('process_file(%s): marking file status as "processing"', + file_id) src_file['status'] = 'processing' update_file_doc(file_id, status='processing') try: processor(gcs, file_id, local_file, src_file) except Exception: - log.warning('process_file(%s): error when processing file, resetting status to ' + log.warning('process_file(%s): error when processing file, ' + 'resetting status to ' '"queued_for_processing"', file_id, exc_info=True) update_file_doc(file_id, status='queued_for_processing') return # Update the original file with additional info, e.g. image resolution - r, _, _, status = put_internal('files', src_file, _id=file_id) + r, _, _, status = current_app.put_internal('files', src_file, _id=file_id) if status not in (200, 201): - log.warning('process_file(%s): status %i when saving processed file info to MongoDB: %s', + log.warning('process_file(%s): status %i when saving processed file ' + 'info to MongoDB: %s', file_id, status, r) @@ -296,6 +293,11 @@ def generate_link(backend, file_path, project_id=None, is_public=False): """ if backend == 'gcs': + if current_app.config['TESTING']: + log.info('Skipping GCS link generation, and returning a fake link ' + 'instead.') + return '/path/to/testing/gcs/%s' % file_path + storage = GoogleCloudStorageBucket(project_id) blob = storage.Get(file_path) if blob is None: @@ -306,8 +308,8 @@ def generate_link(backend, file_path, project_id=None, is_public=False): return blob['signed_url'] if backend == 'pillar': - return url_for('file_storage.index', file_name=file_path, _external=True, - _scheme=current_app.config['SCHEME']) + return url_for('file_storage.index', file_name=file_path, + _external=True, _scheme=current_app.config['SCHEME']) if backend == 'cdnsun': return hash_file_path(file_path, None) if backend == 'unittest': @@ -319,7 +321,8 @@ def generate_link(backend, file_path, project_id=None, is_public=False): def before_returning_file(response): ensure_valid_link(response) - # Enable this call later, when we have implemented the is_public field on files. + # Enable this call later, when we have implemented the is_public field on + # files. # strip_link_and_variations(response) @@ -352,7 +355,7 @@ def ensure_valid_link(response): """Ensures the file item has valid file links using generate_link(...).""" # Log to function-specific logger, so we can easily turn it off. - log = logging.getLogger('%s.ensure_valid_link' % __name__) + log_link = logging.getLogger('%s.ensure_valid_link' % __name__) # log.debug('Inspecting link for file %s', response['_id']) # Check link expiry. @@ -361,13 +364,14 @@ def ensure_valid_link(response): link_expires = response['link_expires'] if now < link_expires: # Not expired yet, so don't bother regenerating anything. - log.debug('Link expires at %s, which is in the future, so not generating new link', - link_expires) + log_link.debug('Link expires at %s, which is in the future, so not ' + 'generating new link', link_expires) return - log.debug('Link expired at %s, which is in the past; generating new link', link_expires) + log_link.debug('Link expired at %s, which is in the past; generating ' + 'new link', link_expires) else: - log.debug('No expiry date for link; generating new link') + log_link.debug('No expiry date for link; generating new link') _generate_all_links(response, now) @@ -380,14 +384,16 @@ def _generate_all_links(response, now): """ project_id = str( - response['project']) if 'project' in response else None # TODO: add project id to all files + response['project']) if 'project' in response else None + # TODO: add project id to all files backend = response['backend'] response['link'] = generate_link(backend, response['file_path'], project_id) variations = response.get('variations') if variations: for variation in variations: - variation['link'] = generate_link(backend, variation['file_path'], project_id) + variation['link'] = generate_link(backend, variation['file_path'], + project_id) # Construct the new expiry datetime. validity_secs = current_app.config['FILE_LINK_VALIDITY'][backend] @@ -395,16 +401,19 @@ def _generate_all_links(response, now): patch_info = remove_private_keys(response) file_id = ObjectId(response['_id']) - (patch_resp, _, _, _) = patch_internal('files', patch_info, _id=file_id) + (patch_resp, _, _, _) = current_app.patch_internal('files', patch_info, + _id=file_id) if patch_resp.get('_status') == 'ERR': - log.warning('Unable to save new links for file %s: %r', response['_id'], patch_resp) + log.warning('Unable to save new links for file %s: %r', + response['_id'], patch_resp) # TODO: raise a snag. response['_updated'] = now else: response['_updated'] = patch_resp['_updated'] # Be silly and re-fetch the etag ourselves. TODO: handle this better. - etag_doc = current_app.data.driver.db['files'].find_one({'_id': file_id}, {'_etag': 1}) + etag_doc = current_app.data.driver.db['files'].find_one({'_id': file_id}, + {'_etag': 1}) response['_etag'] = etag_doc['_etag'] @@ -413,7 +422,8 @@ def before_deleting_file(item): def on_pre_get_files(_, lookup): - # Override the HTTP header, we always want to fetch the document from MongoDB. + # Override the HTTP header, we always want to fetch the document from + # MongoDB. parsed_req = eve.utils.parse_request('files') parsed_req.if_modified_since = None @@ -430,7 +440,8 @@ def on_pre_get_files(_, lookup): def refresh_links_for_project(project_uuid, chunk_size, expiry_seconds): if chunk_size: - log.info('Refreshing the first %i links for project %s', chunk_size, project_uuid) + log.info('Refreshing the first %i links for project %s', + chunk_size, project_uuid) else: log.info('Refreshing all links for project %s', project_uuid) @@ -470,9 +481,11 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds): to_refresh = files_collection.find( {'$or': [{'backend': backend_name, 'link_expires': None}, - {'backend': backend_name, 'link_expires': {'$lt': expire_before}}, + {'backend': backend_name, 'link_expires': { + '$lt': expire_before}}, {'backend': backend_name, 'link': None}] - }).sort([('link_expires', pymongo.ASCENDING)]).limit(chunk_size).batch_size(5) + }).sort([('link_expires', pymongo.ASCENDING)]).limit( + chunk_size).batch_size(5) if to_refresh.count() == 0: log.info('No links to refresh.') @@ -493,11 +506,13 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds): ]}) if count == 0: - log.debug('Skipping file %s, project %s does not exist.', file_id, project_id) + log.debug('Skipping file %s, project %s does not exist.', + file_id, project_id) continue if 'file_path' not in file_doc: - log.warning("Skipping file %s, missing 'file_path' property.", file_id) + log.warning("Skipping file %s, missing 'file_path' property.", + file_id) continue log.debug('Refreshing links for file %s', file_id) @@ -505,21 +520,21 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds): try: _generate_all_links(file_doc, now) except gcloud.exceptions.Forbidden: - log.warning('Skipping file %s, GCS forbids us access to project %s bucket.', - file_id, project_id) + log.warning('Skipping file %s, GCS forbids us access to ' + 'project %s bucket.', file_id, project_id) continue refreshed += 1 except KeyboardInterrupt: - log.warning('Aborting due to KeyboardInterrupt after refreshing %i links', - refreshed) + log.warning('Aborting due to KeyboardInterrupt after refreshing %i ' + 'links', refreshed) return log.info('Refreshed %i links', refreshed) @require_login() -def create_file_doc(name, filename, content_type, length, project, backend='gcs', - **extra_fields): +def create_file_doc(name, filename, content_type, length, project, + backend='gcs', **extra_fields): """Creates a minimal File document for storage in MongoDB. Doesn't save it to MongoDB yet. @@ -550,12 +565,6 @@ def override_content_type(uploaded_file): # Possibly use the browser-provided mime type mimetype = uploaded_file.mimetype - - try: - mimetype = OVERRIDE_MIMETYPES[mimetype] - except KeyError: - pass - if '/' in mimetype: mimecat = mimetype.split('/')[0] if mimecat in {'video', 'audio', 'image'}: @@ -571,7 +580,8 @@ def override_content_type(uploaded_file): # content_type property can't be set directly uploaded_file.headers['content-type'] = mimetype - # It has this, because we used uploaded_file.mimetype earlier this function. + # It has this, because we used uploaded_file.mimetype earlier this + # function. del uploaded_file._parsed_content_type @@ -590,10 +600,13 @@ def assert_file_size_allowed(file_size): return filesize_limit_mb = filesize_limit / 2.0 ** 20 - log.info('User %s tried to upload a %.3f MiB file, but is only allowed %.3f MiB.', - authentication.current_user_id(), file_size / 2.0 ** 20, filesize_limit_mb) + log.info('User %s tried to upload a %.3f MiB file, but is only allowed ' + '%.3f MiB.', + authentication.current_user_id(), file_size / 2.0 ** 20, + filesize_limit_mb) raise wz_exceptions.RequestEntityTooLarge( - 'To upload files larger than %i MiB, subscribe to Blender Cloud' % filesize_limit_mb) + 'To upload files larger than %i MiB, subscribe to Blender Cloud' % + filesize_limit_mb) @file_storage.route('/stream/', methods=['POST', 'OPTIONS']) @@ -613,10 +626,10 @@ def stream_to_gcs(project_id): uploaded_file = request.files['file'] - # Not every upload has a Content-Length header. If it was passed, we might as - # well check for its value before we require the user to upload the entire file. - # (At least I hope that this part of the code is processed before the body is - # read in its entirety) + # Not every upload has a Content-Length header. If it was passed, we might + # as well check for its value before we require the user to upload the + # entire file. (At least I hope that this part of the code is processed + # before the body is read in its entirety) if uploaded_file.content_length: assert_file_size_allowed(uploaded_file.content_length) @@ -756,10 +769,10 @@ def create_file_doc_for_upload(project_id, uploaded_file): if file_doc is None: # Create a file document on MongoDB for this file. file_doc = create_file_doc(name=internal_filename, **new_props) - file_fields, _, _, status = post_internal('files', file_doc) + file_fields, _, _, status = current_app.post_internal('files', file_doc) else: file_doc.update(new_props) - file_fields, _, _, status = put_internal('files', remove_private_keys(file_doc)) + file_fields, _, _, status = current_app.put_internal('files', remove_private_keys(file_doc)) if status not in (200, 201): log.error('Unable to create new file document in MongoDB, status=%i: %s', @@ -799,4 +812,4 @@ def setup_app(app, url_prefix): app.on_replace_files += compute_aggregate_length app.on_insert_files += compute_aggregate_length_items - app.register_blueprint(file_storage, url_prefix=url_prefix) + app.register_api_blueprint(file_storage, url_prefix=url_prefix) diff --git a/pillar/application/modules/latest.py b/pillar/api/latest.py similarity index 79% rename from pillar/application/modules/latest.py rename to pillar/api/latest.py index ea085149..5b5ca1d1 100644 --- a/pillar/application/modules/latest.py +++ b/pillar/api/latest.py @@ -3,12 +3,13 @@ import itertools import pymongo from flask import Blueprint, current_app -from application.utils import jsonify +from pillar.api.utils import jsonify blueprint = Blueprint('latest', __name__) -def keep_fetching(collection, db_filter, projection, sort, py_filter, batch_size=12): +def keep_fetching(collection, db_filter, projection, sort, py_filter, + batch_size=12): """Yields results for which py_filter returns True""" projection['_deleted'] = 1 @@ -47,7 +48,7 @@ def has_public_project(node_doc): return is_project_public(project_id) -# TODO: cache result, at least for a limited amt. of time, or for this HTTP request. +# TODO: cache result, for a limited amt. of time, or for this HTTP request. def is_project_public(project_id): """Returns True iff the project is public.""" @@ -60,7 +61,8 @@ def is_project_public(project_id): @blueprint.route('/assets') def latest_assets(): - latest = latest_nodes({'node_type': 'asset', 'properties.status': 'published'}, + latest = latest_nodes({'node_type': 'asset', + 'properties.status': 'published'}, {'name': 1, 'project': 1, 'user': 1, 'node_type': 1, 'parent': 1, 'picture': 1, 'properties.status': 1, 'properties.content_type': 1, @@ -78,9 +80,9 @@ def embed_user(latest): for comment in latest: user_id = comment['user'] - comment['user'] = users.find_one(user_id, {'auth': 0, 'groups': 0, 'roles': 0, - 'settings': 0, 'email': 0, - '_created': 0, '_updated': 0, '_etag': 0}) + comment['user'] = users.find_one(user_id, { + 'auth': 0, 'groups': 0, 'roles': 0, 'settings': 0, 'email': 0, + '_created': 0, '_updated': 0, '_etag': 0}) def embed_project(latest): @@ -88,14 +90,17 @@ def embed_project(latest): for comment in latest: project_id = comment['project'] - comment['project'] = projects.find_one(project_id, {'_id': 1, 'name': 1, 'url': 1}) + comment['project'] = projects.find_one(project_id, {'_id': 1, 'name': 1, + 'url': 1}) @blueprint.route('/comments') def latest_comments(): - latest = latest_nodes({'node_type': 'comment', 'properties.status': 'published'}, + latest = latest_nodes({'node_type': 'comment', + 'properties.status': 'published'}, {'project': 1, 'parent': 1, 'user': 1, - 'properties.content': 1, 'node_type': 1, 'properties.status': 1, + 'properties.content': 1, 'node_type': 1, + 'properties.status': 1, 'properties.is_reply': 1}, has_public_project, 6) @@ -120,4 +125,4 @@ def latest_comments(): def setup_app(app, url_prefix): - app.register_blueprint(blueprint, url_prefix=url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/modules/local_auth.py b/pillar/api/local_auth.py similarity index 89% rename from pillar/application/modules/local_auth.py rename to pillar/api/local_auth.py index ffb8604a..41d2bb66 100644 --- a/pillar/application/modules/local_auth.py +++ b/pillar/api/local_auth.py @@ -1,17 +1,15 @@ import base64 -import datetime import hashlib import logging -import rsa.randnum + import bcrypt +import datetime +import rsa.randnum from bson import tz_util -from eve.methods.post import post_internal - from flask import abort, Blueprint, current_app, jsonify, request - -from application.utils.authentication import store_token -from application.utils.authentication import create_new_user_document -from application.utils.authentication import make_unique_username +from pillar.api.utils.authentication import create_new_user_document +from pillar.api.utils.authentication import make_unique_username +from pillar.api.utils.authentication import store_token blueprint = Blueprint('authentication', __name__) log = logging.getLogger(__name__) @@ -31,7 +29,7 @@ def create_local_user(email, password): # Make username unique db_user['username'] = make_unique_username(email) # Create the user - r, _, _, status = post_internal('users', db_user) + r, _, _, status = current_app.post_internal('users', db_user) if status != 201: log.error('internal response: %r %r', status, r) return abort(500) @@ -96,4 +94,4 @@ def hash_password(password, salt): def setup_app(app, url_prefix): - app.register_blueprint(blueprint, url_prefix=url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/manage_extra/node_types/__init__.py b/pillar/api/node_types/__init__.py similarity index 100% rename from pillar/manage_extra/node_types/__init__.py rename to pillar/api/node_types/__init__.py diff --git a/pillar/manage_extra/node_types/act.py b/pillar/api/node_types/act.py similarity index 100% rename from pillar/manage_extra/node_types/act.py rename to pillar/api/node_types/act.py diff --git a/pillar/manage_extra/node_types/asset.py b/pillar/api/node_types/asset.py similarity index 97% rename from pillar/manage_extra/node_types/asset.py rename to pillar/api/node_types/asset.py index 21b3f682..7383ec80 100644 --- a/pillar/manage_extra/node_types/asset.py +++ b/pillar/api/node_types/asset.py @@ -1,4 +1,4 @@ -from manage_extra.node_types import _file_embedded_schema +from pillar.api.node_types import _file_embedded_schema node_type_asset = { 'name': 'asset', diff --git a/pillar/manage_extra/node_types/blog.py b/pillar/api/node_types/blog.py similarity index 100% rename from pillar/manage_extra/node_types/blog.py rename to pillar/api/node_types/blog.py diff --git a/pillar/manage_extra/node_types/comment.py b/pillar/api/node_types/comment.py similarity index 100% rename from pillar/manage_extra/node_types/comment.py rename to pillar/api/node_types/comment.py diff --git a/pillar/manage_extra/node_types/group.py b/pillar/api/node_types/group.py similarity index 100% rename from pillar/manage_extra/node_types/group.py rename to pillar/api/node_types/group.py diff --git a/pillar/manage_extra/node_types/group_hdri.py b/pillar/api/node_types/group_hdri.py similarity index 100% rename from pillar/manage_extra/node_types/group_hdri.py rename to pillar/api/node_types/group_hdri.py diff --git a/pillar/manage_extra/node_types/group_texture.py b/pillar/api/node_types/group_texture.py similarity index 100% rename from pillar/manage_extra/node_types/group_texture.py rename to pillar/api/node_types/group_texture.py diff --git a/pillar/manage_extra/node_types/hdri.py b/pillar/api/node_types/hdri.py similarity index 97% rename from pillar/manage_extra/node_types/hdri.py rename to pillar/api/node_types/hdri.py index 20e3387e..db8267a4 100644 --- a/pillar/manage_extra/node_types/hdri.py +++ b/pillar/api/node_types/hdri.py @@ -1,4 +1,4 @@ -from manage_extra.node_types import _file_embedded_schema +from pillar.api.node_types import _file_embedded_schema node_type_hdri = { # When adding this node type, make sure to enable CORS from * on the GCS diff --git a/pillar/manage_extra/node_types/page.py b/pillar/api/node_types/page.py similarity index 96% rename from pillar/manage_extra/node_types/page.py rename to pillar/api/node_types/page.py index 73a74744..3b8a0482 100644 --- a/pillar/manage_extra/node_types/page.py +++ b/pillar/api/node_types/page.py @@ -1,4 +1,4 @@ -from manage_extra.node_types import _file_embedded_schema +from pillar.api.node_types import _file_embedded_schema node_type_page = { 'name': 'page', diff --git a/pillar/manage_extra/node_types/post.py b/pillar/api/node_types/post.py similarity index 96% rename from pillar/manage_extra/node_types/post.py rename to pillar/api/node_types/post.py index f24aade8..024d31df 100644 --- a/pillar/manage_extra/node_types/post.py +++ b/pillar/api/node_types/post.py @@ -1,4 +1,4 @@ -from manage_extra.node_types import _file_embedded_schema +from pillar.api.node_types import _file_embedded_schema node_type_post = { 'name': 'post', diff --git a/pillar/manage_extra/node_types/project.py b/pillar/api/node_types/project.py similarity index 98% rename from pillar/manage_extra/node_types/project.py rename to pillar/api/node_types/project.py index df3be7fe..cfa123cd 100644 --- a/pillar/manage_extra/node_types/project.py +++ b/pillar/api/node_types/project.py @@ -1,4 +1,4 @@ -from manage_extra.node_types import _file_embedded_schema +from pillar.api.node_types import _file_embedded_schema node_type_project = { 'name': 'project', diff --git a/pillar/manage_extra/node_types/scene.py b/pillar/api/node_types/scene.py similarity index 100% rename from pillar/manage_extra/node_types/scene.py rename to pillar/api/node_types/scene.py diff --git a/pillar/manage_extra/node_types/shot.py b/pillar/api/node_types/shot.py similarity index 100% rename from pillar/manage_extra/node_types/shot.py rename to pillar/api/node_types/shot.py diff --git a/pillar/manage_extra/node_types/storage.py b/pillar/api/node_types/storage.py similarity index 100% rename from pillar/manage_extra/node_types/storage.py rename to pillar/api/node_types/storage.py diff --git a/pillar/manage_extra/node_types/task.py b/pillar/api/node_types/task.py similarity index 100% rename from pillar/manage_extra/node_types/task.py rename to pillar/api/node_types/task.py diff --git a/pillar/manage_extra/node_types/text.py b/pillar/api/node_types/text.py similarity index 100% rename from pillar/manage_extra/node_types/text.py rename to pillar/api/node_types/text.py diff --git a/pillar/manage_extra/node_types/texture.py b/pillar/api/node_types/texture.py similarity index 97% rename from pillar/manage_extra/node_types/texture.py rename to pillar/api/node_types/texture.py index 020830d0..1787300f 100644 --- a/pillar/manage_extra/node_types/texture.py +++ b/pillar/api/node_types/texture.py @@ -1,4 +1,4 @@ -from manage_extra.node_types import _file_embedded_schema +from pillar.api.node_types import _file_embedded_schema node_type_texture = { 'name': 'texture', diff --git a/pillar/application/modules/nodes/__init__.py b/pillar/api/nodes/__init__.py similarity index 96% rename from pillar/application/modules/nodes/__init__.py rename to pillar/api/nodes/__init__.py index de44475e..07ba746e 100644 --- a/pillar/application/modules/nodes/__init__.py +++ b/pillar/api/nodes/__init__.py @@ -4,20 +4,19 @@ import urlparse import pymongo.errors import rsa.randnum +import werkzeug.exceptions as wz_exceptions from bson import ObjectId from flask import current_app, g, Blueprint, request -import werkzeug.exceptions as wz_exceptions - -from application.modules import file_storage -from application.utils import str2id, jsonify -from application.utils.authorization import check_permissions, require_login -from application.utils.gcs import update_file_name -from application.utils.activities import activity_subscribe, activity_object_add -from application.utils.algolia import algolia_index_node_delete -from application.utils.algolia import algolia_index_node_save +from pillar.api import file_storage +from pillar.api.activities import activity_subscribe, activity_object_add +from pillar.api.utils.algolia import algolia_index_node_delete +from pillar.api.utils.algolia import algolia_index_node_save +from pillar.api.utils import str2id, jsonify +from pillar.api.utils.authorization import check_permissions, require_login +from pillar.api.utils.gcs import update_file_name log = logging.getLogger(__name__) -blueprint = Blueprint('nodes', __name__) +blueprint = Blueprint('nodes_api', __name__) ROLES_FOR_SHARING = {u'subscriber', u'demo'} @@ -415,4 +414,4 @@ def setup_app(app, url_prefix): app.on_deleted_item_nodes += after_deleting_node - app.register_blueprint(blueprint, url_prefix=url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/modules/nodes/custom/__init__.py b/pillar/api/nodes/custom/__init__.py similarity index 100% rename from pillar/application/modules/nodes/custom/__init__.py rename to pillar/api/nodes/custom/__init__.py diff --git a/pillar/application/modules/nodes/custom/comment.py b/pillar/api/nodes/custom/comment.py similarity index 98% rename from pillar/application/modules/nodes/custom/comment.py rename to pillar/api/nodes/custom/comment.py index 139b39c1..e3794850 100644 --- a/pillar/application/modules/nodes/custom/comment.py +++ b/pillar/api/nodes/custom/comment.py @@ -1,10 +1,10 @@ """PATCH support for comment nodes.""" import logging -from flask import current_app import werkzeug.exceptions as wz_exceptions +from flask import current_app +from pillar.api.utils import authorization, authentication, jsonify -from application.utils import authorization, authentication, jsonify from . import register_patch_handler log = logging.getLogger(__name__) diff --git a/pillar/application/modules/nodes/patch.py b/pillar/api/nodes/patch.py similarity index 87% rename from pillar/application/modules/nodes/patch.py rename to pillar/api/nodes/patch.py index b94dd055..6f15dd29 100644 --- a/pillar/application/modules/nodes/patch.py +++ b/pillar/api/nodes/patch.py @@ -5,11 +5,11 @@ Depends on node_type-specific patch handlers in submodules. import logging -from flask import Blueprint, request import werkzeug.exceptions as wz_exceptions - -from application.utils import str2id -from application.utils import authorization, mongo, authentication +from flask import Blueprint, request +from pillar.api.utils import mongo +from pillar.api.utils import authorization, authentication +from pillar.api.utils import str2id from . import custom @@ -48,4 +48,4 @@ def patch_node(node_id): def setup_app(app, url_prefix): - app.register_blueprint(blueprint, url_prefix=url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/api/projects/__init__.py b/pillar/api/projects/__init__.py new file mode 100644 index 00000000..f4240736 --- /dev/null +++ b/pillar/api/projects/__init__.py @@ -0,0 +1,22 @@ +from . import hooks +from .routes import blueprint_api + + +def setup_app(app, api_prefix): + app.on_replace_projects += hooks.override_is_private_field + app.on_replace_projects += hooks.before_edit_check_permissions + app.on_replace_projects += hooks.protect_sensitive_fields + app.on_update_projects += hooks.override_is_private_field + app.on_update_projects += hooks.before_edit_check_permissions + app.on_update_projects += hooks.protect_sensitive_fields + app.on_delete_item_projects += hooks.before_delete_project + app.on_insert_projects += hooks.before_inserting_override_is_private_field + app.on_insert_projects += hooks.before_inserting_projects + app.on_inserted_projects += hooks.after_inserting_projects + + app.on_fetched_item_projects += hooks.before_returning_project_permissions + app.on_fetched_resource_projects += hooks.before_returning_project_resource_permissions + app.on_fetched_item_projects += hooks.project_node_type_has_method + app.on_fetched_resource_projects += hooks.projects_node_type_has_method + + app.register_api_blueprint(blueprint_api, url_prefix=api_prefix) diff --git a/pillar/api/projects/hooks.py b/pillar/api/projects/hooks.py new file mode 100644 index 00000000..0c0b028d --- /dev/null +++ b/pillar/api/projects/hooks.py @@ -0,0 +1,246 @@ +import copy +import logging + +from flask import request, abort, current_app +from gcloud import exceptions as gcs_exceptions +from pillar.api.node_types.asset import node_type_asset +from pillar.api.node_types.comment import node_type_comment +from pillar.api.node_types.group import node_type_group +from pillar.api.node_types.group_texture import node_type_group_texture +from pillar.api.node_types.texture import node_type_texture +from pillar.api.utils.gcs import GoogleCloudStorageBucket +from pillar.api.utils import authorization, authentication +from pillar.api.utils import remove_private_keys +from pillar.api.utils.authorization import user_has_role, check_permissions +from .utils import abort_with_error + +log = logging.getLogger(__name__) + +# Default project permissions for the admin group. +DEFAULT_ADMIN_GROUP_PERMISSIONS = ['GET', 'PUT', 'POST', 'DELETE'] + + +def before_inserting_projects(items): + """Strip unwanted properties, that will be assigned after creation. Also, + verify permission to create a project (check quota, check role). + + :param items: List of project docs that have been inserted (normally one) + """ + + # Allow admin users to do whatever they want. + if user_has_role(u'admin'): + return + + for item in items: + item.pop('url', None) + + +def override_is_private_field(project, original): + """Override the 'is_private' property from the world permissions. + + :param project: the project, which will be updated + """ + + # No permissions, no access. + if 'permissions' not in project: + project['is_private'] = True + return + + world_perms = project['permissions'].get('world', []) + is_private = 'GET' not in world_perms + project['is_private'] = is_private + + +def before_inserting_override_is_private_field(projects): + for project in projects: + override_is_private_field(project, None) + + +def before_edit_check_permissions(document, original): + # Allow admin users to do whatever they want. + # TODO: possibly move this into the check_permissions function. + if user_has_role(u'admin'): + return + + check_permissions('projects', original, request.method) + + +def before_delete_project(document): + """Checks permissions before we allow deletion""" + + # Allow admin users to do whatever they want. + # TODO: possibly move this into the check_permissions function. + if user_has_role(u'admin'): + return + + check_permissions('projects', document, request.method) + + +def protect_sensitive_fields(document, original): + """When not logged in as admin, prevents update to certain fields.""" + + # Allow admin users to do whatever they want. + if user_has_role(u'admin'): + return + + def revert(name): + if name not in original: + try: + del document[name] + except KeyError: + pass + return + document[name] = original[name] + + revert('status') + revert('category') + revert('user') + + if 'url' in original: + revert('url') + + +def after_inserting_projects(projects): + """After inserting a project in the collection we do some processing such as: + - apply the right permissions + - define basic node types + - optionally generate a url + - initialize storage space + + :param projects: List of project docs that have been inserted (normally one) + """ + + users_collection = current_app.data.driver.db['users'] + for project in projects: + owner_id = project.get('user', None) + owner = users_collection.find_one(owner_id) + after_inserting_project(project, owner) + + +def after_inserting_project(project, db_user): + project_id = project['_id'] + user_id = db_user['_id'] + + # Create a project-specific admin group (with name matching the project id) + result, _, _, status = current_app.post_internal('groups', {'name': str(project_id)}) + if status != 201: + log.error('Unable to create admin group for new project %s: %s', + project_id, result) + return abort_with_error(status) + + admin_group_id = result['_id'] + log.debug('Created admin group %s for project %s', admin_group_id, project_id) + + # Assign the current user to the group + db_user.setdefault('groups', []).append(admin_group_id) + + result, _, _, status = current_app.patch_internal('users', {'groups': db_user['groups']}, + _id=user_id) + if status != 200: + log.error('Unable to add user %s as member of admin group %s for new project %s: %s', + user_id, admin_group_id, project_id, result) + return abort_with_error(status) + log.debug('Made user %s member of group %s', user_id, admin_group_id) + + # Assign the group to the project with admin rights + is_admin = authorization.is_admin(db_user) + world_permissions = ['GET'] if is_admin else [] + permissions = { + 'world': world_permissions, + 'users': [], + 'groups': [ + {'group': admin_group_id, + 'methods': DEFAULT_ADMIN_GROUP_PERMISSIONS[:]}, + ] + } + + def with_permissions(node_type): + copied = copy.deepcopy(node_type) + copied['permissions'] = permissions + return copied + + # Assign permissions to the project itself, as well as to the node_types + project['permissions'] = permissions + project['node_types'] = [ + with_permissions(node_type_group), + with_permissions(node_type_asset), + with_permissions(node_type_comment), + with_permissions(node_type_texture), + with_permissions(node_type_group_texture), + ] + + # Allow admin users to use whatever url they want. + if not is_admin or not project.get('url'): + if project.get('category', '') == 'home': + project['url'] = 'home' + else: + project['url'] = "p-{!s}".format(project_id) + + # Initialize storage page (defaults to GCS) + if current_app.config.get('TESTING'): + log.warning('Not creating Google Cloud Storage bucket while running unit tests!') + else: + try: + gcs_storage = GoogleCloudStorageBucket(str(project_id)) + if gcs_storage.bucket.exists(): + log.info('Created GCS instance for project %s', project_id) + else: + log.warning('Unable to create GCS instance for project %s', project_id) + except gcs_exceptions.Forbidden as ex: + log.warning('GCS forbids me to create CGS instance for project %s: %s', project_id, ex) + + # Commit the changes directly to the MongoDB; a PUT is not allowed yet, + # as the project doesn't have a valid permission structure. + projects_collection = current_app.data.driver.db['projects'] + result = projects_collection.update_one({'_id': project_id}, + {'$set': remove_private_keys(project)}) + if result.matched_count != 1: + log.warning('Unable to update project %s: %s', project_id, result.raw_result) + abort_with_error(500) + + +def before_returning_project_permissions(response): + # Run validation process, since GET on nodes entry point is public + check_permissions('projects', response, 'GET', append_allowed_methods=True) + + +def before_returning_project_resource_permissions(response): + # Return only those projects the user has access to. + allow = [] + for project in response['_items']: + if authorization.has_permissions('projects', project, + 'GET', append_allowed_methods=True): + allow.append(project) + else: + log.debug('User %s requested project %s, but has no access to it; filtered out.', + authentication.current_user_id(), project['_id']) + + response['_items'] = allow + + +def project_node_type_has_method(response): + """Check for a specific request arg, and check generate the allowed_methods + list for the required node_type. + """ + + node_type_name = request.args.get('node_type', '') + + # Proceed only node_type has been requested + if not node_type_name: + return + + # Look up the node type in the project document + if not any(node_type.get('name') == node_type_name + for node_type in response['node_types']): + return abort(404) + + # Check permissions and append the allowed_methods to the node_type + check_permissions('projects', response, 'GET', append_allowed_methods=True, + check_node_type=node_type_name) + + +def projects_node_type_has_method(response): + for project in response['_items']: + project_node_type_has_method(project) + + diff --git a/pillar/api/projects/routes.py b/pillar/api/projects/routes.py new file mode 100644 index 00000000..e7bbe310 --- /dev/null +++ b/pillar/api/projects/routes.py @@ -0,0 +1,138 @@ +import json +import logging + +from bson import ObjectId +from flask import Blueprint, g, request, current_app, make_response, url_for +from pillar.api.utils import authorization, jsonify, str2id +from pillar.api.utils import mongo +from pillar.api.utils.authorization import require_login, check_permissions +from werkzeug import exceptions as wz_exceptions + +from . import utils + +log = logging.getLogger(__name__) + +blueprint_api = Blueprint('projects_api', __name__) + + +@blueprint_api.route('/create', methods=['POST']) +@authorization.require_login(require_roles={u'admin', u'subscriber', u'demo'}) +def create_project(overrides=None): + """Creates a new project.""" + + if request.mimetype == 'application/json': + project_name = request.json['name'] + else: + project_name = request.form['project_name'] + user_id = g.current_user['user_id'] + + project = utils.create_new_project(project_name, user_id, overrides) + + # Return the project in the response. + loc = url_for('projects|item_lookup', _id=project['_id']) + return jsonify(project, status=201, headers={'Location': loc}) + + +@blueprint_api.route('/users', methods=['GET', 'POST']) +@authorization.require_login() +def project_manage_users(): + """Manage users of a project. In this initial implementation, we handle + addition and removal of a user to the admin group of a project. + No changes are done on the project itself. + """ + + projects_collection = current_app.data.driver.db['projects'] + users_collection = current_app.data.driver.db['users'] + + # TODO: check if user is admin of the project before anything + if request.method == 'GET': + project_id = request.args['project_id'] + project = projects_collection.find_one({'_id': ObjectId(project_id)}) + admin_group_id = project['permissions']['groups'][0]['group'] + + users = users_collection.find( + {'groups': {'$in': [admin_group_id]}}, + {'username': 1, 'email': 1, 'full_name': 1}) + return jsonify({'_status': 'OK', '_items': list(users)}) + + # The request is not a form, since it comes from the API sdk + data = json.loads(request.data) + project_id = ObjectId(data['project_id']) + target_user_id = ObjectId(data['user_id']) + action = data['action'] + current_user_id = g.current_user['user_id'] + + project = projects_collection.find_one({'_id': project_id}) + + # Check if the current_user is owner of the project, or removing themselves. + remove_self = target_user_id == current_user_id and action == 'remove' + if project['user'] != current_user_id and not remove_self: + utils.abort_with_error(403) + + admin_group = utils.get_admin_group(project) + + # Get the user and add the admin group to it + if action == 'add': + operation = '$addToSet' + log.info('project_manage_users: Adding user %s to admin group of project %s', + target_user_id, project_id) + elif action == 'remove': + log.info('project_manage_users: Removing user %s from admin group of project %s', + target_user_id, project_id) + operation = '$pull' + else: + log.warning('project_manage_users: Unsupported action %r called by user %s', + action, current_user_id) + raise wz_exceptions.UnprocessableEntity() + + users_collection.update({'_id': target_user_id}, + {operation: {'groups': admin_group['_id']}}) + + user = users_collection.find_one({'_id': target_user_id}, + {'username': 1, 'email': 1, + 'full_name': 1}) + + if not user: + return jsonify({'_status': 'ERROR'}), 404 + + user['_status'] = 'OK' + return jsonify(user) + + +@blueprint_api.route('//quotas') +@require_login() +def project_quotas(project_id): + """Returns information about the project's limits.""" + + # Check that the user has GET permissions on the project itself. + project = mongo.find_one_or_404('projects', project_id) + check_permissions('projects', project, 'GET') + + file_size_used = utils.project_total_file_size(project_id) + + info = { + 'file_size_quota': None, # TODO: implement this later. + 'file_size_used': file_size_used, + } + + return jsonify(info) + + +@blueprint_api.route('//', methods=['OPTIONS', 'GET']) +def get_allowed_methods(project_id=None, node_type=None): + """Returns allowed methods to create a node of a certain type. + + Either project_id or parent_node_id must be given. If the latter is given, + the former is deducted from it. + """ + + project = mongo.find_one_or_404('projects', str2id(project_id)) + proj_methods = authorization.compute_allowed_methods('projects', project, node_type) + + resp = make_response() + resp.headers['Allowed'] = ', '.join(sorted(proj_methods)) + resp.status_code = 204 + + return resp + + diff --git a/pillar/api/projects/utils.py b/pillar/api/projects/utils.py new file mode 100644 index 00000000..a7c6df3e --- /dev/null +++ b/pillar/api/projects/utils.py @@ -0,0 +1,92 @@ +import logging + +from bson import ObjectId +from flask import current_app +from werkzeug import exceptions as wz_exceptions +from werkzeug.exceptions import abort + +log = logging.getLogger(__name__) + + +def project_total_file_size(project_id): + """Returns the total number of bytes used by files of this project.""" + + files = current_app.data.driver.db['files'] + file_size_used = files.aggregate([ + {'$match': {'project': ObjectId(project_id)}}, + {'$project': {'length_aggregate_in_bytes': 1}}, + {'$group': {'_id': None, + 'all_files': {'$sum': '$length_aggregate_in_bytes'}}} + ]) + + # The aggregate function returns a cursor, not a document. + try: + return next(file_size_used)['all_files'] + except StopIteration: + # No files used at all. + return 0 + + +def get_admin_group(project): + """Returns the admin group for the project.""" + + groups_collection = current_app.data.driver.db['groups'] + + # TODO: search through all groups to find the one with the project ID as its name. + admin_group_id = ObjectId(project['permissions']['groups'][0]['group']) + group = groups_collection.find_one({'_id': admin_group_id}) + + if group is None: + raise ValueError('Unable to handle project without admin group.') + + if group['name'] != str(project['_id']): + return abort_with_error(403) + + return group + + +def abort_with_error(status): + """Aborts with the given status, or 500 if the status doesn't indicate an error. + + If the status is < 400, status 500 is used instead. + """ + + abort(status if status // 100 >= 4 else 500) + raise wz_exceptions.InternalServerError('abort() should have aborted!') + + +def create_new_project(project_name, user_id, overrides): + """Creates a new project owned by the given user.""" + + log.info('Creating new project "%s" for user %s', project_name, user_id) + + # Create the project itself, the rest will be done by the after-insert hook. + project = {'description': '', + 'name': project_name, + 'node_types': [], + 'status': 'published', + 'user': user_id, + 'is_private': True, + 'permissions': {}, + 'url': '', + 'summary': '', + 'category': 'assets', # TODO: allow the user to choose this. + } + if overrides is not None: + project.update(overrides) + + result, _, _, status = current_app.post_internal('projects', project) + if status != 201: + log.error('Unable to create project "%s": %s', project_name, result) + return abort_with_error(status) + project.update(result) + + # Now re-fetch the project, as both the initial document and the returned + # result do not contain the same etag as the database. This also updates + # other fields set by hooks. + document = current_app.data.driver.db['projects'].find_one(project['_id']) + project.update(document) + + log.info('Created project %s for user %s', project['_id'], user_id) + + return project diff --git a/pillar/application/modules/service.py b/pillar/api/service.py similarity index 94% rename from pillar/application/modules/service.py rename to pillar/api/service.py index 11ff96fb..c6c14a99 100644 --- a/pillar/application/modules/service.py +++ b/pillar/api/service.py @@ -3,12 +3,12 @@ import logging import blinker -from flask import Blueprint, current_app, g, request +from flask import Blueprint, current_app, request +from pillar.api import local_auth +from pillar.api.utils import mongo +from pillar.api.utils import authorization, authentication, str2id, jsonify from werkzeug import exceptions as wz_exceptions -from application.utils import authorization, authentication, str2id, mongo, jsonify -from application.modules import local_auth - blueprint = Blueprint('service', __name__) log = logging.getLogger(__name__) signal_user_changed_role = blinker.NamedSignal('badger:user_changed_role') @@ -172,7 +172,6 @@ def create_service_account(email, roles, service): :type service: dict :return: tuple (user doc, token doc) """ - from eve.methods.post import post_internal # Create a user with the correct roles. roles = list(set(roles).union({u'service'})) @@ -184,7 +183,7 @@ def create_service_account(email, roles, service): 'full_name': email, 'email': email, 'service': service} - result, _, _, status = post_internal('users', user) + result, _, _, status = current_app.post_internal('users', user) if status != 201: raise SystemExit('Error creating user {}: {}'.format(email, result)) user.update(result) @@ -195,5 +194,5 @@ def create_service_account(email, roles, service): return user, token -def setup_app(app, url_prefix): - app.register_blueprint(blueprint, url_prefix=url_prefix) +def setup_app(app, api_prefix): + app.register_api_blueprint(blueprint, url_prefix=api_prefix) diff --git a/pillar/api/users/__init__.py b/pillar/api/users/__init__.py new file mode 100644 index 00000000..15289df8 --- /dev/null +++ b/pillar/api/users/__init__.py @@ -0,0 +1,15 @@ +from . import hooks +from .routes import blueprint_api + + +def setup_app(app, api_prefix): + app.on_pre_GET_users += hooks.check_user_access + app.on_post_GET_users += hooks.post_GET_user + app.on_pre_PUT_users += hooks.check_put_access + app.on_pre_PUT_users += hooks.before_replacing_user + app.on_replaced_users += hooks.push_updated_user_to_algolia + app.on_replaced_users += hooks.send_blinker_signal_roles_changed + app.on_fetched_item_users += hooks.after_fetching_user + app.on_fetched_resource_users += hooks.after_fetching_user_resource + + app.register_api_blueprint(blueprint_api, url_prefix=api_prefix) diff --git a/pillar/application/modules/users.py b/pillar/api/users/hooks.py similarity index 72% rename from pillar/application/modules/users.py rename to pillar/api/users/hooks.py index 61dcea2c..68468f37 100644 --- a/pillar/application/modules/users.py +++ b/pillar/api/users/hooks.py @@ -1,45 +1,11 @@ import copy -import hashlib import json -import logging -import urllib -from flask import g, current_app, Blueprint - -from werkzeug.exceptions import Forbidden from eve.utils import parse_request -from eve.methods.get import get - -from application.utils.authorization import user_has_role, require_login -from application.utils import jsonify - -log = logging.getLogger(__name__) -blueprint = Blueprint('users', __name__) - - -@blueprint.route('/me') -@require_login() -def my_info(): - eve_resp, _, _, status, _ = get('users', {'_id': g.current_user['user_id']}) - resp = jsonify(eve_resp['_items'][0], status=status) - return resp - - -def gravatar(email, size=64): - parameters = {'s': str(size), 'd': 'mm'} - return "https://www.gravatar.com/avatar/" + \ - hashlib.md5(str(email)).hexdigest() + \ - "?" + urllib.urlencode(parameters) - - -def post_GET_user(request, payload): - json_data = json.loads(payload.data) - # Check if we are querying the users endpoint (instead of the single user) - if json_data.get('_id') is None: - return - # json_data['computed_permissions'] = \ - # compute_permissions(json_data['_id'], app.data.driver) - payload.data = json.dumps(json_data) +from flask import current_app, g +from pillar.api.users.routes import log +from pillar.api.utils.authorization import user_has_role +from werkzeug.exceptions import Forbidden def before_replacing_user(request, lookup): @@ -64,7 +30,7 @@ def push_updated_user_to_algolia(user, original): """Push an update to the Algolia index when a user item is updated""" from algoliasearch.client import AlgoliaException - from application.utils.algolia import algolia_index_user_save + from pillar.api.utils.algolia import algolia_index_user_save try: algolia_index_user_save(user) @@ -79,7 +45,7 @@ def send_blinker_signal_roles_changed(user, original): if user.get('roles') == original.get('roles'): return - from application.modules.service import signal_user_changed_role + from pillar.api.service import signal_user_changed_role log.info('User %s changed roles to %s, sending Blinker signal', user.get('_id'), user.get('roles')) @@ -147,14 +113,11 @@ def after_fetching_user_resource(response): after_fetching_user(user) -def setup_app(app, url_prefix): - app.on_pre_GET_users += check_user_access - app.on_post_GET_users += post_GET_user - app.on_pre_PUT_users += check_put_access - app.on_pre_PUT_users += before_replacing_user - app.on_replaced_users += push_updated_user_to_algolia - app.on_replaced_users += send_blinker_signal_roles_changed - app.on_fetched_item_users += after_fetching_user - app.on_fetched_resource_users += after_fetching_user_resource - - app.register_blueprint(blueprint, url_prefix=url_prefix) +def post_GET_user(request, payload): + json_data = json.loads(payload.data) + # Check if we are querying the users endpoint (instead of the single user) + if json_data.get('_id') is None: + return + # json_data['computed_permissions'] = \ + # compute_permissions(json_data['_id'], app.data.driver) + payload.data = json.dumps(json_data) diff --git a/pillar/api/users/routes.py b/pillar/api/users/routes.py new file mode 100644 index 00000000..11ad0a14 --- /dev/null +++ b/pillar/api/users/routes.py @@ -0,0 +1,19 @@ +import logging + +from eve.methods.get import get +from flask import g, Blueprint +from pillar.api.utils import jsonify +from pillar.api.utils.authorization import require_login + +log = logging.getLogger(__name__) +blueprint_api = Blueprint('users_api', __name__) + + +@blueprint_api.route('/me') +@require_login() +def my_info(): + eve_resp, _, _, status, _ = get('users', {'_id': g.current_user['user_id']}) + resp = jsonify(eve_resp['_items'][0], status=status) + return resp + + diff --git a/pillar/application/utils/__init__.py b/pillar/api/utils/__init__.py similarity index 92% rename from pillar/application/utils/__init__.py rename to pillar/api/utils/__init__.py index 65400912..c3d1c19e 100644 --- a/pillar/application/utils/__init__.py +++ b/pillar/api/utils/__init__.py @@ -1,5 +1,8 @@ import copy +import hashlib import json +import urllib + import datetime import functools import logging @@ -104,3 +107,10 @@ def str2id(document_id): except bson.objectid.InvalidId: log.debug('str2id(%r): Invalid Object ID', document_id) raise wz_exceptions.BadRequest('Invalid object ID %r' % document_id) + + +def gravatar(email, size=64): + parameters = {'s': str(size), 'd': 'mm'} + return "https://www.gravatar.com/avatar/" + \ + hashlib.md5(str(email)).hexdigest() + \ + "?" + urllib.urlencode(parameters) \ No newline at end of file diff --git a/pillar/api/utils/algolia.py b/pillar/api/utils/algolia.py new file mode 100644 index 00000000..60eb1a6f --- /dev/null +++ b/pillar/api/utils/algolia.py @@ -0,0 +1,98 @@ +import logging + +from bson import ObjectId +from flask import current_app + +from pillar.api.file_storage import generate_link +from . import skip_when_testing + +log = logging.getLogger(__name__) + +INDEX_ALLOWED_USER_ROLES = {'admin', 'subscriber', 'demo'} +INDEX_ALLOWED_NODE_TYPES = {'asset', 'texture', 'group', 'hdri'} + + +@skip_when_testing +def algolia_index_user_save(user): + if current_app.algolia_index_users is None: + return + # Strip unneeded roles + if 'roles' in user: + roles = set(user['roles']).intersection(INDEX_ALLOWED_USER_ROLES) + else: + roles = set() + if current_app.algolia_index_users: + # Create or update Algolia index for the user + current_app.algolia_index_users.save_object({ + 'objectID': user['_id'], + 'full_name': user['full_name'], + 'username': user['username'], + 'roles': list(roles), + 'groups': user['groups'], + 'email': user['email'] + }) + + +@skip_when_testing +def algolia_index_node_save(node): + if not current_app.algolia_index_nodes: + return + if node['node_type'] not in INDEX_ALLOWED_NODE_TYPES: + return + # If a nodes does not have status published, do not index + if node['properties'].get('status') != 'published': + return + + projects_collection = current_app.data.driver.db['projects'] + project = projects_collection.find_one({'_id': ObjectId(node['project'])}) + + users_collection = current_app.data.driver.db['users'] + user = users_collection.find_one({'_id': ObjectId(node['user'])}) + + node_ob = { + 'objectID': node['_id'], + 'name': node['name'], + 'project': { + '_id': project['_id'], + 'name': project['name'] + }, + 'created': node['_created'], + 'updated': node['_updated'], + 'node_type': node['node_type'], + 'user': { + '_id': user['_id'], + 'full_name': user['full_name'] + }, + } + if 'description' in node and node['description']: + node_ob['description'] = node['description'] + if 'picture' in node and node['picture']: + files_collection = current_app.data.driver.db['files'] + lookup = {'_id': ObjectId(node['picture'])} + picture = files_collection.find_one(lookup) + if picture['backend'] == 'gcs': + variation_t = next((item for item in picture['variations'] \ + if item['size'] == 't'), None) + if variation_t: + node_ob['picture'] = generate_link(picture['backend'], + variation_t['file_path'], project_id=str(picture['project']), + is_public=True) + # If the node has world permissions, compute the Free permission + if 'permissions' in node and 'world' in node['permissions']: + if 'GET' in node['permissions']['world']: + node_ob['is_free'] = True + # Append the media key if the node is of node_type 'asset' + if node['node_type'] == 'asset': + node_ob['media'] = node['properties']['content_type'] + # Add tags + if 'tags' in node['properties']: + node_ob['tags'] = node['properties']['tags'] + + current_app.algolia_index_nodes.save_object(node_ob) + + +@skip_when_testing +def algolia_index_node_delete(node): + if current_app.algolia_index_nodes is None: + return + current_app.algolia_index_nodes.delete_object(node['_id']) diff --git a/pillar/application/utils/authentication.py b/pillar/api/utils/authentication.py similarity index 82% rename from pillar/application/utils/authentication.py rename to pillar/api/utils/authentication.py index 3f9b20e4..407aa2e5 100644 --- a/pillar/application/utils/authentication.py +++ b/pillar/api/utils/authentication.py @@ -1,7 +1,7 @@ """Generic authentication. Contains functionality to validate tokens, create users and tokens, and make -unique usernames from emails. Calls out to the application.modules.blender_id +unique usernames from emails. Calls out to the pillar_server.modules.blender_id module for Blender ID communication. """ @@ -12,7 +12,6 @@ from bson import tz_util from flask import g from flask import request from flask import current_app -from eve.methods.post import post_internal log = logging.getLogger(__name__) @@ -28,21 +27,39 @@ def validate_token(): @returns True iff the user is logged in with a valid Blender ID token. """ - # Default to no user at all. - g.current_user = None + if request.authorization: + token = request.authorization.username + oauth_subclient = request.authorization.password + else: + # Check the session, the user might be logged in through Flask-Login. + from pillar import auth - _delete_expired_tokens() + token = auth.get_blender_id_oauth_token() + if token and isinstance(token, (tuple, list)): + token = token[0] + oauth_subclient = None - if not request.authorization: + if not token: # If no authorization headers are provided, we are getting a request # from a non logged in user. Proceed accordingly. log.debug('No authentication headers, so not logged in.') + g.current_user = None return False - # Check the users to see if there is one with this Blender ID token. - token = request.authorization.username - oauth_subclient = request.authorization.password + return validate_this_token(token, oauth_subclient) is not None + +def validate_this_token(token, oauth_subclient=None): + """Validates a given token, and sets g.current_user. + + :returns: the user in MongoDB, or None if not a valid token. + :rtype: dict + """ + + g.current_user = None + _delete_expired_tokens() + + # Check the users to see if there is one with this Blender ID token. db_token = find_token(token, oauth_subclient) if not db_token: log.debug('Token %s not found in our local database.', token) @@ -51,7 +68,7 @@ def validate_token(): # request to the Blender ID server to verify the validity of the token # passed via the HTTP header. We will get basic user info if the user # is authorized, and we will store the token in our local database. - from application.modules import blender_id + from pillar.api import blender_id db_user, status = blender_id.validate_create_user('', token, oauth_subclient) else: @@ -61,13 +78,13 @@ def validate_token(): if db_user is None: log.debug('Validation failed, user not logged in') - return False + return None g.current_user = {'user_id': db_user['_id'], 'groups': db_user['groups'], 'roles': set(db_user.get('roles', []))} - return True + return db_user def find_token(token, is_subclient_token=False, **extra_filters): @@ -91,6 +108,8 @@ def store_token(user_id, token, token_expiry, oauth_subclient_id=False): :returns: the token document from MongoDB """ + assert isinstance(token, (str, unicode)), 'token must be string type, not %r' % type(token) + token_data = { 'user': user_id, 'token': token, @@ -99,7 +118,7 @@ def store_token(user_id, token, token_expiry, oauth_subclient_id=False): if oauth_subclient_id: token_data['is_subclient_token'] = True - r, _, _, status = post_internal('tokens', token_data) + r, _, _, status = current_app.post_internal('tokens', token_data) if status not in {200, 201}: log.error('Unable to store authentication token: %s', r) @@ -119,7 +138,7 @@ def create_new_user(email, username, user_id): """ user_data = create_new_user_document(email, user_id, username) - r = post_internal('users', user_data) + r = current_app.post_internal('users', user_data) user_id = r[0]['_id'] return user_id @@ -196,3 +215,10 @@ def current_user_id(): current_user = g.get('current_user') or {} return current_user.get('user_id') + + +def setup_app(app): + @app.before_request + def validate_token_at_each_request(): + validate_token() + return None diff --git a/pillar/application/utils/authorization.py b/pillar/api/utils/authorization.py similarity index 100% rename from pillar/application/utils/authorization.py rename to pillar/api/utils/authorization.py diff --git a/pillar/application/utils/cdn.py b/pillar/api/utils/cdn.py similarity index 100% rename from pillar/application/utils/cdn.py rename to pillar/api/utils/cdn.py diff --git a/pillar/application/utils/encoding.py b/pillar/api/utils/encoding.py similarity index 75% rename from pillar/application/utils/encoding.py rename to pillar/api/utils/encoding.py index 68688273..d36b54db 100644 --- a/pillar/application/utils/encoding.py +++ b/pillar/api/utils/encoding.py @@ -3,8 +3,6 @@ import os from flask import current_app -from application import encoding_service_client - log = logging.getLogger(__name__) @@ -18,7 +16,7 @@ class Encoder: """Create an encoding job. Return the backend used as well as an id. """ if current_app.config['ENCODING_BACKEND'] != 'zencoder' or \ - encoding_service_client is None: + current_app.encoding_service_client is None: log.error('I can only work with Zencoder, check the config file.') return None @@ -35,9 +33,9 @@ class Encoder: outputs = [{'format': v['format'], 'url': os.path.join(storage_base, v['file_path'])} for v in src_file['variations']] - r = encoding_service_client.job.create(file_input, - outputs=outputs, - options=options) + r = current_app.encoding_service_client.job.create(file_input, + outputs=outputs, + options=options) if r.code != 201: log.error('Error %i creating Zencoder job: %s', r.code, r.body) return None @@ -47,8 +45,10 @@ class Encoder: @staticmethod def job_progress(job_id): - if isinstance(encoding_service_client, Zencoder): - r = encoding_service_client.job.progress(int(job_id)) + from zencoder import Zencoder + + if isinstance(current_app.encoding_service_client, Zencoder): + r = current_app.encoding_service_client.job.progress(int(job_id)) return r.body else: return None diff --git a/pillar/application/utils/gcs.py b/pillar/api/utils/gcs.py similarity index 99% rename from pillar/application/utils/gcs.py rename to pillar/api/utils/gcs.py index 058f225d..54452429 100644 --- a/pillar/application/utils/gcs.py +++ b/pillar/api/utils/gcs.py @@ -16,8 +16,6 @@ def get_client(): """Stores the GCS client on the global Flask object. The GCS client is not user-specific anyway. - - :rtype: Client """ _gcs = getattr(g, '_gcs_client', None) diff --git a/pillar/application/utils/imaging.py b/pillar/api/utils/imaging.py similarity index 100% rename from pillar/application/utils/imaging.py rename to pillar/api/utils/imaging.py diff --git a/pillar/application/utils/mongo.py b/pillar/api/utils/mongo.py similarity index 100% rename from pillar/application/utils/mongo.py rename to pillar/api/utils/mongo.py diff --git a/pillar/application/utils/storage.py b/pillar/api/utils/storage.py similarity index 98% rename from pillar/application/utils/storage.py rename to pillar/api/utils/storage.py index cbc7f35e..f5e44a5e 100644 --- a/pillar/application/utils/storage.py +++ b/pillar/api/utils/storage.py @@ -1,8 +1,8 @@ -import os import subprocess +import os from flask import current_app -from application.utils.gcs import GoogleCloudStorageBucket +from pillar.api.utils.gcs import GoogleCloudStorageBucket def get_sizedata(filepath): diff --git a/pillar/application/__init__.py b/pillar/application/__init__.py deleted file mode 100644 index ed3c01ca..00000000 --- a/pillar/application/__init__.py +++ /dev/null @@ -1,268 +0,0 @@ -import logging.config -import os -import subprocess -import tempfile -from bson import ObjectId -from datetime import datetime -from flask import g -from flask import request -from flask import abort -from eve import Eve - -from eve.auth import TokenAuth -from eve.io.mongo import Validator - -from application.utils import project_get_node_type - -RFC1123_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' - - -class ValidateCustomFields(Validator): - def convert_properties(self, properties, node_schema): - for prop in node_schema: - if not prop in properties: - continue - schema_prop = node_schema[prop] - prop_type = schema_prop['type'] - if prop_type == 'dict': - properties[prop] = self.convert_properties( - properties[prop], schema_prop['schema']) - if prop_type == 'list': - if properties[prop] in ['', '[]']: - properties[prop] = [] - for k, val in enumerate(properties[prop]): - if not 'schema' in schema_prop: - continue - item_schema = {'item': schema_prop['schema']} - item_prop = {'item': properties[prop][k]} - properties[prop][k] = self.convert_properties( - item_prop, item_schema)['item'] - # Convert datetime string to RFC1123 datetime - elif prop_type == 'datetime': - prop_val = properties[prop] - properties[prop] = datetime.strptime(prop_val, RFC1123_DATE_FORMAT) - elif prop_type == 'objectid': - prop_val = properties[prop] - if prop_val: - properties[prop] = ObjectId(prop_val) - else: - properties[prop] = None - - return properties - - def _validate_valid_properties(self, valid_properties, field, value): - projects_collection = app.data.driver.db['projects'] - lookup = {'_id': ObjectId(self.document['project'])} - - project = projects_collection.find_one(lookup, { - 'node_types.name': 1, - 'node_types.dyn_schema': 1, - }) - if project is None: - log.warning('Unknown project %s, declared by node %s', - lookup, self.document.get('_id')) - self._error(field, 'Unknown project') - return False - - node_type_name = self.document['node_type'] - node_type = project_get_node_type(project, node_type_name) - if node_type is None: - log.warning('Project %s has no node type %s, declared by node %s', - project, node_type_name, self.document.get('_id')) - self._error(field, 'Unknown node type') - return False - - try: - value = self.convert_properties(value, node_type['dyn_schema']) - except Exception as e: - log.warning("Error converting form properties", exc_info=True) - - v = Validator(node_type['dyn_schema']) - val = v.validate(value) - - if val: - return True - - log.warning('Error validating properties for node %s: %s', self.document, v.errors) - self._error(field, "Error validating properties") - - -# We specify a settings.py file because when running on wsgi we can't detect it -# automatically. The default path (which works in Docker) can be overridden with -# an env variable. -settings_path = os.environ.get( - 'EVE_SETTINGS', '/data/git/pillar/pillar/settings.py') -app = Eve(settings=settings_path, validator=ValidateCustomFields) - -# Load configuration from three different sources, to make it easy to override -# settings with secrets, as well as for development & testing. -app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -app.config.from_pyfile(os.path.join(app_root, 'config.py'), silent=False) -app.config.from_pyfile(os.path.join(app_root, 'config_local.py'), silent=True) -from_envvar = os.environ.get('PILLAR_CONFIG') -if from_envvar: - # Don't use from_envvar, as we want different behaviour. If the envvar - # is not set, it's fine (i.e. silent=True), but if it is set and the - # configfile doesn't exist, it should error out (i.e. silent=False). - app.config.from_pyfile(from_envvar, silent=False) - -# Set the TMP environment variable to manage where uploads are stored. -# These are all used by tempfile.mkstemp(), but we don't knwow in whic -# order. As such, we remove all used variables but the one we set. -tempfile.tempdir = app.config['STORAGE_DIR'] -os.environ['TMP'] = app.config['STORAGE_DIR'] -os.environ.pop('TEMP', None) -os.environ.pop('TMPDIR', None) - - -# Configure logging -logging.config.dictConfig(app.config['LOGGING']) -log = logging.getLogger(__name__) -if app.config['DEBUG']: - log.info('Pillar starting, debug=%s', app.config['DEBUG']) - -# Get the Git hash -try: - git_cmd = ['git', '-C', app_root, 'describe', '--always'] - description = subprocess.check_output(git_cmd) - app.config['GIT_REVISION'] = description.strip() -except (subprocess.CalledProcessError, OSError) as ex: - log.warning('Unable to run "git describe" to get git revision: %s', ex) - app.config['GIT_REVISION'] = 'unknown' -log.info('Git revision %r', app.config['GIT_REVISION']) - -# Configure Bugsnag -if not app.config.get('TESTING') and app.config.get('BUGSNAG_API_KEY'): - import bugsnag - import bugsnag.flask - import bugsnag.handlers - - bugsnag.configure( - api_key=app.config['BUGSNAG_API_KEY'], - project_root="/data/git/pillar/pillar", - ) - bugsnag.flask.handle_exceptions(app) - - bs_handler = bugsnag.handlers.BugsnagHandler() - bs_handler.setLevel(logging.ERROR) - log.addHandler(bs_handler) -else: - log.info('Bugsnag NOT configured.') - -# Google Cloud project -try: - os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = \ - app.config['GCLOUD_APP_CREDENTIALS'] -except KeyError: - raise SystemExit('GCLOUD_APP_CREDENTIALS configuration is missing') - -# Storage backend (GCS) -try: - os.environ['GCLOUD_PROJECT'] = app.config['GCLOUD_PROJECT'] -except KeyError: - raise SystemExit('GCLOUD_PROJECT configuration value is missing') - -# Algolia search -if app.config['SEARCH_BACKEND'] == 'algolia': - from algoliasearch import algoliasearch - - client = algoliasearch.Client( - app.config['ALGOLIA_USER'], - app.config['ALGOLIA_API_KEY']) - algolia_index_users = client.init_index(app.config['ALGOLIA_INDEX_USERS']) - algolia_index_nodes = client.init_index(app.config['ALGOLIA_INDEX_NODES']) -else: - algolia_index_users = None - algolia_index_nodes = None - -# Encoding backend -if app.config['ENCODING_BACKEND'] == 'zencoder': - from zencoder import Zencoder - encoding_service_client = Zencoder(app.config['ZENCODER_API_KEY']) -else: - encoding_service_client = None - -from utils.authentication import validate_token -from utils.authorization import check_permissions -from utils.activities import notification_parse -from modules.projects import before_inserting_projects -from modules.projects import after_inserting_projects - - -@app.before_request -def validate_token_at_every_request(): - validate_token() - - -def before_returning_item_notifications(response): - if request.args.get('parse'): - notification_parse(response) - - -def before_returning_resource_notifications(response): - for item in response['_items']: - if request.args.get('parse'): - notification_parse(item) - - -app.on_fetched_item_notifications += before_returning_item_notifications -app.on_fetched_resource_notifications += before_returning_resource_notifications - - -@app.before_first_request -def setup_db_indices(): - """Adds missing database indices. - - This does NOT drop and recreate existing indices, - nor does it reconfigure existing indices. - If you want that, drop them manually first. - """ - - log.debug('Adding missing database indices.') - - import pymongo - - db = app.data.driver.db - - coll = db['tokens'] - coll.create_index([('user', pymongo.ASCENDING)]) - coll.create_index([('token', pymongo.ASCENDING)]) - - coll = db['notifications'] - coll.create_index([('user', pymongo.ASCENDING)]) - - coll = db['activities-subscriptions'] - coll.create_index([('context_object', pymongo.ASCENDING)]) - - coll = db['nodes'] - # This index is used for queries on project, and for queries on - # the combination (project, node type). - coll.create_index([('project', pymongo.ASCENDING), - ('node_type', pymongo.ASCENDING)]) - coll.create_index([('parent', pymongo.ASCENDING)]) - coll.create_index([('short_code', pymongo.ASCENDING)], - sparse=True, unique=True) - - -# The encoding module (receive notification and report progress) -from modules.encoding import encoding -from modules.blender_id import blender_id -from modules import projects -from modules import local_auth -from modules import file_storage -from modules import users -from modules import nodes -from modules import latest -from modules import blender_cloud -from modules import service - -app.register_blueprint(encoding, url_prefix='/encoding') -app.register_blueprint(blender_id, url_prefix='/blender_id') -projects.setup_app(app, url_prefix='/p') -local_auth.setup_app(app, url_prefix='/auth') -file_storage.setup_app(app, url_prefix='/storage') -latest.setup_app(app, url_prefix='/latest') -blender_cloud.setup_app(app, url_prefix='/bcloud') -users.setup_app(app, url_prefix='/users') -service.setup_app(app, url_prefix='/service') -nodes.setup_app(app, url_prefix='/nodes') diff --git a/pillar/application/modules/projects.py b/pillar/application/modules/projects.py deleted file mode 100644 index ecbc4687..00000000 --- a/pillar/application/modules/projects.py +++ /dev/null @@ -1,472 +0,0 @@ -import copy -import logging -import json - -from bson import ObjectId -from eve.methods.post import post_internal -from eve.methods.patch import patch_internal -from flask import g, Blueprint, request, abort, current_app, make_response -from gcloud import exceptions as gcs_exceptions -from werkzeug import exceptions as wz_exceptions - -from application.utils import remove_private_keys, jsonify, mongo, str2id -from application.utils import authorization, authentication -from application.utils.gcs import GoogleCloudStorageBucket -from application.utils.authorization import user_has_role, check_permissions, require_login -from manage_extra.node_types.asset import node_type_asset -from manage_extra.node_types.comment import node_type_comment -from manage_extra.node_types.group import node_type_group -from manage_extra.node_types.texture import node_type_texture -from manage_extra.node_types.group_texture import node_type_group_texture - -log = logging.getLogger(__name__) -blueprint = Blueprint('projects', __name__) - -# Default project permissions for the admin group. -DEFAULT_ADMIN_GROUP_PERMISSIONS = ['GET', 'PUT', 'POST', 'DELETE'] - - -def before_inserting_projects(items): - """Strip unwanted properties, that will be assigned after creation. Also, - verify permission to create a project (check quota, check role). - - :param items: List of project docs that have been inserted (normally one) - """ - - # Allow admin users to do whatever they want. - if user_has_role(u'admin'): - return - - for item in items: - item.pop('url', None) - - -def override_is_private_field(project, original): - """Override the 'is_private' property from the world permissions. - - :param project: the project, which will be updated - """ - - # No permissions, no access. - if 'permissions' not in project: - project['is_private'] = True - return - - world_perms = project['permissions'].get('world', []) - is_private = 'GET' not in world_perms - project['is_private'] = is_private - - -def before_inserting_override_is_private_field(projects): - for project in projects: - override_is_private_field(project, None) - - -def before_edit_check_permissions(document, original): - # Allow admin users to do whatever they want. - # TODO: possibly move this into the check_permissions function. - if user_has_role(u'admin'): - return - - check_permissions('projects', original, request.method) - - -def before_delete_project(document): - """Checks permissions before we allow deletion""" - - # Allow admin users to do whatever they want. - # TODO: possibly move this into the check_permissions function. - if user_has_role(u'admin'): - return - - check_permissions('projects', document, request.method) - - -def protect_sensitive_fields(document, original): - """When not logged in as admin, prevents update to certain fields.""" - - # Allow admin users to do whatever they want. - if user_has_role(u'admin'): - return - - def revert(name): - if name not in original: - try: - del document[name] - except KeyError: - pass - return - document[name] = original[name] - - revert('status') - revert('category') - revert('user') - - if 'url' in original: - revert('url') - - -def after_inserting_projects(projects): - """After inserting a project in the collection we do some processing such as: - - apply the right permissions - - define basic node types - - optionally generate a url - - initialize storage space - - :param projects: List of project docs that have been inserted (normally one) - """ - - users_collection = current_app.data.driver.db['users'] - for project in projects: - owner_id = project.get('user', None) - owner = users_collection.find_one(owner_id) - after_inserting_project(project, owner) - - -def after_inserting_project(project, db_user): - project_id = project['_id'] - user_id = db_user['_id'] - - # Create a project-specific admin group (with name matching the project id) - result, _, _, status = post_internal('groups', {'name': str(project_id)}) - if status != 201: - log.error('Unable to create admin group for new project %s: %s', - project_id, result) - return abort_with_error(status) - - admin_group_id = result['_id'] - log.debug('Created admin group %s for project %s', admin_group_id, project_id) - - # Assign the current user to the group - db_user.setdefault('groups', []).append(admin_group_id) - - result, _, _, status = patch_internal('users', {'groups': db_user['groups']}, _id=user_id) - if status != 200: - log.error('Unable to add user %s as member of admin group %s for new project %s: %s', - user_id, admin_group_id, project_id, result) - return abort_with_error(status) - log.debug('Made user %s member of group %s', user_id, admin_group_id) - - # Assign the group to the project with admin rights - is_admin = authorization.is_admin(db_user) - world_permissions = ['GET'] if is_admin else [] - permissions = { - 'world': world_permissions, - 'users': [], - 'groups': [ - {'group': admin_group_id, - 'methods': DEFAULT_ADMIN_GROUP_PERMISSIONS[:]}, - ] - } - - def with_permissions(node_type): - copied = copy.deepcopy(node_type) - copied['permissions'] = permissions - return copied - - # Assign permissions to the project itself, as well as to the node_types - project['permissions'] = permissions - project['node_types'] = [ - with_permissions(node_type_group), - with_permissions(node_type_asset), - with_permissions(node_type_comment), - with_permissions(node_type_texture), - with_permissions(node_type_group_texture), - ] - - # Allow admin users to use whatever url they want. - if not is_admin or not project.get('url'): - if project.get('category', '') == 'home': - project['url'] = 'home' - else: - project['url'] = "p-{!s}".format(project_id) - - # Initialize storage page (defaults to GCS) - if current_app.config.get('TESTING'): - log.warning('Not creating Google Cloud Storage bucket while running unit tests!') - else: - try: - gcs_storage = GoogleCloudStorageBucket(str(project_id)) - if gcs_storage.bucket.exists(): - log.info('Created GCS instance for project %s', project_id) - else: - log.warning('Unable to create GCS instance for project %s', project_id) - except gcs_exceptions.Forbidden as ex: - log.warning('GCS forbids me to create CGS instance for project %s: %s', project_id, ex) - - # Commit the changes directly to the MongoDB; a PUT is not allowed yet, - # as the project doesn't have a valid permission structure. - projects_collection = current_app.data.driver.db['projects'] - result = projects_collection.update_one({'_id': project_id}, - {'$set': remove_private_keys(project)}) - if result.matched_count != 1: - log.warning('Unable to update project %s: %s', project_id, result.raw_result) - abort_with_error(500) - - -def create_new_project(project_name, user_id, overrides): - """Creates a new project owned by the given user.""" - - log.info('Creating new project "%s" for user %s', project_name, user_id) - - # Create the project itself, the rest will be done by the after-insert hook. - project = {'description': '', - 'name': project_name, - 'node_types': [], - 'status': 'published', - 'user': user_id, - 'is_private': True, - 'permissions': {}, - 'url': '', - 'summary': '', - 'category': 'assets', # TODO: allow the user to choose this. - } - if overrides is not None: - project.update(overrides) - - result, _, _, status = post_internal('projects', project) - if status != 201: - log.error('Unable to create project "%s": %s', project_name, result) - return abort_with_error(status) - project.update(result) - - # Now re-fetch the project, as both the initial document and the returned - # result do not contain the same etag as the database. This also updates - # other fields set by hooks. - document = current_app.data.driver.db['projects'].find_one(project['_id']) - project.update(document) - - log.info('Created project %s for user %s', project['_id'], user_id) - - return project - - -@blueprint.route('/create', methods=['POST']) -@authorization.require_login(require_roles={u'admin', u'subscriber', u'demo'}) -def create_project(overrides=None): - """Creates a new project.""" - - if request.mimetype == 'application/json': - project_name = request.json['name'] - else: - project_name = request.form['project_name'] - user_id = g.current_user['user_id'] - - project = create_new_project(project_name, user_id, overrides) - - # Return the project in the response. - return jsonify(project, status=201, headers={'Location': '/projects/%s' % project['_id']}) - - -@blueprint.route('/users', methods=['GET', 'POST']) -@authorization.require_login() -def project_manage_users(): - """Manage users of a project. In this initial implementation, we handle - addition and removal of a user to the admin group of a project. - No changes are done on the project itself. - """ - - projects_collection = current_app.data.driver.db['projects'] - users_collection = current_app.data.driver.db['users'] - - # TODO: check if user is admin of the project before anything - if request.method == 'GET': - project_id = request.args['project_id'] - project = projects_collection.find_one({'_id': ObjectId(project_id)}) - admin_group_id = project['permissions']['groups'][0]['group'] - - users = users_collection.find( - {'groups': {'$in': [admin_group_id]}}, - {'username': 1, 'email': 1, 'full_name': 1}) - return jsonify({'_status': 'OK', '_items': list(users)}) - - # The request is not a form, since it comes from the API sdk - data = json.loads(request.data) - project_id = ObjectId(data['project_id']) - target_user_id = ObjectId(data['user_id']) - action = data['action'] - current_user_id = g.current_user['user_id'] - - project = projects_collection.find_one({'_id': project_id}) - - # Check if the current_user is owner of the project, or removing themselves. - remove_self = target_user_id == current_user_id and action == 'remove' - if project['user'] != current_user_id and not remove_self: - return abort_with_error(403) - - admin_group = get_admin_group(project) - - # Get the user and add the admin group to it - if action == 'add': - operation = '$addToSet' - log.info('project_manage_users: Adding user %s to admin group of project %s', - target_user_id, project_id) - elif action == 'remove': - log.info('project_manage_users: Removing user %s from admin group of project %s', - target_user_id, project_id) - operation = '$pull' - else: - log.warning('project_manage_users: Unsupported action %r called by user %s', - action, current_user_id) - raise wz_exceptions.UnprocessableEntity() - - users_collection.update({'_id': target_user_id}, - {operation: {'groups': admin_group['_id']}}) - - user = users_collection.find_one({'_id': target_user_id}, - {'username': 1, 'email': 1, - 'full_name': 1}) - - if not user: - return jsonify({'_status': 'ERROR'}), 404 - - user['_status'] = 'OK' - return jsonify(user) - - -def get_admin_group(project): - """Returns the admin group for the project.""" - - groups_collection = current_app.data.driver.db['groups'] - - # TODO: search through all groups to find the one with the project ID as its name. - admin_group_id = ObjectId(project['permissions']['groups'][0]['group']) - group = groups_collection.find_one({'_id': admin_group_id}) - - if group is None: - raise ValueError('Unable to handle project without admin group.') - - if group['name'] != str(project['_id']): - return abort_with_error(403) - - return group - - -def abort_with_error(status): - """Aborts with the given status, or 500 if the status doesn't indicate an error. - - If the status is < 400, status 500 is used instead. - """ - - abort(status if status // 100 >= 4 else 500) - - -@blueprint.route('//quotas') -@require_login() -def project_quotas(project_id): - """Returns information about the project's limits.""" - - # Check that the user has GET permissions on the project itself. - project = mongo.find_one_or_404('projects', project_id) - check_permissions('projects', project, 'GET') - - file_size_used = project_total_file_size(project_id) - - info = { - 'file_size_quota': None, # TODO: implement this later. - 'file_size_used': file_size_used, - } - - return jsonify(info) - - -def project_total_file_size(project_id): - """Returns the total number of bytes used by files of this project.""" - - files = current_app.data.driver.db['files'] - file_size_used = files.aggregate([ - {'$match': {'project': ObjectId(project_id)}}, - {'$project': {'length_aggregate_in_bytes': 1}}, - {'$group': {'_id': None, - 'all_files': {'$sum': '$length_aggregate_in_bytes'}}} - ]) - - # The aggregate function returns a cursor, not a document. - try: - return next(file_size_used)['all_files'] - except StopIteration: - # No files used at all. - return 0 - - -def before_returning_project_permissions(response): - # Run validation process, since GET on nodes entry point is public - check_permissions('projects', response, 'GET', append_allowed_methods=True) - - -def before_returning_project_resource_permissions(response): - # Return only those projects the user has access to. - allow = [] - for project in response['_items']: - if authorization.has_permissions('projects', project, - 'GET', append_allowed_methods=True): - allow.append(project) - else: - log.debug('User %s requested project %s, but has no access to it; filtered out.', - authentication.current_user_id(), project['_id']) - - response['_items'] = allow - - -def project_node_type_has_method(response): - """Check for a specific request arg, and check generate the allowed_methods - list for the required node_type. - """ - - node_type_name = request.args.get('node_type', '') - - # Proceed only node_type has been requested - if not node_type_name: - return - - # Look up the node type in the project document - if not any(node_type.get('name') == node_type_name - for node_type in response['node_types']): - return abort(404) - - # Check permissions and append the allowed_methods to the node_type - check_permissions('projects', response, 'GET', append_allowed_methods=True, - check_node_type=node_type_name) - - -def projects_node_type_has_method(response): - for project in response['_items']: - project_node_type_has_method(project) - - -@blueprint.route('//', methods=['OPTIONS', 'GET']) -def get_allowed_methods(project_id=None, node_type=None): - """Returns allowed methods to create a node of a certain type. - - Either project_id or parent_node_id must be given. If the latter is given, - the former is deducted from it. - """ - - project = mongo.find_one_or_404('projects', str2id(project_id)) - proj_methods = authorization.compute_allowed_methods('projects', project, node_type) - - resp = make_response() - resp.headers['Allowed'] = ', '.join(sorted(proj_methods)) - resp.status_code = 204 - - return resp - - -def setup_app(app, url_prefix): - app.on_replace_projects += override_is_private_field - app.on_replace_projects += before_edit_check_permissions - app.on_replace_projects += protect_sensitive_fields - app.on_update_projects += override_is_private_field - app.on_update_projects += before_edit_check_permissions - app.on_update_projects += protect_sensitive_fields - app.on_delete_item_projects += before_delete_project - app.on_insert_projects += before_inserting_override_is_private_field - app.on_insert_projects += before_inserting_projects - app.on_inserted_projects += after_inserting_projects - - app.on_fetched_item_projects += before_returning_project_permissions - app.on_fetched_resource_projects += before_returning_project_resource_permissions - app.on_fetched_item_projects += project_node_type_has_method - app.on_fetched_resource_projects += projects_node_type_has_method - - app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/static/.webassets-cache/.gitignore b/pillar/application/static/.webassets-cache/.gitignore deleted file mode 100644 index 4b4eae0f..00000000 --- a/pillar/application/static/.webassets-cache/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Ignore everything but self -* -!.gitignore diff --git a/pillar/application/utils/algolia.py b/pillar/application/utils/algolia.py deleted file mode 100644 index 8fc6a354..00000000 --- a/pillar/application/utils/algolia.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging - -from bson import ObjectId -from flask import current_app - -from application import algolia_index_users -from application import algolia_index_nodes -from application.modules.file_storage import generate_link -from . import skip_when_testing - -log = logging.getLogger(__name__) - -INDEX_ALLOWED_USER_ROLES = {'admin', 'subscriber', 'demo'} -INDEX_ALLOWED_NODE_TYPES = {'asset', 'texture', 'group', 'hdri'} - - -@skip_when_testing -def algolia_index_user_save(user): - if algolia_index_users is None: - return - # Strip unneeded roles - if 'roles' in user: - roles = set(user['roles']).intersection(INDEX_ALLOWED_USER_ROLES) - else: - roles = set() - if algolia_index_users: - # Create or update Algolia index for the user - algolia_index_users.save_object({ - 'objectID': user['_id'], - 'full_name': user['full_name'], - 'username': user['username'], - 'roles': list(roles), - 'groups': user['groups'], - 'email': user['email'] - }) - - -@skip_when_testing -def algolia_index_node_save(node): - if node['node_type'] in INDEX_ALLOWED_NODE_TYPES and algolia_index_nodes: - # If a nodes does not have status published, do not index - if 'status' in node['properties'] \ - and node['properties']['status'] != 'published': - return - - projects_collection = current_app.data.driver.db['projects'] - project = projects_collection.find_one({'_id': ObjectId(node['project'])}) - - users_collection = current_app.data.driver.db['users'] - user = users_collection.find_one({'_id': ObjectId(node['user'])}) - - node_ob = { - 'objectID': node['_id'], - 'name': node['name'], - 'project': { - '_id': project['_id'], - 'name': project['name'] - }, - 'created': node['_created'], - 'updated': node['_updated'], - 'node_type': node['node_type'], - 'user': { - '_id': user['_id'], - 'full_name': user['full_name'] - }, - } - if 'description' in node and node['description']: - node_ob['description'] = node['description'] - if 'picture' in node and node['picture']: - files_collection = current_app.data.driver.db['files'] - lookup = {'_id': ObjectId(node['picture'])} - picture = files_collection.find_one(lookup) - if picture['backend'] == 'gcs': - variation_t = next((item for item in picture['variations'] \ - if item['size'] == 't'), None) - if variation_t: - node_ob['picture'] = generate_link(picture['backend'], - variation_t['file_path'], project_id=str(picture['project']), - is_public=True) - # If the node has world permissions, compute the Free permission - if 'permissions' in node and 'world' in node['permissions']: - if 'GET' in node['permissions']['world']: - node_ob['is_free'] = True - # Append the media key if the node is of node_type 'asset' - if node['node_type'] == 'asset': - node_ob['media'] = node['properties']['content_type'] - # Add tags - if 'tags' in node['properties']: - node_ob['tags'] = node['properties']['tags'] - - algolia_index_nodes.save_object(node_ob) - - -@skip_when_testing -def algolia_index_node_delete(node): - if algolia_index_nodes is None: - return - algolia_index_nodes.delete_object(node['_id']) diff --git a/pillar/auth/__init__.py b/pillar/auth/__init__.py new file mode 100644 index 00000000..e1a5bb33 --- /dev/null +++ b/pillar/auth/__init__.py @@ -0,0 +1,104 @@ +"""Authentication code common to the web and api modules.""" + +import logging + +from flask import current_app, session +import flask_login +import flask_oauthlib.client + +from ..api import utils, blender_id +from ..api.utils import authentication + +log = logging.getLogger(__name__) + + +class UserClass(flask_login.UserMixin): + def __init__(self, token): + # We store the Token instead of ID + self.id = token + self.username = None + self.full_name = None + self.objectid = None + self.gravatar = None + self.email = None + self.roles = [] + + def has_role(self, *roles): + """Returns True iff the user has one or more of the given roles.""" + + if not self.roles: + return False + + return bool(set(self.roles).intersection(set(roles))) + + +class AnonymousUser(flask_login.AnonymousUserMixin): + def has_role(self, *roles): + return False + + +def _load_user(token): + """Loads a user by their token. + + :returns: returns a UserClass instance if logged in, or an AnonymousUser() if not. + :rtype: UserClass + """ + + db_user = authentication.validate_this_token(token) + if not db_user: + return AnonymousUser() + + login_user = UserClass(token) + login_user.email = db_user['email'] + login_user.objectid = unicode(db_user['_id']) + login_user.username = db_user['username'] + login_user.gravatar = utils.gravatar(db_user['email']) + login_user.roles = db_user.get('roles', []) + login_user.groups = [unicode(g) for g in db_user['groups'] or ()] + login_user.full_name = db_user.get('full_name', '') + + return login_user + + +def config_login_manager(app): + """Configures the Flask-Login manager, used for the web endpoints.""" + + login_manager = flask_login.LoginManager() + login_manager.init_app(app) + login_manager.login_view = "users.login" + login_manager.anonymous_user = AnonymousUser + # noinspection PyTypeChecker + login_manager.user_loader(_load_user) + + return login_manager + + +def get_blender_id_oauth_token(): + """Returns a tuple (token, ''), for use with flask_oauthlib.""" + return session.get('blender_id_oauth_token') + + +def config_oauth_login(app): + config = app.config + if not config.get('SOCIAL_BLENDER_ID'): + log.info('OAuth Blender-ID login not setup.') + return None + + oauth = flask_oauthlib.client.OAuth(app) + social_blender_id = config.get('SOCIAL_BLENDER_ID') + + oauth_blender_id = oauth.remote_app( + 'blender_id', + consumer_key=social_blender_id['app_id'], + consumer_secret=social_blender_id['app_secret'], + request_token_params={'scope': 'email'}, + base_url=config['BLENDER_ID_OAUTH_URL'], + request_token_url=None, + access_token_url=config['BLENDER_ID_BASE_ACCESS_TOKEN_URL'], + authorize_url=config['BLENDER_ID_AUTHORIZE_URL'] + ) + + oauth_blender_id.tokengetter(get_blender_id_oauth_token) + log.info('OAuth Blender-ID login setup as %s', social_blender_id['app_id']) + + return oauth_blender_id diff --git a/pillar/cli.py b/pillar/cli.py new file mode 100644 index 00000000..e6392e32 --- /dev/null +++ b/pillar/cli.py @@ -0,0 +1,354 @@ +"""Commandline interface. + +Run commands with 'flask ' +""" + +from __future__ import print_function, division + +import logging + +from bson.objectid import ObjectId, InvalidId +from flask import current_app +from flask.ext.script import Manager + +log = logging.getLogger(__name__) +manager = Manager(current_app) + + +@manager.command +def setup_db(admin_email): + """Setup the database + - Create admin, subscriber and demo Group collection + - Create admin user (must use valid blender-id credentials) + - Create one project + """ + + # Create default groups + groups_list = [] + for group in ['admin', 'subscriber', 'demo']: + g = {'name': group} + g = current_app.post_internal('groups', g) + groups_list.append(g[0]['_id']) + print("Creating group {0}".format(group)) + + # Create admin user + user = {'username': admin_email, + 'groups': groups_list, + 'roles': ['admin', 'subscriber', 'demo'], + 'settings': {'email_communications': 1}, + 'auth': [], + 'full_name': admin_email, + 'email': admin_email} + result, _, _, status = current_app.post_internal('users', user) + if status != 201: + raise SystemExit('Error creating user {}: {}'.format(admin_email, result)) + user.update(result) + print("Created user {0}".format(user['_id'])) + + # Create a default project by faking a POST request. + with current_app.test_request_context(data={'project_name': u'Default Project'}): + from flask import g + from pillar.api.projects import routes as proj_routes + + g.current_user = {'user_id': user['_id'], + 'groups': user['groups'], + 'roles': set(user['roles'])} + + proj_routes.create_project(overrides={'url': 'default-project', + 'is_private': False}) + + +@manager.command +def find_duplicate_users(): + """Finds users that have the same BlenderID user_id.""" + + from collections import defaultdict + + users_coll = current_app.data.driver.db['users'] + nodes_coll = current_app.data.driver.db['nodes'] + projects_coll = current_app.data.driver.db['projects'] + + found_users = defaultdict(list) + + for user in users_coll.find(): + blender_ids = [auth['user_id'] for auth in user['auth'] + if auth['provider'] == 'blender-id'] + if not blender_ids: + continue + blender_id = blender_ids[0] + found_users[blender_id].append(user) + + for blender_id, users in found_users.iteritems(): + if len(users) == 1: + continue + + usernames = ', '.join(user['username'] for user in users) + print('Blender ID: %5s has %i users: %s' % ( + blender_id, len(users), usernames)) + + for user in users: + print(' %s owns %i nodes and %i projects' % ( + user['username'], + nodes_coll.count({'user': user['_id']}), + projects_coll.count({'user': user['_id']}), + )) + + +@manager.command +def sync_role_groups(do_revoke_groups): + """For each user, synchronizes roles and group membership. + + This ensures that everybody with the 'subscriber' role is also member of the 'subscriber' + group, and people without the 'subscriber' role are not member of that group. Same for + admin and demo groups. + + When do_revoke_groups=False (the default), people are only added to groups. + when do_revoke_groups=True, people are also removed from groups. + """ + + from pillar.api import service + + if do_revoke_groups not in {'true', 'false'}: + print('Use either "true" or "false" as first argument.') + print('When passing "false", people are only added to groups.') + print('when passing "true", people are also removed from groups.') + raise SystemExit() + do_revoke_groups = do_revoke_groups == 'true' + + service.fetch_role_to_group_id_map() + + users_coll = current_app.data.driver.db['users'] + groups_coll = current_app.data.driver.db['groups'] + + group_names = {} + + def gname(gid): + try: + return group_names[gid] + except KeyError: + name = groups_coll.find_one(gid, projection={'name': 1})['name'] + name = str(name) + group_names[gid] = name + return name + + ok_users = bad_users = 0 + for user in users_coll.find(): + grant_groups = set() + revoke_groups = set() + current_groups = set(user.get('groups', [])) + user_roles = user.get('roles', set()) + + for role in service.ROLES_WITH_GROUPS: + action = 'grant' if role in user_roles else 'revoke' + groups = service.manage_user_group_membership(user, role, action) + + if groups is None: + # No changes required + continue + + if groups == current_groups: + continue + + grant_groups.update(groups.difference(current_groups)) + revoke_groups.update(current_groups.difference(groups)) + + if grant_groups or revoke_groups: + bad_users += 1 + + expected_groups = current_groups.union(grant_groups).difference(revoke_groups) + + print('Discrepancy for user %s/%s:' % (user['_id'], user['full_name'].encode('utf8'))) + print(' - actual groups :', sorted(gname(gid) for gid in user.get('groups'))) + print(' - expected groups:', sorted(gname(gid) for gid in expected_groups)) + print(' - will grant :', sorted(gname(gid) for gid in grant_groups)) + + if do_revoke_groups: + label = 'WILL REVOKE ' + else: + label = 'could revoke' + print(' - %s :' % label, sorted(gname(gid) for gid in revoke_groups)) + + if grant_groups and revoke_groups: + print(' ------ CAREFUL this one has BOTH grant AND revoke -----') + + # Determine which changes we'll apply + final_groups = current_groups.union(grant_groups) + if do_revoke_groups: + final_groups.difference_update(revoke_groups) + print(' - final groups :', sorted(gname(gid) for gid in final_groups)) + + # Perform the actual update + users_coll.update_one({'_id': user['_id']}, + {'$set': {'groups': list(final_groups)}}) + else: + ok_users += 1 + + print('%i bad and %i ok users seen.' % (bad_users, ok_users)) + + +@manager.command +def sync_project_groups(user_email, fix): + """Gives the user access to their self-created projects.""" + + if fix.lower() not in {'true', 'false'}: + print('Use either "true" or "false" as second argument.') + print('When passing "false", only a report is produced.') + print('when passing "true", group membership is fixed.') + raise SystemExit() + fix = fix.lower() == 'true' + + users_coll = current_app.data.driver.db['users'] + proj_coll = current_app.data.driver.db['projects'] + groups_coll = current_app.data.driver.db['groups'] + + # Find by email or by user ID + if '@' in user_email: + where = {'email': user_email} + else: + try: + where = {'_id': ObjectId(user_email)} + except InvalidId: + log.warning('Invalid ObjectID: %s', user_email) + return + + user = users_coll.find_one(where, projection={'_id': 1, 'groups': 1}) + if user is None: + log.error('User %s not found', where) + raise SystemExit() + + user_groups = set(user['groups']) + user_id = user['_id'] + log.info('Updating projects for user %s', user_id) + + ok_groups = missing_groups = 0 + for proj in proj_coll.find({'user': user_id}): + project_id = proj['_id'] + log.info('Investigating project %s (%s)', project_id, proj['name']) + + # Find the admin group + admin_group = groups_coll.find_one({'name': str(project_id)}, projection={'_id': 1}) + if admin_group is None: + log.warning('No admin group for project %s', project_id) + continue + group_id = admin_group['_id'] + + # Check membership + if group_id not in user_groups: + log.info('Missing group membership') + missing_groups += 1 + user_groups.add(group_id) + else: + ok_groups += 1 + + log.info('User %s was missing %i group memberships; %i projects were ok.', + user_id, missing_groups, ok_groups) + + if missing_groups > 0 and fix: + log.info('Updating database.') + result = users_coll.update_one({'_id': user_id}, + {'$set': {'groups': list(user_groups)}}) + log.info('Updated %i user.', result.modified_count) + + +@manager.command +def badger(action, user_email, role): + from pillar.api import service + + with current_app.app_context(): + service.fetch_role_to_group_id_map() + response, status = service.do_badger(action, user_email, role) + + if status == 204: + log.info('Done.') + else: + log.info('Response: %s', response) + log.info('Status : %i', status) + + +def _create_service_account(email, service_roles, service_definition): + from pillar.api import service + from pillar.api.utils import dumps + + account, token = service.create_service_account( + email, + service_roles, + service_definition + ) + + print('Account created:') + print(dumps(account, indent=4, sort_keys=True)) + print() + print('Access token: %s' % token['token']) + print(' expires on: %s' % token['expire_time']) + + +@manager.command +def create_badger_account(email, badges): + """ + Creates a new service account that can give badges (i.e. roles). + + :param email: email address associated with the account + :param badges: single space-separated argument containing the roles + this account can assign and revoke. + """ + + _create_service_account(email, [u'badger'], {'badger': badges.strip().split()}) + + +@manager.command +def create_urler_account(email): + """Creates a new service account that can fetch all project URLs.""" + + _create_service_account(email, [u'urler'], {}) + + +@manager.command +def create_local_user_account(email, password): + from pillar.api.local_auth import create_local_user + create_local_user(email, password) + + +@manager.command +@manager.option('-c', '--chunk', dest='chunk_size', default=50) +@manager.option('-q', '--quiet', dest='quiet', action='store_true', default=False) +@manager.option('-w', '--window', dest='window', default=12) +def refresh_backend_links(backend_name, chunk_size=50, quiet=False, window=12): + """Refreshes all file links that are using a certain storage backend.""" + + chunk_size = int(chunk_size) + window = int(window) + + if quiet: + import logging + from pillar import log + + logging.getLogger().setLevel(logging.WARNING) + log.setLevel(logging.WARNING) + + chunk_size = int(chunk_size) # CLI parameters are passed as strings + from pillar.api import file_storage + + file_storage.refresh_links_for_backend(backend_name, chunk_size, window * 3600) + + +@manager.command +def expire_all_project_links(project_uuid): + """Expires all file links for a certain project without refreshing. + + This is just for testing. + """ + + import datetime + import bson.tz_util + + files_collection = current_app.data.driver.db['files'] + + now = datetime.datetime.now(tz=bson.tz_util.utc) + expires = now - datetime.timedelta(days=1) + + result = files_collection.update_many( + {'project': ObjectId(project_uuid)}, + {'$set': {'link_expires': expires}} + ) + + print('Expired %i links' % result.matched_count) diff --git a/pillar/config.py b/pillar/config.py index cacc0a87..973036e3 100644 --- a/pillar/config.py +++ b/pillar/config.py @@ -1,23 +1,23 @@ import os.path +from os import getenv from collections import defaultdict -import requests.certs - -# Certificate file for communication with other systems. -TLS_CERT_FILE = requests.certs.where() -print('Loading TLS certificates from %s' % TLS_CERT_FILE) RFC1123_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' +PILLAR_SERVER_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SCHEME = 'https' -STORAGE_DIR = '/data/storage/pillar' -SHARED_DIR = '/data/storage/shared' +STORAGE_DIR = getenv('PILLAR_STORAGE_DIR', '/data/storage/pillar') PORT = 5000 HOST = '0.0.0.0' DEBUG = False +SECRET_KEY = '123' + # Authentication settings BLENDER_ID_ENDPOINT = 'http://blender_id:8000/' +PILLAR_SERVER_ENDPOINT = 'http://pillar:5001/api/' + CDN_USE_URL_SIGNING = True CDN_SERVICE_DOMAIN_PROTOCOL = 'https' CDN_SERVICE_DOMAIN = '-CONFIG-THIS-' @@ -44,7 +44,7 @@ BIN_FFMPEG = '/usr/bin/ffmpeg' BIN_SSH = '/usr/bin/ssh' BIN_RSYNC = '/usr/bin/rsync' -GCLOUD_APP_CREDENTIALS = os.path.join(os.path.dirname(__file__), 'google_app.json') +GCLOUD_APP_CREDENTIALS = 'google_app.json' GCLOUD_PROJECT = '-SECRET-' ADMIN_USER_GROUP = '5596e975ea893b269af85c0e' @@ -93,7 +93,7 @@ LOGGING = { } }, 'loggers': { - 'application': {'level': 'INFO'}, + 'pillar': {'level': 'INFO'}, 'werkzeug': {'level': 'INFO'}, }, 'root': { @@ -111,3 +111,29 @@ SHORT_CODE_LENGTH = 6 # characters FILESIZE_LIMIT_BYTES_NONSUBS = 32 * 2 ** 20 # Unless they have one of those roles. ROLES_FOR_UNLIMITED_UPLOADS = {u'subscriber', u'demo', u'admin'} + + +############################################# +# Old pillar-web config: + +# Mapping from /{path} to URL to redirect to. +REDIRECTS = {} + +GIT = 'git' + +# Setting this to True can be useful for development. +# Note that it doesn't add the /p/home/{node-id} endpoint, so you will have to +# change the URL of the home project if you want to have direct access to nodes. +RENDER_HOME_AS_REGULAR_PROJECT = False + + +# Authentication token for the Urler service. If None, defaults +# to the authentication token of the current user. +URLER_SERVICE_AUTH_TOKEN = None + + +# Blender Cloud add-on version. This updates the value in all places in the +# front-end. +BLENDER_CLOUD_ADDON_VERSION = '1.4' + +EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER = 'https://store.blender.org/api/' diff --git a/pillar/extension.py b/pillar/extension.py new file mode 100644 index 00000000..22b8c759 --- /dev/null +++ b/pillar/extension.py @@ -0,0 +1,64 @@ +"""Pillar extensions support. + +Each Pillar extension should create a subclass of PillarExtension, which +can then be registered to the application at app creation time: + + from pillar_server import PillarServer + from attract_server import AttractExtension + + app = PillarServer('.') + app.load_extension(AttractExtension(), url_prefix='/attract') + app.process_extensions() # Always process extensions after the last one is loaded. + + if __name__ == '__main__': + app.run('::0', 5000) + +""" + +import abc + + +class PillarExtension(object): + __metaclass__ = abc.ABCMeta + + @abc.abstractproperty + def name(self): + """The name of this extension. + + The name determines the path at which Eve exposes the extension's + resources (/{extension name}/{resource name}), as well as the + MongoDB collection in which those resources are stored + ({extensions name}.{resource name}). + + :rtype: unicode + """ + + @abc.abstractmethod + def flask_config(self): + """Returns extension-specific defaults for the Flask configuration. + + Use this to set sensible default values for configuration settings + introduced by the extension. + + :rtype: dict + """ + + @abc.abstractmethod + def blueprints(self): + """Returns the list of top-level blueprints for the extension. + + These blueprints will be mounted at the url prefix given to + app.load_extension(). + + :rtype: list of flask.Blueprint objects. + """ + + @abc.abstractmethod + def eve_settings(self): + """Returns extensions to the Eve settings. + + Currently only the DOMAIN key is used to insert new resources into + Eve's configuration. + + :rtype: dict + """ diff --git a/pillar/manage_extra/import_data.py b/pillar/manage_extra/import_data.py deleted file mode 100644 index 21b42f93..00000000 --- a/pillar/manage_extra/import_data.py +++ /dev/null @@ -1,182 +0,0 @@ -def import_data(path): - import json - import pprint - from bson import json_util - if not os.path.isfile(path): - return "File does not exist" - with open(path, 'r') as infile: - d = json.load(infile) - - def commit_object(collection, f, parent=None): - variation_id = f.get('variation_id') - if variation_id: - del f['variation_id'] - - asset_id = f.get('asset_id') - if asset_id: - del f['asset_id'] - - node_id = f.get('node_id') - if node_id: - del f['node_id'] - - if parent: - f['parent'] = parent - else: - if f.get('parent'): - del f['parent'] - - #r = [{'_status': 'OK', '_id': 'DRY-ID'}] - r = post_item(collection, f) - if r[0]['_status'] == 'ERR': - print r[0]['_issues'] - print "Tried to commit the following object" - pprint.pprint(f) - - # Assign the Mongo ObjectID - f['_id'] = str(r[0]['_id']) - # Restore variation_id - if variation_id: - f['variation_id'] = variation_id - if asset_id: - f['asset_id'] = asset_id - if node_id: - f['node_id'] = node_id - try: - print "{0} {1}".format(f['_id'], f['name']) - except UnicodeEncodeError: - print "{0}".format(f['_id']) - return f - - # Build list of parent files - parent_files = [f for f in d['files'] if 'parent_asset_id' in f] - children_files = [f for f in d['files'] if 'parent_asset_id' not in f] - - for p in parent_files: - # Store temp property - parent_asset_id = p['parent_asset_id'] - # Remove from dict to prevent invalid submission - del p['parent_asset_id'] - # Commit to database - p = commit_object('files', p) - # Restore temp property - p['parent_asset_id'] = parent_asset_id - # Find children of the current file - children = [c for c in children_files if c['parent'] == p['variation_id']] - for c in children: - # Commit to database with parent id - c = commit_object('files', c, p['_id']) - - - # Merge the dicts and replace the original one - d['files'] = parent_files + children_files - - # Files for picture previews of folders (groups) - for f in d['files_group']: - item_id = f['item_id'] - del f['item_id'] - f = commit_object('files', f) - f['item_id'] = item_id - - # Files for picture previews of assets - for f in d['files_asset']: - item_id = f['item_id'] - del f['item_id'] - f = commit_object('files',f) - f['item_id'] = item_id - - - nodes_asset = [n for n in d['nodes'] if 'asset_id' in n] - nodes_group = [n for n in d['nodes'] if 'node_id' in n] - - def get_parent(node_id): - #print "Searching for {0}".format(node_id) - try: - parent = [p for p in nodes_group if p['node_id'] == node_id][0] - except IndexError: - return None - return parent - - def traverse_nodes(parent_id): - parents_list = [] - while True: - parent = get_parent(parent_id) - #print parent - if not parent: - break - else: - parents_list.append(parent['node_id']) - if parent.get('parent'): - parent_id = parent['parent'] - else: - break - parents_list.reverse() - return parents_list - - for n in nodes_asset: - node_type_asset = db.node_types.find_one({"name": "asset"}) - if n.get('picture'): - filename = os.path.splitext(n['picture'])[0] - pictures = [p for p in d['files_asset'] if p['name'] == filename] - if pictures: - n['picture'] = pictures[0]['_id'] - print "Adding picture link {0}".format(n['picture']) - n['node_type'] = node_type_asset['_id'] - # An asset node must have a parent - # parent = [p for p in nodes_group if p['node_id'] == n['parent']][0] - parents_list = traverse_nodes(n['parent']) - - tree_index = 0 - for node_id in parents_list: - node = [p for p in nodes_group if p['node_id'] == node_id][0] - - if node.get('_id') is None: - node_type_group = db.node_types.find_one({"name": "group"}) - node['node_type'] = node_type_group['_id'] - # Assign picture to the node group - if node.get('picture'): - filename = os.path.splitext(node['picture'])[0] - picture = [p for p in d['files_group'] if p['name'] == filename][0] - node['picture'] = picture['_id'] - print "Adding picture link to node {0}".format(node['picture']) - if tree_index == 0: - # We are at the root of the tree (so we link to the project) - node_type_project = db.node_types.find_one({"name": "project"}) - node['node_type'] = node_type_project['_id'] - parent = None - if node['properties'].get('picture_square'): - filename = os.path.splitext(node['properties']['picture_square'])[0] - picture = [p for p in d['files_group'] if p['name'] == filename][0] - node['properties']['picture_square'] = picture['_id'] - print "Adding picture_square link to node" - if node['properties'].get('picture_header'): - filename = os.path.splitext(node['properties']['picture_header'])[0] - picture = [p for p in d['files_group'] if p['name'] == filename][0] - node['properties']['picture_header'] = picture['_id'] - print "Adding picture_header link to node" - else: - # Get the parent node id - parents_list_node_id = parents_list[tree_index - 1] - parent_node = [p for p in nodes_group if p['node_id'] == parents_list_node_id][0] - parent = parent_node['_id'] - print "About to commit Node" - commit_object('nodes', node, parent) - tree_index += 1 - # Commit the asset - print "About to commit Asset {0}".format(n['asset_id']) - parent_node = [p for p in nodes_group if p['node_id'] == parents_list[-1]][0] - try: - asset_file = [a for a in d['files'] if a['md5'] == n['properties']['file']][0] - n['properties']['file'] = str(asset_file['_id']) - commit_object('nodes', n, parent_node['_id']) - except IndexError: - pass - - return - - - # New path with _ - path = '_' + path - with open(path, 'w') as outfile: - json.dump(d, outfile, default=json_util.default) - return diff --git a/pillar/runserver.wsgi b/pillar/runserver.wsgi deleted file mode 100644 index a5112d49..00000000 --- a/pillar/runserver.wsgi +++ /dev/null @@ -1,11 +0,0 @@ -import sys - -activate_this = '/data/venv/bin/activate_this.py' -execfile(activate_this, dict(__file__=activate_this)) -from flup.server.fcgi import WSGIServer - -sys.path.append('/data/git/pillar/pillar/') -from application import app as application - -if __name__ == '__main__': - WSGIServer(application).run() diff --git a/pillar/sdk.py b/pillar/sdk.py new file mode 100644 index 00000000..28b8e1dd --- /dev/null +++ b/pillar/sdk.py @@ -0,0 +1,100 @@ +"""PillarSDK subclass for direct Flask-internal calls.""" + +import logging +import urlparse +from flask import current_app + +import pillarsdk +from pillarsdk import exceptions + +log = logging.getLogger(__name__) + + +class FlaskInternalApi(pillarsdk.Api): + """SDK API subclass that calls Flask directly. + + Can only be used from the same Python process the Pillar server itself is + running on. + """ + + def http_call(self, url, method, **kwargs): + """Fakes a http call through Flask/Werkzeug.""" + client = current_app.test_client() + self.requests_to_flask_kwargs(kwargs) + url = urlparse.urlsplit(url) + path = url.scheme + "://" + url.netloc + url.path + query = url.query + try: + response = client.open(path=path, query_string=query, method=method, + **kwargs) + except Exception as ex: + log.warning('Error performing HTTP %s request to %s: %s', method, + url, str(ex)) + raise + + if method == 'OPTIONS': + return response + + self.flask_to_requests_response(response) + + try: + content = self.handle_response(response, response.data) + except: + log.warning("%s: Response[%s]: %s", url, response.status_code, + response.data) + raise + + return content + + def requests_to_flask_kwargs(self, kwargs): + """Converts Requests arguments to Flask test client arguments.""" + + kwargs.pop('verify', None) + # No network connection, so nothing to verify. + + # Files to upload need to be sent in the 'data' kwarg instead of the + # 'files' kwarg, and have a different order. + if 'files' in kwargs: + # By default, 'data' is there but None, so setdefault('data', {}) + # won't work. + data = kwargs.get('data') or {} + + for file_name, file_value in kwargs['files'].items(): + fname, fobj, mimeytpe = file_value + data[file_name] = (fobj, fname) + + del kwargs['files'] + kwargs['data'] = data + + def flask_to_requests_response(self, response): + """Adds some properties to a Flask response object to mimick a Requests + object. + """ + + # Our API always sends back UTF8, so we don't have to check headers for + # that. + if response.mimetype.startswith('text'): + response.text = response.data.decode('utf8') + else: + response.text = None + + def OPTIONS(self, action, headers=None): + """Make OPTIONS request. + + Contrary to other requests, this method returns the raw requests.Response object. + + :rtype: requests.Response + """ + import os + + url = os.path.join(self.endpoint, action.strip('/')) + response = self.request(url, 'OPTIONS', headers=headers) + if 200 <= response.status_code <= 299: + return response + + exception = exceptions.exception_for_status(response.status_code) + if exception: + raise exception(response, response.text) + + raise exceptions.ConnectionError(response, response.text, + "Unknown response code: %s" % response.status_code) diff --git a/tests/common_test_class.py b/pillar/tests/__init__.py similarity index 82% rename from tests/common_test_class.py rename to pillar/tests/__init__.py index 5813db4a..aa5134e3 100644 --- a/tests/common_test_class.py +++ b/pillar/tests/__init__.py @@ -1,12 +1,13 @@ # -*- encoding: utf-8 -*- -import json +import base64 import copy -import sys +import json import logging + import datetime import os -import base64 +import sys try: from urllib.parse import urlencode @@ -16,16 +17,17 @@ except ImportError: from bson import ObjectId, tz_util # Override Eve settings before importing eve.tests. -import common_test_settings +from pillar.tests import eve_test_settings -common_test_settings.override_eve() +eve_test_settings.override_eve() from eve.tests import TestMinimal import pymongo.collection from flask.testing import FlaskClient import responses -from common_test_data import EXAMPLE_PROJECT, EXAMPLE_FILE +from pillar.tests.common_test_data import EXAMPLE_PROJECT, EXAMPLE_FILE +import pillar # from six: PY3 = sys.version_info[0] == 3 @@ -49,32 +51,41 @@ BLENDER_ID_USER_RESPONSE = {'status': 'success', 'id': BLENDER_ID_TEST_USERID}, 'token_expires': 'Mon, 1 Jan 2018 01:02:03 GMT'} -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)-15s %(levelname)8s %(name)s %(message)s') + +class TestPillarServer(pillar.PillarServer): + def _load_flask_config(self): + super(TestPillarServer, self)._load_flask_config() + + pillar_config_file = os.path.join(MY_PATH, 'config_testing.py') + self.config.from_pyfile(pillar_config_file) + + def _config_logging(self): + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)-15s %(levelname)8s %(name)s %(message)s') + logging.getLogger('').setLevel(logging.DEBUG) + logging.getLogger('pillar').setLevel(logging.DEBUG) + logging.getLogger('werkzeug').setLevel(logging.DEBUG) + logging.getLogger('eve').setLevel(logging.DEBUG) class AbstractPillarTest(TestMinimal): + pillar_server_class = TestPillarServer + def setUp(self, **kwargs): - eve_settings_file = os.path.join(MY_PATH, 'common_test_settings.py') - pillar_config_file = os.path.join(MY_PATH, 'config_testing.py') + eve_settings_file = os.path.join(MY_PATH, 'eve_test_settings.py') kwargs['settings_file'] = eve_settings_file os.environ['EVE_SETTINGS'] = eve_settings_file - os.environ['PILLAR_CONFIG'] = pillar_config_file super(AbstractPillarTest, self).setUp(**kwargs) - from application import app - - logging.getLogger('').setLevel(logging.DEBUG) - logging.getLogger('application').setLevel(logging.DEBUG) - logging.getLogger('werkzeug').setLevel(logging.DEBUG) - logging.getLogger('eve').setLevel(logging.DEBUG) - from eve.utils import config config.DEBUG = True - self.app = app - self.client = app.test_client() + self.app = self.pillar_server_class(os.path.dirname(os.path.dirname(__file__))) + self.app.process_extensions() + assert self.app.config['MONGO_DBNAME'] == 'pillar_test' + + self.client = self.app.test_client() assert isinstance(self.client, FlaskClient) def tearDown(self): @@ -82,9 +93,9 @@ class AbstractPillarTest(TestMinimal): # Not only delete self.app (like the superclass does), # but also un-import the application. - del sys.modules['application'] + del sys.modules['pillar'] remove = [modname for modname in sys.modules - if modname.startswith('application.')] + if modname.startswith('pillar.')] for modname in remove: del sys.modules[modname] @@ -126,7 +137,7 @@ class AbstractPillarTest(TestMinimal): def create_user(self, user_id='cafef00dc379cf10c4aaceaf', roles=('subscriber',), groups=None): - from application.utils.authentication import make_unique_username + from pillar.api.utils.authentication import make_unique_username with self.app.test_request_context(): users = self.app.data.driver.db['users'] @@ -154,12 +165,25 @@ class AbstractPillarTest(TestMinimal): future = now + datetime.timedelta(days=1) with self.app.test_request_context(): - from application.utils import authentication as auth + from pillar.api.utils import authentication as auth token_data = auth.store_token(user_id, token, future, None) return token_data + def create_project_with_admin(self, user_id='cafef00dc379cf10c4aaceaf', roles=('subscriber', )): + """Creates a project and a user that's member of the project's admin group. + + :returns: (project_id, user_id) + :rtype: tuple + """ + project_id, proj = self.ensure_project_exists() + admin_group_id = proj['permissions']['groups'][0]['group'] + + user_id = self.create_user(user_id=user_id, roles=roles, groups=[admin_group_id]) + + return project_id, user_id + def badger(self, user_email, roles, action, srv_token=None): """Creates a service account, and uses it to grant or revoke a role to the user. @@ -174,7 +198,7 @@ class AbstractPillarTest(TestMinimal): # Create a service account if needed. if srv_token is None: - from application.modules.service import create_service_account + from pillar.api.service import create_service_account with self.app.test_request_context(): _, srv_token_doc = create_service_account('service@example.com', {'badger'}, @@ -182,14 +206,12 @@ class AbstractPillarTest(TestMinimal): srv_token = srv_token_doc['token'] for role in roles: - resp = self.client.post('/service/badger', - headers={'Authorization': self.make_header(srv_token), - 'Content-Type': 'application/json'}, - data=json.dumps({'action': action, - 'role': role, - 'user_email': user_email})) - self.assertEqual(204, resp.status_code, resp.data) - + self.post('/api/service/badger', + auth_token=srv_token, + json={'action': action, + 'role': role, + 'user_email': user_email}, + expected_status=204) return srv_token def mock_blenderid_validate_unhappy(self): @@ -218,7 +240,7 @@ class AbstractPillarTest(TestMinimal): :returns: mapping from group name to group ID """ - from application.modules import service + from pillar.api import service with self.app.test_request_context(): group_ids = {} @@ -266,7 +288,7 @@ class AbstractPillarTest(TestMinimal): data=None, headers=None, files=None, content_type=None): """Performs a HTTP request to the server.""" - from application.utils import dumps + from pillar.api.utils import dumps import json as mod_json headers = headers or {} diff --git a/tests/common_test_data.py b/pillar/tests/common_test_data.py similarity index 100% rename from tests/common_test_data.py rename to pillar/tests/common_test_data.py diff --git a/tests/config_testing.py b/pillar/tests/config_testing.py similarity index 100% rename from tests/config_testing.py rename to pillar/tests/config_testing.py diff --git a/tests/common_test_settings.py b/pillar/tests/eve_test_settings.py similarity index 57% rename from tests/common_test_settings.py rename to pillar/tests/eve_test_settings.py index 3125847e..ae7d9365 100644 --- a/tests/common_test_settings.py +++ b/pillar/tests/eve_test_settings.py @@ -1,6 +1,6 @@ -from settings import * +from pillar.api.eve_settings import * -from eve.tests.test_settings import MONGO_DBNAME +MONGO_DBNAME = 'pillar_test' def override_eve(): @@ -9,5 +9,6 @@ def override_eve(): test_settings.MONGO_HOST = MONGO_HOST test_settings.MONGO_PORT = MONGO_PORT + test_settings.MONGO_DBNAME = MONGO_DBNAME tests.MONGO_HOST = MONGO_HOST - tests.MONGO_PORT = MONGO_PORT + tests.MONGO_DBNAME = MONGO_DBNAME diff --git a/pillar/web/__init__.py b/pillar/web/__init__.py new file mode 100644 index 00000000..3e9aaa7a --- /dev/null +++ b/pillar/web/__init__.py @@ -0,0 +1,8 @@ +def setup_app(app): + from . import main, users, projects, nodes, notifications, redirects + main.setup_app(app, url_prefix=None) + users.setup_app(app, url_prefix=None) + redirects.setup_app(app, url_prefix='/r') + projects.setup_app(app, url_prefix='/p') + nodes.setup_app(app, url_prefix='/nodes') + notifications.setup_app(app, url_prefix='/notifications') diff --git a/pillar/web/main/__init__.py b/pillar/web/main/__init__.py new file mode 100644 index 00000000..9a13f987 --- /dev/null +++ b/pillar/web/main/__init__.py @@ -0,0 +1,5 @@ +from .routes import blueprint + + +def setup_app(app, url_prefix): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/web/main/routes.py b/pillar/web/main/routes.py new file mode 100644 index 00000000..52d538cb --- /dev/null +++ b/pillar/web/main/routes.py @@ -0,0 +1,324 @@ +import itertools +import logging + +from pillarsdk import Node +from pillarsdk import Project +from pillarsdk.exceptions import ResourceNotFound +from flask import abort +from flask import Blueprint +from flask import current_app +from flask import render_template +from flask import redirect +from flask import request +from flask.ext.login import current_user +from werkzeug.contrib.atom import AtomFeed + +from pillar.web.utils import system_util +from pillar.web.nodes.routes import url_for_node +from pillar.web.nodes.custom.posts import posts_view +from pillar.web.nodes.custom.posts import posts_create +from pillar.web.utils import attach_project_pictures +from pillar.web.utils import current_user_is_authenticated +from pillar.web.utils import get_file + +blueprint = Blueprint('main', __name__) +log = logging.getLogger(__name__) + + +@blueprint.route('/') +def homepage(): + # Workaround to cache rendering of a page if user not logged in + @current_app.cache.cached(timeout=3600) + def render_page(): + return render_template('join.html') + + if current_user.is_anonymous: + return render_page() + + # Get latest blog posts + api = system_util.pillar_api() + latest_posts = Node.all({ + 'projection': {'name': 1, 'project': 1, 'node_type': 1, + 'picture': 1, 'properties.status': 1, 'properties.url': 1}, + 'where': {'node_type': 'post', 'properties.status': 'published'}, + 'embedded': {'project': 1}, + 'sort': '-_created', + 'max_results': '5' + }, api=api) + + # Append picture Files to last_posts + for post in latest_posts._items: + post.picture = get_file(post.picture, api=api) + + # Get latest assets added to any project + latest_assets = Node.latest('assets', api=api) + + # Append picture Files to latest_assets + for asset in latest_assets._items: + asset.picture = get_file(asset.picture, api=api) + + # Get latest comments to any node + latest_comments = Node.latest('comments', api=api) + + # Get a list of random featured assets + random_featured = get_random_featured_nodes() + + # Parse results for replies + for comment in latest_comments._items: + if comment.properties.is_reply: + comment.attached_to = Node.find(comment.parent.parent, + {'projection': { + '_id': 1, + 'name': 1, + }}, + api=api) + else: + comment.attached_to = comment.parent + + main_project = Project.find(current_app.config['MAIN_PROJECT_ID'], api=api) + main_project.picture_header = get_file(main_project.picture_header, api=api) + + # Merge latest assets and comments into one activity stream. + def sort_key(item): + return item._created + + activities = itertools.chain(latest_posts._items, + latest_assets._items, + latest_comments._items) + activity_stream = sorted(activities, key=sort_key, reverse=True) + + return render_template( + 'homepage.html', + main_project=main_project, + latest_posts=latest_posts._items, + activity_stream=activity_stream, + random_featured=random_featured, + api=api) + + +# @blueprint.errorhandler(500) +# def error_500(e): +# return render_template('errors/500.html'), 500 +# +# +# @blueprint.errorhandler(404) +# def error_404(e): +# return render_template('errors/404.html'), 404 +# +# +# @blueprint.errorhandler(403) +# def error_404(e): +# return render_template('errors/403_embed.html'), 403 +# + +@blueprint.route('/join') +def join(): + """Join page""" + return redirect('https://store.blender.org/product/membership/') + + +@blueprint.route('/services') +def services(): + """Services page""" + return render_template('services.html') + + +@blueprint.route('/blog/') +@blueprint.route('/blog/') +def main_blog(url=None): + """Blog with project news""" + project_id = current_app.config['MAIN_PROJECT_ID'] + + @current_app.cache.memoize(timeout=3600, unless=current_user_is_authenticated) + def cache_post_view(url): + return posts_view(project_id, url) + + return cache_post_view(url) + + +@blueprint.route('/blog/create') +def main_posts_create(): + project_id = current_app.config['MAIN_PROJECT_ID'] + return posts_create(project_id) + + +@blueprint.route('/p//blog/') +@blueprint.route('/p//blog/') +def project_blog(project_url, url=None): + """View project blog""" + + @current_app.cache.memoize(timeout=3600, + unless=current_user_is_authenticated) + def cache_post_view(project_url, url): + api = system_util.pillar_api() + try: + project = Project.find_one({ + 'where': '{"url" : "%s"}' % (project_url)}, api=api) + return posts_view(project._id, url=url) + except ResourceNotFound: + return abort(404) + + return cache_post_view(project_url, url) + + +def get_projects(category): + """Utility to get projects based on category. Should be moved on the API + and improved with more extensive filtering capabilities. + """ + api = system_util.pillar_api() + projects = Project.all({ + 'where': { + 'category': category, + 'is_private': False}, + 'sort': '-_created', + }, api=api) + for project in projects._items: + attach_project_pictures(project, api) + return projects + + +def get_random_featured_nodes(): + + import random + + api = system_util.pillar_api() + projects = Project.all({ + 'projection': {'nodes_featured': 1}, + 'where': {'is_private': False}, + 'max_results': '15' + }, api=api) + + featured_nodes = (p.nodes_featured for p in projects._items if p.nodes_featured) + featured_nodes = [item for sublist in featured_nodes for item in sublist] + if len(featured_nodes) > 3: + featured_nodes = random.sample(featured_nodes, 3) + + featured_node_documents = [] + + for node in featured_nodes: + node_document = Node.find(node, { + 'projection': {'name': 1, 'project': 1, 'picture': 1, + 'properties.content_type': 1, 'properties.url': 1}, + 'embedded': {'project': 1} + }, api=api) + + node_document.picture = get_file(node_document.picture, api=api) + featured_node_documents.append(node_document) + + return featured_node_documents + + +@blueprint.route('/open-projects') +def open_projects(): + @current_app.cache.cached(timeout=3600, unless=current_user_is_authenticated) + def render_page(): + projects = get_projects('film') + return render_template( + 'projects/index_collection.html', + title='open-projects', + projects=projects._items, + api=system_util.pillar_api()) + + return render_page() + + +@blueprint.route('/training') +def training(): + @current_app.cache.cached(timeout=3600, unless=current_user_is_authenticated) + def render_page(): + projects = get_projects('training') + return render_template( + 'projects/index_collection.html', + title='training', + projects=projects._items, + api=system_util.pillar_api()) + + return render_page() + + +@blueprint.route('/gallery') +def gallery(): + return redirect('/p/gallery') + + +@blueprint.route('/textures') +def redir_textures(): + return redirect('/p/textures') + + +@blueprint.route('/hdri') +def redir_hdri(): + return redirect('/p/hdri') + + +@blueprint.route('/caminandes') +def caminandes(): + return redirect('/p/caminandes-3') + + +@blueprint.route('/cf2') +def cf2(): + return redirect('/p/creature-factory-2') + + +@blueprint.route('/characters') +def redir_characters(): + return redirect('/p/characters') + + +@blueprint.route('/vrview') +def vrview(): + """Call this from iframes to render sperical content (video and images)""" + if 'image' not in request.args: + return redirect('/') + return render_template('vrview.html') + + +@blueprint.route('/403') +def error_403(): + """Custom entry point to display the not allowed template""" + return render_template('errors/403_embed.html') + + +# Shameful redirects +@blueprint.route('/p/blender-cloud/') +def redirect_cloud_blog(): + return redirect('/blog') + + +@blueprint.route('/feeds/blogs.atom') +def feeds_blogs(): + """Global feed generator for latest blogposts across all projects""" + @current_app.cache.cached(60*5) + def render_page(): + feed = AtomFeed('Blender Cloud - Latest updates', + feed_url=request.url, url=request.url_root) + # Get latest blog posts + api = system_util.pillar_api() + latest_posts = Node.all({ + 'where': {'node_type': 'post', 'properties.status': 'published'}, + 'embedded': {'user': 1}, + 'sort': '-_created', + 'max_results': '15' + }, api=api) + + # Populate the feed + for post in latest_posts._items: + author = post.user.fullname + updated = post._updated if post._updated else post._created + url = url_for_node(node=post) + content = post.properties.content[:500] + content = u'

{0}... Read more

'.format(content, url) + feed.add(post.name, unicode(content), + content_type='html', + author=author, + url=url, + updated=updated, + published=post._created) + return feed.get_response() + return render_page() + + +@blueprint.route('/search') +def nodes_search_index(): + return render_template('nodes/search.html') diff --git a/pillar/web/nodes/__init__.py b/pillar/web/nodes/__init__.py new file mode 100644 index 00000000..9d09c695 --- /dev/null +++ b/pillar/web/nodes/__init__.py @@ -0,0 +1,5 @@ +from .routes import blueprint + + +def setup_app(app, url_prefix=None): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/web/nodes/custom/__init__.py b/pillar/web/nodes/custom/__init__.py new file mode 100644 index 00000000..a1cd86d7 --- /dev/null +++ b/pillar/web/nodes/custom/__init__.py @@ -0,0 +1,2 @@ +def append_custom_node_endpoints(): + pass diff --git a/pillar/web/nodes/custom/comments.py b/pillar/web/nodes/custom/comments.py new file mode 100644 index 00000000..e326a073 --- /dev/null +++ b/pillar/web/nodes/custom/comments.py @@ -0,0 +1,189 @@ +import logging +from flask import current_app +from flask import request +from flask import jsonify +from flask import render_template +from flask.ext.login import login_required +from flask.ext.login import current_user +from pillarsdk import Node +from pillarsdk import Project +import werkzeug.exceptions as wz_exceptions +from pillar.web.nodes.routes import blueprint +from pillar.web.utils import gravatar +from pillar.web.utils import pretty_date +from pillar.web.utils import system_util + +log = logging.getLogger(__name__) + + +@blueprint.route('/comments/create', methods=['POST']) +@login_required +def comments_create(): + content = request.form['content'] + parent_id = request.form.get('parent_id') + api = system_util.pillar_api() + parent_node = Node.find(parent_id, api=api) + + node_asset_props = dict( + project=parent_node.project, + name='Comment', + user=current_user.objectid, + node_type='comment', + properties=dict( + content=content, + status='published', + confidence=0, + rating_positive=0, + rating_negative=0)) + + if parent_id: + node_asset_props['parent'] = parent_id + + # Get the parent node and check if it's a comment. In which case we flag + # the current comment as a reply. + parent_node = Node.find(parent_id, api=api) + if parent_node.node_type == 'comment': + node_asset_props['properties']['is_reply'] = True + + node_asset = Node(node_asset_props) + node_asset.create(api=api) + + return jsonify( + asset_id=node_asset._id, + content=node_asset.properties.content) + + +@blueprint.route('/comments/', methods=['POST']) +@login_required +def comment_edit(comment_id): + """Allows a user to edit their comment (or any they have PUT access to).""" + + api = system_util.pillar_api() + + # Fetch the old comment. + comment_node = Node.find(comment_id, api=api) + if comment_node.node_type != 'comment': + log.info('POST to %s node %s done as if it were a comment edit; rejected.', + comment_node.node_type, comment_id) + raise wz_exceptions.BadRequest('Node ID is not a comment.') + + # Update the node. + comment_node.properties.content = request.form['content'] + update_ok = comment_node.update(api=api) + if not update_ok: + log.warning('Unable to update comment node %s: %s', + comment_id, comment_node.error) + raise wz_exceptions.InternalServerError('Unable to update comment node, unknown why.') + + return '', 204 + + +def format_comment(comment, is_reply=False, is_team=False, replies=None): + """Format a comment node into a simpler dictionary. + + :param comment: the comment object + :param is_reply: True if the comment is a reply to another comment + :param is_team: True if the author belongs to the group that owns the node + :param replies: list of replies (formatted with this function) + """ + try: + is_own = (current_user.objectid == comment.user._id) \ + if current_user.is_authenticated else False + except AttributeError: + current_app.bugsnag.notify(Exception( + 'Missing user for embedded user ObjectId'), + meta_data={'nodes_info': {'node_id': comment['_id']}}) + return + is_rated = False + is_rated_positive = None + if comment.properties.ratings: + for rating in comment.properties.ratings: + if current_user.is_authenticated and rating.user == current_user.objectid: + is_rated = True + is_rated_positive = rating.is_positive + break + + return dict(_id=comment._id, + gravatar=gravatar(comment.user.email, size=32), + time_published=pretty_date(comment._created, detail=True), + rating=comment.properties.rating_positive - comment.properties.rating_negative, + author=comment.user.full_name, + author_username=comment.user.username, + content=comment.properties.content, + is_reply=is_reply, + is_own=is_own, + is_rated=is_rated, + is_rated_positive=is_rated_positive, + is_team=is_team, + replies=replies) + + +@blueprint.route("/comments/") +def comments_index(): + parent_id = request.args.get('parent_id') + # Get data only if we format it + api = system_util.pillar_api() + if request.args.get('format') == 'json': + nodes = Node.all({ + 'where': '{"node_type" : "comment", "parent": "%s"}' % (parent_id), + 'embedded': '{"user":1}'}, api=api) + + comments = [] + for comment in nodes._items: + # Query for first level children (comment replies) + replies = Node.all({ + 'where': '{"node_type" : "comment", "parent": "%s"}' % (comment._id), + 'embedded': '{"user":1}'}, api=api) + replies = replies._items if replies._items else None + if replies: + replies = [format_comment(reply, is_reply=True) for reply in replies] + + comments.append( + format_comment(comment, is_reply=False, replies=replies)) + + return_content = jsonify(items=[c for c in comments if c is not None]) + else: + parent_node = Node.find(parent_id, api=api) + project = Project({'_id': parent_node.project}) + has_method_POST = project.node_type_has_method('comment', 'POST', api=api) + # Data will be requested via javascript + return_content = render_template('nodes/custom/_comments.html', + parent_id=parent_id, + has_method_POST=has_method_POST) + return return_content + + +@blueprint.route("/comments//rate/", methods=['POST']) +@login_required +def comments_rate(comment_id, operation): + """Comment rating function + + :param comment_id: the comment id + :type comment_id: str + :param rating: the rating (is cast from 0 to False and from 1 to True) + :type rating: int + + """ + + if operation not in {u'revoke', u'upvote', u'downvote'}: + raise wz_exceptions.BadRequest('Invalid operation') + + api = system_util.pillar_api() + + comment = Node.find(comment_id, {'projection': {'_id': 1}}, api=api) + if not comment: + log.info('Node %i not found; how could someone click on the upvote/downvote button?', + comment_id) + raise wz_exceptions.NotFound() + + # PATCH the node and return the result. + result = comment.patch({'op': operation}, api=api) + assert result['_status'] == 'OK' + + return jsonify({ + 'status': 'success', + 'data': { + 'op': operation, + 'rating_positive': result.properties.rating_positive, + 'rating_negative': result.properties.rating_negative, + }}) diff --git a/pillar/web/nodes/custom/groups.py b/pillar/web/nodes/custom/groups.py new file mode 100644 index 00000000..fcada244 --- /dev/null +++ b/pillar/web/nodes/custom/groups.py @@ -0,0 +1,36 @@ +from flask import request +from flask import jsonify +from flask.ext.login import login_required +from flask.ext.login import current_user +from pillarsdk import Node +from pillar.web.utils import system_util +from ..routes import blueprint + + +@blueprint.route('/groups/create', methods=['POST']) +@login_required +def groups_create(): + # Use current_project_id from the session instead of the cookie + name = request.form['name'] + project_id = request.form['project_id'] + parent_id = request.form.get('parent_id') + + api = system_util.pillar_api() + # We will create the Node object later on, after creating the file object + node_asset_props = dict( + name=name, + user=current_user.objectid, + node_type='group', + project=project_id, + properties=dict( + status='published')) + # Add parent_id only if provided (we do not provide it when creating groups + # at the Project root) + if parent_id: + node_asset_props['parent'] = parent_id + + node_asset = Node(node_asset_props) + node_asset.create(api=api) + return jsonify( + status='success', + data=dict(name=name, asset_id=node_asset._id)) diff --git a/pillar/web/nodes/custom/posts.py b/pillar/web/nodes/custom/posts.py new file mode 100644 index 00000000..0f8e0c07 --- /dev/null +++ b/pillar/web/nodes/custom/posts.py @@ -0,0 +1,168 @@ +from pillarsdk import Node +from pillarsdk import Project +from pillarsdk.exceptions import ResourceNotFound +from flask import abort +from flask import render_template +from flask import redirect +from flask.ext.login import login_required +from flask.ext.login import current_user +from pillar.web.utils import system_util +from pillar.web.utils import attach_project_pictures +from pillar.web.utils import get_file + +from pillar.web.nodes.routes import blueprint +from pillar.web.nodes.routes import url_for_node +from pillar.web.nodes.forms import get_node_form +from pillar.web.nodes.forms import process_node_form +from pillar.web.projects.routes import project_update_nodes_list + + +def posts_view(project_id, url=None): + """View individual blogpost""" + api = system_util.pillar_api() + # Fetch project (for backgroud images and links generation) + project = Project.find(project_id, api=api) + attach_project_pictures(project, api) + try: + blog = Node.find_one({ + 'where': {'node_type': 'blog', 'project': project_id}, + }, api=api) + except ResourceNotFound: + abort(404) + if url: + try: + post = Node.find_one({ + 'where': '{"parent": "%s", "properties.url": "%s"}' % (blog._id, url), + 'embedded': '{"node_type": 1, "user": 1}', + }, api=api) + if post.picture: + post.picture = get_file(post.picture, api=api) + except ResourceNotFound: + return abort(404) + + # If post is not published, check that the user is also the author of + # the post. If not, return 404. + if post.properties.status != "published": + if current_user.is_authenticated: + if not post.has_method('PUT'): + abort(403) + else: + abort(403) + + return render_template( + 'nodes/custom/post/view.html', + blog=blog, + node=post, + project=project, + title='blog', + api=api) + else: + node_type_post = project.get_node_type('post') + status_query = "" if blog.has_method('PUT') else ', "properties.status": "published"' + posts = Node.all({ + 'where': '{"parent": "%s" %s}' % (blog._id, status_query), + 'embedded': '{"user": 1}', + 'sort': '-_created' + }, api=api) + + for post in posts._items: + post.picture = get_file(post.picture, api=api) + + return render_template( + 'nodes/custom/blog/index.html', + node_type_post=node_type_post, + posts=posts._items, + project=project, + title='blog', + api=api) + + +@blueprint.route("/posts//create", methods=['GET', 'POST']) +@login_required +def posts_create(project_id): + api = system_util.pillar_api() + try: + project = Project.find(project_id, api=api) + except ResourceNotFound: + return abort(404) + attach_project_pictures(project, api) + + blog = Node.find_one({ + 'where': {'node_type': 'blog', 'project': project_id}}, api=api) + node_type = project.get_node_type('post') + # Check if user is allowed to create a post in the blog + if not project.node_type_has_method('post', 'POST', api=api): + return abort(403) + form = get_node_form(node_type) + if form.validate_on_submit(): + # Create new post object from scratch + post_props = dict( + node_type='post', + name=form.name.data, + picture=form.picture.data, + user=current_user.objectid, + parent=blog._id, + project=project._id, + properties=dict( + content=form.content.data, + status=form.status.data, + url=form.url.data)) + if form.picture.data == '': + post_props['picture'] = None + post = Node(post_props) + post.create(api=api) + # Only if the node is set as published, push it to the list + if post.properties.status == 'published': + project_update_nodes_list(post, project_id=project._id, list_name='blog') + return redirect(url_for_node(node=post)) + form.parent.data = blog._id + return render_template('nodes/custom/post/create.html', + node_type=node_type, + form=form, + project=project, + api=api) + + +@blueprint.route("/posts//edit", methods=['GET', 'POST']) +@login_required +def posts_edit(post_id): + api = system_util.pillar_api() + + try: + post = Node.find(post_id, { + 'embedded': '{"user": 1}'}, api=api) + except ResourceNotFound: + return abort(404) + # Check if user is allowed to edit the post + if not post.has_method('PUT'): + return abort(403) + + project = Project.find(post.project, api=api) + attach_project_pictures(project, api) + + node_type = project.get_node_type(post.node_type) + form = get_node_form(node_type) + if form.validate_on_submit(): + if process_node_form(form, node_id=post_id, node_type=node_type, + user=current_user.objectid): + # The the post is published, add it to the list + if form.status.data == 'published': + project_update_nodes_list(post, project_id=project._id, list_name='blog') + return redirect(url_for_node(node=post)) + form.parent.data = post.parent + form.name.data = post.name + form.content.data = post.properties.content + form.status.data = post.properties.status + form.url.data = post.properties.url + if post.picture: + form.picture.data = post.picture + # Embed picture file + post.picture = get_file(post.picture, api=api) + if post.properties.picture_square: + form.picture_square.data = post.properties.picture_square + return render_template('nodes/custom/post/edit.html', + node_type=node_type, + post=post, + form=form, + project=project, + api=api) diff --git a/pillar/web/nodes/custom/storage.py b/pillar/web/nodes/custom/storage.py new file mode 100644 index 00000000..704487de --- /dev/null +++ b/pillar/web/nodes/custom/storage.py @@ -0,0 +1,31 @@ +import requests +import os +from pillar.web.utils import system_util + + +class StorageNode(object): + path = "storage" + + def __init__(self, storage_node): + self.storage_node = storage_node + + @property + def entrypoint(self): + return os.path.join( + system_util.pillar_server_endpoint(), + self.path, + self.storage_node.properties.backend, + self.storage_node.properties.project, + self.storage_node.properties.subdir) + + # @current_app.cache.memoize(timeout=3600) + def browse(self, path=None): + """Search a storage node for a path, which can point both to a directory + of to a file. + """ + if path is None: + url = self.entrypoint + else: + url = os.path.join(self.entrypoint, path) + r = requests.get(url) + return r.json() diff --git a/pillar/web/nodes/forms.py b/pillar/web/nodes/forms.py new file mode 100644 index 00000000..fcb27ee0 --- /dev/null +++ b/pillar/web/nodes/forms.py @@ -0,0 +1,289 @@ +import logging + +from datetime import datetime +from datetime import date +import pillarsdk +from flask import current_app +from flask_wtf import Form +from wtforms import StringField +from wtforms import DateField +from wtforms import SelectField +from wtforms import HiddenField +from wtforms import BooleanField +from wtforms import IntegerField +from wtforms import FloatField +from wtforms import TextAreaField +from wtforms import DateTimeField +from wtforms import SelectMultipleField +from wtforms import FieldList +from wtforms.validators import DataRequired +from pillar.web.utils import system_util +from pillar.web.utils.forms import FileSelectField +from pillar.web.utils.forms import ProceduralFileSelectForm +from pillar.web.utils.forms import CustomFormField +from pillar.web.utils.forms import build_file_select_form + +log = logging.getLogger(__name__) + + +def add_form_properties(form_class, node_schema, form_schema, prefix=''): + """Add fields to a form based on the node and form schema provided. + :type node_schema: dict + :param node_schema: the validation schema used by Cerberus + :type form_class: class + :param form_class: The form class to which we append fields + :type form_schema: dict + :param form_schema: description of how to build the form (which fields to + show and hide) + """ + + for prop, schema_prop in node_schema.iteritems(): + form_prop = form_schema.get(prop, {}) + if prop == 'items': + continue + if not form_prop.get('visible', True): + continue + prop_name = "{0}{1}".format(prefix, prop) + + # Recursive call if detects a dict + field_type = schema_prop['type'] + if field_type == 'dict': + # This works if the dictionary schema is hardcoded. + # If we define it using propertyschema and valueschema, this + # validation pattern does not work and crahses. + add_form_properties(form_class, schema_prop['schema'], + form_prop['schema'], "{0}__".format(prop_name)) + continue + + if field_type == 'list': + if prop == 'attachments': + # class AttachmentForm(Form): + # pass + # AttachmentForm.file = FileSelectField('file') + # AttachmentForm.size = StringField() + # AttachmentForm.slug = StringField() + field = FieldList(CustomFormField(ProceduralFileSelectForm)) + elif prop == 'files': + schema = schema_prop['schema']['schema'] + file_select_form = build_file_select_form(schema) + field = FieldList(CustomFormField(file_select_form), + min_entries=1) + elif 'allowed' in schema_prop['schema']: + choices = [(c, c) for c in schema_prop['schema']['allowed']] + field = SelectMultipleField(choices=choices) + else: + field = SelectMultipleField(choices=[]) + elif 'allowed' in schema_prop: + select = [] + for option in schema_prop['allowed']: + select.append((str(option), str(option))) + field = SelectField(choices=select) + elif field_type == 'datetime': + if form_prop.get('dateonly'): + field = DateField(prop_name, default=date.today()) + else: + field = DateTimeField(prop_name, default=datetime.now()) + elif field_type == 'integer': + field = IntegerField(prop_name, default=0) + elif field_type == 'float': + field = FloatField(prop_name, default=0) + elif field_type == 'boolean': + field = BooleanField(prop_name) + elif field_type == 'objectid' and 'data_relation' in schema_prop: + if schema_prop['data_relation']['resource'] == 'files': + field = FileSelectField(prop_name) + else: + field = StringField(prop_name) + elif schema_prop.get('maxlength', 0) > 64: + field = TextAreaField(prop_name) + else: + field = StringField(prop_name) + + setattr(form_class, prop_name, field) + + +def get_node_form(node_type): + """Get a procedurally generated WTForm, based on the dyn_schema and + node_schema of a specific node_type. + :type node_type: dict + :param node_type: Describes the node type via dyn_schema, form_schema and + parent + """ + class ProceduralForm(Form): + pass + + node_schema = node_type['dyn_schema'].to_dict() + form_prop = node_type['form_schema'].to_dict() + parent_prop = node_type['parent'] + + ProceduralForm.name = StringField('Name', validators=[DataRequired()]) + # Parenting + if parent_prop: + parent_names = ", ".join(parent_prop) + ProceduralForm.parent = HiddenField('Parent ({0})'.format(parent_names)) + + ProceduralForm.description = TextAreaField('Description') + ProceduralForm.picture = FileSelectField('Picture', file_format='image') + ProceduralForm.node_type = HiddenField(default=node_type['name']) + + add_form_properties(ProceduralForm, node_schema, form_prop) + + return ProceduralForm() + + +def recursive(path, rdict, data): + item = path.pop(0) + if not item in rdict: + rdict[item] = {} + if len(path) > 0: + rdict[item] = recursive(path, rdict[item], data) + else: + rdict[item] = data + return rdict + + +def process_node_form(form, node_id=None, node_type=None, user=None): + """Generic function used to process new nodes, as well as edits + """ + if not user: + log.warning('process_node_form(node_id=%s) called while user not logged in', node_id) + return False + + api = system_util.pillar_api() + node_schema = node_type['dyn_schema'].to_dict() + form_schema = node_type['form_schema'].to_dict() + + if node_id: + # Update existing node + node = pillarsdk.Node.find(node_id, api=api) + node.name = form.name.data + node.description = form.description.data + if 'picture' in form: + node.picture = form.picture.data + if node.picture == 'None' or node.picture == '': + node.picture = None + if 'parent' in form: + if form.parent.data != "": + node.parent = form.parent.data + + def update_data(node_schema, form_schema, prefix=""): + for pr in node_schema: + schema_prop = node_schema[pr] + form_prop = form_schema.get(pr, {}) + if pr == 'items': + continue + if 'visible' in form_prop and not form_prop['visible']: + continue + prop_name = "{0}{1}".format(prefix, pr) + if schema_prop['type'] == 'dict': + update_data( + schema_prop['schema'], + form_prop['schema'], + "{0}__".format(prop_name)) + continue + data = form[prop_name].data + if schema_prop['type'] == 'dict': + if data == 'None': + continue + elif schema_prop['type'] == 'integer': + if data == '': + data = 0 + else: + data = int(form[prop_name].data) + elif schema_prop['type'] == 'datetime': + data = datetime.strftime(data, + app.config['RFC1123_DATE_FORMAT']) + elif schema_prop['type'] == 'list': + if pr == 'attachments': + # data = json.loads(data) + data = [dict(field='description', files=data)] + elif pr == 'files': + # Only keep those items that actually refer to a file. + data = [file_item for file_item in data + if file_item.get('file')] + # elif pr == 'tags': + # data = [tag.strip() for tag in data.split(',')] + elif schema_prop['type'] == 'objectid': + if data == '': + # Set empty object to None so it gets removed by the + # SDK before node.update() + data = None + else: + if pr in form: + data = form[prop_name].data + path = prop_name.split('__') + if len(path) > 1: + recursive_prop = recursive( + path, node.properties.to_dict(), data) + node.properties = recursive_prop + else: + node.properties[prop_name] = data + update_data(node_schema, form_schema) + ok = node.update(api=api) + if not ok: + log.warning('Unable to update node: %s', node.error) + # if form.picture.data: + # image_data = request.files[form.picture.name].read() + # post = node.replace_picture(image_data, api=api) + return ok + else: + # Create a new node + node = pillarsdk.Node() + prop = {} + files = {} + prop['name'] = form.name.data + prop['description'] = form.description.data + prop['user'] = user + if 'picture' in form: + prop['picture'] = form.picture.data + if prop['picture'] == 'None' or prop['picture'] == '': + prop['picture'] = None + if 'parent' in form: + prop['parent'] = form.parent.data + prop['properties'] = {} + + def get_data(node_schema, form_schema, prefix=""): + for pr in node_schema: + schema_prop = node_schema[pr] + form_prop = form_schema.get(pr, {}) + if pr == 'items': + continue + if 'visible' in form_prop and not form_prop['visible']: + continue + prop_name = "{0}{1}".format(prefix, pr) + if schema_prop['type'] == 'dict': + get_data( + schema_prop['schema'], + form_prop['schema'], + "{0}__".format(prop_name)) + continue + data = form[prop_name].data + if schema_prop['type'] == 'media': + tmpfile = '/tmp/binary_data' + data.save(tmpfile) + binfile = open(tmpfile, 'rb') + files[pr] = binfile + continue + if schema_prop['type'] == 'integer': + if data == '': + data = 0 + if schema_prop['type'] == 'list': + if data == '': + data = [] + if schema_prop['type'] == 'datetime': + data = datetime.strftime(data, app.config['RFC1123_DATE_FORMAT']) + if schema_prop['type'] == 'objectid': + if data == '': + data = None + path = prop_name.split('__') + if len(path) > 1: + prop['properties'] = recursive(path, prop['properties'], data) + else: + prop['properties'][prop_name] = data + + get_data(node_schema, form_schema) + + prop['node_type'] = form.node_type_id.data + post = node.post(prop, api=api) + + return post diff --git a/pillar/web/nodes/routes.py b/pillar/web/nodes/routes.py new file mode 100644 index 00000000..91293f7a --- /dev/null +++ b/pillar/web/nodes/routes.py @@ -0,0 +1,688 @@ +import os +import json +import logging +from datetime import datetime + +import pillarsdk +from pillarsdk import Node +from pillarsdk import Project +from pillarsdk.exceptions import ResourceNotFound +from pillarsdk.exceptions import ForbiddenAccess + +from flask import Blueprint, current_app +from flask import redirect +from flask import render_template +from flask import url_for +from flask import request +from flask import jsonify +from flask import abort +from flask_login import current_user +from werkzeug.exceptions import NotFound +from wtforms import SelectMultipleField +from flask.ext.login import login_required +from jinja2.exceptions import TemplateNotFound + +from pillar.web.utils import caching +from pillar.web.nodes.forms import get_node_form +from pillar.web.nodes.forms import process_node_form +from pillar.web.nodes.custom.storage import StorageNode +from pillar.web.projects.routes import project_update_nodes_list +from pillar.web.utils import get_file +from pillar.web.utils.jstree import jstree_build_children +from pillar.web.utils.jstree import jstree_build_from_node +from pillar.web.utils.forms import ProceduralFileSelectForm +from pillar.web.utils.forms import build_file_select_form +from pillar.web import system_util + +blueprint = Blueprint('nodes', __name__) +log = logging.getLogger(__name__) + + +def get_node(node_id, user_id): + api = system_util.pillar_api() + node = Node.find(node_id + '/?embedded={"node_type":1}', api=api) + return node.to_dict() + + +def get_node_children(node_id, node_type_name, user_id): + """This function is currently unused since it does not give significant + performance improvements. + """ + api = system_util.pillar_api() + if node_type_name == 'group': + published_status = ',"properties.status": "published"' + else: + published_status = '' + + children = Node.all({ + 'where': '{"parent": "%s" %s}' % (node_id, published_status), + 'embedded': '{"node_type": 1}'}, api=api) + return children.to_dict() + + +@blueprint.route("//jstree") +def jstree(node_id): + """JsTree view. + + This return a lightweight version of the node, to be used by JsTree in the + frontend. We have two possible cases: + - https://pillar//jstree (construct the whole + expanded tree starting from the node_id. Use only once) + - https://pillar//jstree&children=1 (deliver the + children of a node - use in the navigation of the tree) + """ + + # Get node with basic embedded data + api = system_util.pillar_api() + node = Node.find(node_id, { + 'projection': { + 'name': 1, + 'node_type': 1, + 'parent': 1, + 'project': 1, + 'properties.content_type': 1, + } + }, api=api) + + if request.args.get('children') != '1': + return jsonify(items=jstree_build_from_node(node)) + + if node.node_type == 'storage': + storage = StorageNode(node) + # Check if we specify a path within the storage + path = request.args.get('path') + # Generate the storage listing + listing = storage.browse(path) + # Inject the current node id in the response, so that JsTree can + # expose the storage_node property and use it for further queries + listing['storage_node'] = node._id + if 'children' in listing: + for child in listing['children']: + child['storage_node'] = node._id + return jsonify(listing) + + return jsonify(jstree_build_children(node)) + + +@blueprint.route("//view") +def view(node_id): + api = system_util.pillar_api() + + # Get node, we'll embed linked objects later. + try: + node = Node.find(node_id, api=api) + except ResourceNotFound: + return render_template('errors/404_embed.html') + except ForbiddenAccess: + return render_template('errors/403_embed.html') + + node_type_name = node.node_type + + if node_type_name == 'post': + # Posts shouldn't be shown at this route, redirect to the correct one. + return redirect(url_for_node(node=node)) + + # Set the default name of the template path based on the node name + template_path = os.path.join('nodes', 'custom', node_type_name) + # Set the default action for a template. By default is view and we override + # it only if we are working storage nodes, where an 'index' is also possible + template_action = 'view' + + def allow_link(): + """Helper function to cross check if the user is authenticated, and it + is has the 'subscriber' role. Also, we check if the node has world GET + permissions, which means it's free. + """ + + # Check if node permissions for the world exist (if node is free) + if node.permissions and node.permissions.world: + return 'GET' in node.permissions.world + + if current_user.is_authenticated: + allowed_roles = {u'subscriber', u'demo', u'admin'} + return bool(allowed_roles.intersection(current_user.roles or ())) + + return False + + link_allowed = allow_link() + + node_type_handlers = { + 'asset': _view_handler_asset, + 'storage': _view_handler_storage, + 'texture': _view_handler_texture, + 'hdri': _view_handler_hdri, + } + if node_type_name in node_type_handlers: + handler = node_type_handlers[node_type_name] + template_path, template_action = handler(node, template_path, template_action, link_allowed) + # Fetch linked resources. + node.picture = get_file(node.picture, api=api) + node.user = node.user and pillarsdk.User.find(node.user, api=api) + + try: + node.parent = node.parent and pillarsdk.Node.find(node.parent, api=api) + except ForbiddenAccess: + # This can happen when a node has world-GET, but the parent doesn't. + node.parent = None + + # Get children + children_projection = {'project': 1, 'name': 1, 'picture': 1, 'parent': 1, + 'node_type': 1, 'properties.order': 1, 'properties.status': 1, + 'user': 1, 'properties.content_type': 1} + children_where = {'parent': node._id} + + if node_type_name == 'group': + children_where['properties.status'] = 'published' + children_projection['permissions.world'] = 1 + else: + children_projection['properties.files'] = 1 + children_projection['properties.is_tileable'] = 1 + + try: + children = Node.all({ + 'projection': children_projection, + 'where': children_where, + 'sort': [('properties.order', 1), ('name', 1)]}, api=api) + except ForbiddenAccess: + return render_template('errors/403_embed.html') + children = children._items + + for child in children: + child.picture = get_file(child.picture, api=api) + + if request.args.get('format') == 'json': + node = node.to_dict() + node['url_edit'] = url_for('nodes.edit', node_id=node['_id']) + return jsonify({ + 'node': node, + 'children': children.to_dict() if children else {}, + 'parent': node['parent'] if 'parent' in node else {} + }) + + if 't' in request.args: + template_path = os.path.join('nodes', 'custom', 'asset') + template_action = 'view_theatre' + + template_path = '{0}/{1}_embed.html'.format(template_path, template_action) + # template_path_full = os.path.join(current_app.config['TEMPLATES_PATH'], template_path) + # + # # Check if template exists on the filesystem + # if not os.path.exists(template_path_full): + # log.warning('Template %s does not exist for node type %s', + # template_path, node_type_name) + # raise NotFound("Missing template '{0}'".format(template_path)) + + return render_template(template_path, + node_id=node._id, + node=node, + parent=node.parent, + children=children, + config=current_app.config, + api=api) + + +def _view_handler_asset(node, template_path, template_action, link_allowed): + # Attach the file document to the asset node + node_file = get_file(node.properties.file) + node.file = node_file + + # Remove the link to the file if it's not allowed. + if node_file and not link_allowed: + node.file.link = None + + if node_file and node_file.content_type is not None: + asset_type = node_file.content_type.split('/')[0] + else: + asset_type = None + + if asset_type == 'video': + # Process video type and select video template + if link_allowed: + sources = [] + if node_file and node_file.variations: + for f in node_file.variations: + sources.append({'type': f.content_type, 'src': f.link}) + # Build a link that triggers download with proper filename + # TODO: move this to Pillar + if f.backend == 'cdnsun': + f.link = "{0}&name={1}.{2}".format(f.link, node.name, f.format) + node.video_sources = json.dumps(sources) + node.file_variations = node_file.variations + else: + node.video_sources = None + node.file_variations = None + elif asset_type != 'image': + # Treat it as normal file (zip, blend, application, etc) + asset_type = 'file' + + template_path = os.path.join(template_path, asset_type) + + return template_path, template_action + + +def _view_handler_storage(node, template_path, template_action, link_allowed): + storage = StorageNode(node) + path = request.args.get('path') + listing = storage.browse(path) + node.name = listing['name'] + listing['storage_node'] = node._id + # If the item has children we are working with a group + if 'children' in listing: + for child in listing['children']: + child['storage_node'] = node._id + child['name'] = child['text'] + child['content_type'] = os.path.dirname(child['type']) + node.children = listing['children'] + template_action = 'index' + else: + node.status = 'published' + node.length = listing['size'] + node.download_link = listing['signed_url'] + return template_path, template_action + + +def _view_handler_texture(node, template_path, template_action, link_allowed): + for f in node.properties.files: + f.file = get_file(f.file) + # Remove the link to the file if it's not allowed. + if f.file and not link_allowed: + f.file.link = None + + return template_path, template_action + + +def _view_handler_hdri(node, template_path, template_action, link_allowed): + if not link_allowed: + node.properties.files = None + else: + for f in node.properties.files: + f.file = get_file(f.file) + + return template_path, template_action + + +@blueprint.route("//edit", methods=['GET', 'POST']) +@login_required +def edit(node_id): + """Generic node editing form + """ + + def set_properties(dyn_schema, form_schema, node_properties, form, + prefix="", + set_data=True): + """Initialize custom properties for the form. We run this function once + before validating the function with set_data=False, so that we can set + any multiselect field that was originally specified empty and fill it + with the current choices. + """ + for prop in dyn_schema: + schema_prop = dyn_schema[prop] + form_prop = form_schema.get(prop, {}) + prop_name = "{0}{1}".format(prefix, prop) + + if schema_prop['type'] == 'dict': + set_properties( + schema_prop['schema'], + form_prop['schema'], + node_properties[prop_name], + form, + "{0}__".format(prop_name)) + continue + + if prop_name not in form: + continue + + try: + db_prop_value = node_properties[prop] + except KeyError: + log.debug('%s not found in form for node %s', prop_name, node_id) + continue + + if schema_prop['type'] == 'datetime': + db_prop_value = datetime.strptime(db_prop_value, + current_app.config['RFC1123_DATE_FORMAT']) + + if isinstance(form[prop_name], SelectMultipleField): + # If we are dealing with a multiselect field, check if + # it's empty (usually because we can't query the whole + # database to pick all the choices). If it's empty we + # populate the choices with the actual data. + if not form[prop_name].choices: + form[prop_name].choices = [(d, d) for d in db_prop_value] + # Choices should be a tuple with value and name + + # Assign data to the field + if set_data: + if prop_name == 'attachments': + for attachment_collection in db_prop_value: + for a in attachment_collection['files']: + attachment_form = ProceduralFileSelectForm() + attachment_form.file = a['file'] + attachment_form.slug = a['slug'] + attachment_form.size = 'm' + form[prop_name].append_entry(attachment_form) + + elif prop_name == 'files': + schema = schema_prop['schema']['schema'] + # Extra entries are caused by min_entries=1 in the form + # creation. + field_list = form[prop_name] + if len(db_prop_value) > 0: + while len(field_list): + field_list.pop_entry() + + for file_data in db_prop_value: + file_form_class = build_file_select_form(schema) + subform = file_form_class() + for key, value in file_data.iteritems(): + setattr(subform, key, value) + field_list.append_entry(subform) + + # elif prop_name == 'tags': + # form[prop_name].data = ', '.join(data) + else: + form[prop_name].data = db_prop_value + else: + # Default population of multiple file form list (only if + # we are getting the form) + if request.method == 'POST': + continue + if prop_name == 'attachments': + if not db_prop_value: + attachment_form = ProceduralFileSelectForm() + attachment_form.file = 'file' + attachment_form.slug = '' + attachment_form.size = '' + form[prop_name].append_entry(attachment_form) + + api = system_util.pillar_api() + node = Node.find(node_id, api=api) + project = Project.find(node.project, api=api) + node_type = project.get_node_type(node.node_type) + form = get_node_form(node_type) + user_id = current_user.objectid + dyn_schema = node_type['dyn_schema'].to_dict() + form_schema = node_type['form_schema'].to_dict() + error = "" + + node_properties = node.properties.to_dict() + + ensure_lists_exist_as_empty(node.to_dict(), node_type) + set_properties(dyn_schema, form_schema, node_properties, form, + set_data=False) + + if form.validate_on_submit(): + if process_node_form(form, node_id=node_id, node_type=node_type, user=user_id): + # Handle the specific case of a blog post + if node_type.name == 'post': + project_update_nodes_list(node, list_name='blog') + else: + project_update_nodes_list(node) + # Emergency hardcore cache flush + # cache.clear() + return redirect(url_for('nodes.view', node_id=node_id, embed=1, + _external=True, + _scheme=current_app.config['SCHEME'])) + else: + log.debug('Error sending data to Pillar, see Pillar logs.') + error = 'Server error' + else: + if form.errors: + log.debug('Form errors: %s', form.errors) + + # Populate Form + form.name.data = node.name + form.description.data = node.description + if 'picture' in form: + form.picture.data = node.picture + if node.parent: + form.parent.data = node.parent + + set_properties(dyn_schema, form_schema, node_properties, form) + + # Get previews + node.picture = get_file(node.picture, api=api) if node.picture else None + + # Get Parent + try: + parent = Node.find(node['parent'], api=api) + except KeyError: + parent = None + except ResourceNotFound: + parent = None + + embed_string = '' + # Check if we want to embed the content via an AJAX call + if request.args.get('embed'): + if request.args.get('embed') == '1': + # Define the prefix for the embedded template + embed_string = '_embed' + + template = '{0}/edit{1}.html'.format(node_type['name'], embed_string) + + # We should more simply check if the template file actually exsists on + # the filesystem level + try: + return render_template( + template, + node=node, + parent=parent, + form=form, + errors=form.errors, + error=error, + api=api) + except TemplateNotFound: + template = 'nodes/edit{1}.html'.format(node_type['name'], embed_string) + return render_template( + template, + node=node, + parent=parent, + form=form, + errors=form.errors, + error=error, + api=api) + + +def ensure_lists_exist_as_empty(node_doc, node_type): + """Ensures that any properties of type 'list' exist as empty lists. + + This allows us to iterate over lists without worrying that they + are set to None. Only works for top-level list properties. + """ + + node_properties = node_doc.setdefault('properties', {}) + + for prop, schema in node_type.dyn_schema.to_dict().iteritems(): + if schema['type'] != 'list': + continue + + if node_properties.get(prop) is None: + node_properties[prop] = [] + + +@blueprint.route('/create', methods=['POST']) +@login_required +def create(): + """Create a node. Requires a number of params: + + - project id + - node_type + - parent node (optional) + """ + if request.method != 'POST': + return abort(403) + + project_id = request.form['project_id'] + parent_id = request.form.get('parent_id') + node_type_name = request.form['node_type_name'] + + api = system_util.pillar_api() + # Fetch the Project or 404 + try: + project = Project.find(project_id, api=api) + except ResourceNotFound: + return abort(404) + + node_type = project.get_node_type(node_type_name) + node_type_name = 'folder' if node_type['name'] == 'group' else \ + node_type['name'] + + node_props = dict( + name='New {}'.format(node_type_name), + project=project['_id'], + user=current_user.objectid, + node_type=node_type['name'], + properties={} + ) + + if parent_id: + node_props['parent'] = parent_id + + ensure_lists_exist_as_empty(node_props, node_type) + + node = Node(node_props) + node.create(api=api) + + return jsonify(status='success', data=dict(asset_id=node['_id'])) + + +@blueprint.route("//redir") +def redirect_to_context(node_id): + """Redirects to the context URL of the node. + + Comment: redirects to whatever the comment is attached to + #node_id + (unless 'whatever the comment is attached to' already contains '#', then + '#node_id' isn't appended) + Post: redirects to main or project-specific blog post + Other: redirects to project.url + #node_id + """ + + if node_id.lower() == '{{objectid}}': + log.warning("JavaScript should have filled in the ObjectID placeholder, but didn't. " + "URL=%s and referrer=%s", + request.url, request.referrer) + raise NotFound('Invalid ObjectID') + + try: + url = url_for_node(node_id) + except ValueError as ex: + log.warning("%s: URL=%s and referrer=%s", + str(ex), request.url, request.referrer) + raise NotFound('Invalid ObjectID') + + return redirect(url) + + +def url_for_node(node_id=None, node=None): + assert isinstance(node_id, (basestring, type(None))) + + api = system_util.pillar_api() + + # Find node by its ID, or the ID by the node, depending on what was passed + # as parameters. + if node is None: + try: + node = Node.find(node_id, api=api) + except ResourceNotFound: + log.warning( + 'url_for_node(node_id=%r, node=None): Unable to find node.', + node_id) + raise ValueError('Unable to find node %r' % node_id) + elif node_id is None: + node_id = node['_id'] + else: + raise ValueError('Either node or node_id must be given') + + return _find_url_for_node(node_id, node=node) + + +@caching.cache_for_request() +def project_url(project_id, project): + """Returns the project, raising a ValueError if it can't be found. + + Uses the "urler" service endpoint. + """ + + if project is not None: + return project + + urler_api = system_util.pillar_api( + token=current_app.config['URLER_SERVICE_AUTH_TOKEN']) + return Project.find_from_endpoint( + '/service/urler/%s' % project_id, api=urler_api) + + +# Cache the actual URL based on the node ID, for the duration of the request. +@caching.cache_for_request() +def _find_url_for_node(node_id, node): + api = system_util.pillar_api() + + # Find the node's project, or its ID, depending on whether a project + # was embedded. This is needed in two of the three finder functions. + project_id = node.project + if isinstance(project_id, pillarsdk.Resource): + # Embedded project + project = project_id + project_id = project['_id'] + else: + project = None + + def find_for_comment(): + """Returns the URL for a comment.""" + + parent = node + while parent.node_type == 'comment': + if isinstance(parent.parent, pillarsdk.Resource): + parent = parent.parent + continue + + try: + parent = Node.find(parent.parent, api=api) + except ResourceNotFound: + log.warning( + 'url_for_node(node_id=%r): Unable to find parent node %r', + node_id, parent.parent) + raise ValueError('Unable to find parent node %r' % parent.parent) + + # Find the redirection URL for the parent node. + parent_url = url_for_node(node=parent) + if '#' in parent_url: + # We can't attach yet another fragment, so just don't link to + # the comment for now. + return parent_url + return parent_url + '#{}'.format(node_id) + + def find_for_post(): + """Returns the URL for a blog post.""" + + if str(project_id) == current_app.config['MAIN_PROJECT_ID']: + return url_for('main.main_blog', + url=node.properties.url) + + the_project = project_url(project_id, project=project) + return url_for('main.project_blog', + project_url=the_project.url, + url=node.properties.url) + + # Fallback: Assets, textures, and other node types. + def find_for_other(): + the_project = project_url(project_id, project=project) + return url_for('projects.view_node', + project_url=the_project.url, + node_id=node_id) + + # Determine which function to use to find the correct URL. + url_finders = { + 'comment': find_for_comment, + 'post': find_for_post, + } + + finder = url_finders.get(node.node_type, find_for_other) + return finder() + + +# Import of custom modules (using the same nodes decorator) +import custom.comments +import custom.groups +import custom.storage +import custom.posts diff --git a/pillar/web/notifications/__init__.py b/pillar/web/notifications/__init__.py new file mode 100644 index 00000000..a08edd3a --- /dev/null +++ b/pillar/web/notifications/__init__.py @@ -0,0 +1,126 @@ +import logging +from flask import jsonify +from flask import Blueprint +from flask import request +from flask import url_for +from flask import abort +from flask.ext.login import login_required +from flask.ext.login import current_user +from pillarsdk.activities import Notification +from pillarsdk.activities import ActivitySubscription +from pillar.web.utils import system_util +from pillar.web.utils import pretty_date + +log = logging.getLogger(__name__) +blueprint = Blueprint('notifications', __name__) + + +def notification_parse(notification): + if notification.actor: + username = notification.actor['username'] + avatar = notification.actor['avatar'] + else: + return None + return dict( + _id=notification['_id'], + username=username, + username_avatar=avatar, + action=notification.action, + object_type=notification.object_type, + object_name=notification.object_name, + object_url=url_for( + 'nodes.redirect_to_context', node_id=notification.object_id), + context_object_type=notification.context_object_type, + context_object_name=notification.context_object_name, + context_object_url=url_for( + 'nodes.redirect_to_context', node_id=notification.context_object_id), + date=pretty_date(notification['_created'], detail=True), + is_read=notification.is_read, + is_subscribed=notification.is_subscribed, + subscription=notification.subscription + ) + + +@blueprint.route('/') +@login_required +def index(): + """Get notifications for the current user. + + Optional url args: + - limit: limits the number of notifications + """ + limit = request.args.get('limit', 25) + api = system_util.pillar_api() + user_notifications = Notification.all({ + 'where': {'user': str(current_user.objectid)}, + 'sort': '-_created', + 'max_results': str(limit), + 'parse': '1'}, api=api) + # TODO: properly investigate and handle missing actors + items = [notification_parse(n) for n in user_notifications['_items'] if + notification_parse(n)] + + return jsonify(items=items) + + +@blueprint.route('//read-toggle') +@login_required +def action_read_toggle(notification_id): + api = system_util.pillar_api() + notification = Notification.find(notification_id, api=api) + if notification.user == current_user.objectid: + notification.is_read = not notification.is_read + notification.update(api=api) + return jsonify( + status='success', + data=dict( + message="Notification {0} is_read {1}".format( + notification_id, + notification.is_read), + is_read=notification.is_read)) + else: + return abort(403) + + +@blueprint.route('/read-all') +@login_required +def action_read_all(): + """Mark all notifications as read""" + api = system_util.pillar_api() + notifications = Notification.all({ + 'where': '{"user": "%s"}' % current_user.objectid, + 'sort': '-_created'}, api=api) + + for notification in notifications._items: + notification = Notification.find(notification._id, api=api) + notification.is_read = True + notification.update(api=api) + + return jsonify( + status='success', + data=dict(message="All notifications mark as read")) + + +@blueprint.route('//subscription-toggle') +@login_required +def action_subscription_toggle(notification_id): + """Given a notification id, get the ActivitySubscription and update it by + toggling the notifications status for the web key. + """ + api = system_util.pillar_api() + # Get the notification + notification = notification_parse( + Notification.find(notification_id, {'parse':'1'}, api=api)) + # Get the subscription and modify it + subscription = ActivitySubscription.find( + notification['subscription'], api=api) + subscription.notifications['web'] = not subscription.notifications['web'] + subscription.update(api=api) + return jsonify( + status='success', + data=dict(message="You have been {}subscribed".format( + '' if subscription.notifications['web'] else 'un'))) + + +def setup_app(app, url_prefix=None): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/web/projects/__init__.py b/pillar/web/projects/__init__.py new file mode 100644 index 00000000..9d09c695 --- /dev/null +++ b/pillar/web/projects/__init__.py @@ -0,0 +1,5 @@ +from .routes import blueprint + + +def setup_app(app, url_prefix=None): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/web/projects/forms.py b/pillar/web/projects/forms.py new file mode 100644 index 00000000..e75422d4 --- /dev/null +++ b/pillar/web/projects/forms.py @@ -0,0 +1,63 @@ +from flask_wtf import Form +from wtforms import StringField +from wtforms import BooleanField +from wtforms import HiddenField +from wtforms import TextAreaField +from wtforms import SelectField +from wtforms.validators import DataRequired +from wtforms.validators import Length +from pillarsdk.projects import Project +from pillarsdk import exceptions as sdk_exceptions +from pillar.web import system_util +from pillar.web.utils.forms import FileSelectField, JSONRequired + + +class ProjectForm(Form): + project_id = HiddenField('project_id', validators=[DataRequired()]) + name = StringField('Name', validators=[DataRequired()]) + url = StringField('Url', validators=[DataRequired()]) + summary = StringField('Summary', validators=[Length(min=1, max=128)]) + description = TextAreaField('Description', validators=[DataRequired()]) + is_private = BooleanField('Private') + category = SelectField('Category', choices=[ + ('film', 'Film'), + ('training', 'Training'), + ('assets', 'Assets')]) + status = SelectField('Status', choices=[ + ('published', 'Published'), + ('pending', 'Pending'), + ('deleted', 'Deleted')]) + picture_header = FileSelectField('Picture header', file_format='image') + picture_square = FileSelectField('Picture square', file_format='image') + + def validate(self): + rv = Form.validate(self) + if not rv: + return False + + api = system_util.pillar_api() + project = Project.find(self.project_id.data, api=api) + if project.url == self.url.data: + # Same URL as before, so that's fine. + return True + + try: + project_url = Project.find_one({'where': {'url': self.url.data}}, api=api) + except sdk_exceptions.ResourceNotFound: + # Not found, so the URL is fine. + return True + + if project_url: + self.url.errors.append('Sorry, project url already exists!') + return False + return True + + +class NodeTypeForm(Form): + project_id = HiddenField('project_id', validators=[DataRequired()]) + name = StringField('Name', validators=[DataRequired()]) + parent = StringField('Parent') + description = TextAreaField('Description') + dyn_schema = TextAreaField('Schema', validators=[JSONRequired()]) + form_schema = TextAreaField('Form Schema', validators=[JSONRequired()]) + permissions = TextAreaField('Permissions', validators=[JSONRequired()]) diff --git a/pillar/web/projects/routes.py b/pillar/web/projects/routes.py new file mode 100644 index 00000000..c62ac61d --- /dev/null +++ b/pillar/web/projects/routes.py @@ -0,0 +1,771 @@ +import json +import logging + +from pillarsdk import Node +from pillarsdk import Project +from pillarsdk.exceptions import ResourceNotFound +from pillarsdk.exceptions import ForbiddenAccess +from flask import Blueprint, current_app +from flask import render_template +from flask import request +from flask import jsonify +from flask import session +from flask import abort +from flask import redirect +from flask import url_for +from flask.ext.login import login_required +from flask.ext.login import current_user +import werkzeug.exceptions as wz_exceptions + +from pillar.web import system_util +from pillar.web.utils import get_file +from pillar.web.utils import attach_project_pictures +from pillar.web.utils.jstree import jstree_get_children +from pillar.web.utils import gravatar +from .forms import ProjectForm +from .forms import NodeTypeForm + +blueprint = Blueprint('projects', __name__) +log = logging.getLogger(__name__) +SYNC_GROUP_NODE_NAME = 'Blender Sync' +IMAGE_SHARING_GROUP_NODE_NAME = 'Image sharing' + + +@blueprint.route('/') +@login_required +def index(): + api = system_util.pillar_api() + + # Get all projects, except the home project. + projects_user = Project.all({ + 'where': {'user': current_user.objectid, + 'category': {'$ne': 'home'}}, + 'sort': '-_created' + }, api=api) + + projects_shared = Project.all({ + 'where': {'user': {'$ne': current_user.objectid}, + 'permissions.groups.group': {'$in': current_user.groups}, + 'is_private': True}, + 'sort': '-_created', + 'embedded': {'user': 1}, + }, api=api) + + # Attach project images + for project in projects_user['_items']: + attach_project_pictures(project, api) + + for project in projects_shared['_items']: + attach_project_pictures(project, api) + + return render_template( + 'projects/index_dashboard.html', + gravatar=gravatar(current_user.email, size=128), + projects_user=projects_user['_items'], + projects_shared=projects_shared['_items'], + api=api) + + +@blueprint.route('//jstree') +def jstree(project_url): + """Entry point to view a project as JSTree""" + api = system_util.pillar_api() + + try: + project = Project.find_one({ + 'projection': {'_id': 1}, + 'where': {'url': project_url} + }, api=api) + except ResourceNotFound: + raise wz_exceptions.NotFound('No such project') + + return jsonify(items=jstree_get_children(None, project._id)) + + +@blueprint.route('/home/') +@login_required +def home_project(): + api = system_util.pillar_api() + project = _home_project(api) + + # Get the synchronised Blender versions + project_id = project['_id'] + synced_versions = synced_blender_versions(project_id, api) + + extra_context = { + 'synced_versions': synced_versions, + 'show_addon_download_buttons': True, + } + + return render_project(project, api, extra_context) + + +@blueprint.route('/home/images') +@login_required +def home_project_shared_images(): + api = system_util.pillar_api() + project = _home_project(api) + + # Get the shared images + project_id = project['_id'] + image_nodes = shared_image_nodes(project_id, api) + + extra_context = { + 'shared_images': image_nodes, + 'show_addon_download_buttons': current_user.has_role('subscriber', 'demo'), + } + + return render_project(project, api, extra_context, + template_name='projects/home_images.html') + + +def _home_project(api): + try: + project = Project.find_from_endpoint('/bcloud/home-project', api=api) + except ResourceNotFound: + log.warning('Home project for user %s not found', current_user.objectid) + raise wz_exceptions.NotFound('No such project') + + return project + + +def synced_blender_versions(home_project_id, api): + """Returns a list of Blender versions with synced settings. + + Returns a list of {'version': '2.77', 'date': datetime.datetime()} dicts. + Returns an empty list if no Blender versions were synced. + """ + + sync_group = Node.find_first({ + 'where': {'project': home_project_id, + 'node_type': 'group', + 'parent': None, + 'name': SYNC_GROUP_NODE_NAME}, + 'projection': {'_id': 1}}, + api=api) + + if not sync_group: + return [] + + sync_nodes = Node.all({ + 'where': {'project': home_project_id, + 'node_type': 'group', + 'parent': sync_group['_id']}, + 'projection': { + 'name': 1, + '_updated': 1, + }}, + api=api) + + sync_nodes = sync_nodes._items + if not sync_nodes: + return [] + + return [{'version': node.name, 'date': node._updated} + for node in sync_nodes] + + +def shared_image_nodes(home_project_id, api): + """Returns a list of pillarsdk.Node objects.""" + + parent_group = Node.find_first({ + 'where': {'project': home_project_id, + 'node_type': 'group', + 'parent': None, + 'name': IMAGE_SHARING_GROUP_NODE_NAME}, + 'projection': {'_id': 1}}, + api=api) + + if not parent_group: + log.debug('No image sharing parent node found.') + return [] + + nodes = Node.all({ + 'where': {'project': home_project_id, + 'node_type': 'asset', + 'properties.content_type': 'image', + 'parent': parent_group['_id']}, + 'sort': '-_created', + 'projection': { + '_created': 1, + 'name': 1, + 'picture': 1, + 'short_code': 1, + }}, + api=api) + + nodes = nodes._items or [] + for node in nodes: + node.picture = get_file(node.picture) + + return nodes + + +@blueprint.route('/home/jstree') +def home_jstree(): + """Entry point to view the home project as JSTree""" + api = system_util.pillar_api() + + try: + project = Project.find_from_endpoint('/bcloud/home-project', + params={'projection': { + '_id': 1, + 'permissions': 1, + 'category': 1, + 'user': 1}}, + api=api) + except ResourceNotFound: + raise wz_exceptions.NotFound('No such project') + + return jsonify(items=jstree_get_children(None, project._id)) + + +@blueprint.route('//') +def view(project_url): + """Entry point to view a project""" + + if request.args.get('format') == 'jstree': + log.warning('projects.view(%r) endpoint called with format=jstree, ' + 'redirecting to proper endpoint. URL is %s; referrer is %s', + project_url, request.url, request.referrer) + return redirect(url_for('projects.jstree', project_url=project_url)) + + api = system_util.pillar_api() + project = find_project_or_404(project_url, + embedded={'header_node': 1}, + api=api) + + # Load the header video file, if there is any. + header_video_file = None + header_video_node = None + if project.header_node and project.header_node.node_type == 'asset' and \ + project.header_node.properties.content_type == 'video': + header_video_node = project.header_node + header_video_file = get_file(project.header_node.properties.file) + header_video_node.picture = get_file(header_video_node.picture) + + return render_project(project, api, + extra_context={'header_video_file': header_video_file, + 'header_video_node': header_video_node}) + + +def render_project(project, api, extra_context=None, template_name=None): + project.picture_square = get_file(project.picture_square, api=api) + project.picture_header = get_file(project.picture_header, api=api) + + def load_latest(list_of_ids, get_picture=False): + """Loads a list of IDs in reversed order.""" + + if not list_of_ids: + return [] + + # Construct query parameters outside the loop. + projection = {'name': 1, 'user': 1, 'node_type': 1, 'project': 1, 'properties.url': 1} + params = {'projection': projection, 'embedded': {'user': 1}} + if get_picture: + projection['picture'] = 1 + + list_latest = [] + for node_id in reversed(list_of_ids or ()): + try: + node_item = Node.find(node_id, params, api=api) + + node_item.picture = get_file(node_item.picture, api=api) + list_latest.append(node_item) + except ForbiddenAccess: + pass + except ResourceNotFound: + log.warning('Project %s refers to removed node %s!', + project._id, node_id) + + return list_latest + + project.nodes_latest = load_latest(project.nodes_latest) + project.nodes_featured = load_latest(project.nodes_featured, get_picture=True) + project.nodes_blog = load_latest(project.nodes_blog) + + if extra_context is None: + extra_context = {} + + if project.category == 'home' and not current_app.config['RENDER_HOME_AS_REGULAR_PROJECT']: + template_name = template_name or 'projects/home_index.html' + return render_template( + template_name, + gravatar=gravatar(current_user.email, size=128), + project=project, + api=system_util.pillar_api(), + **extra_context) + + if template_name is None: + if request.args.get('embed'): + embed_string = '_embed' + else: + embed_string = '' + template_name = "projects/view{0}.html".format(embed_string) + + return render_template(template_name, + api=api, + project=project, + node=None, + show_node=False, + show_project=True, + og_picture=project.picture_header, + **extra_context) + + +@blueprint.route('//') +def view_node(project_url, node_id): + """Entry point to view a node in the context of a project""" + + # Some browsers mangle URLs and URL-encode /p/{p-url}/#node-id + if node_id.startswith('#'): + return redirect(url_for('projects.view_node', + project_url=project_url, + node_id=node_id[1:]), + code=301) # permanent redirect + + api = system_util.pillar_api() + theatre_mode = 't' in request.args + + # Fetch the node before the project. If this user has access to the + # node, we should be able to get the project URL too. + try: + node = Node.find(node_id, api=api) + except ForbiddenAccess: + return render_template('errors/403.html'), 403 + except ResourceNotFound: + raise wz_exceptions.NotFound('No such node') + + try: + project = Project.find_one({'where': {"url": project_url, '_id': node.project}}, api=api) + except ResourceNotFound: + # In theatre mode, we don't need access to the project at all. + if theatre_mode: + project = None + else: + raise wz_exceptions.NotFound('No such project') + + og_picture = node.picture = get_file(node.picture, api=api) + if project: + if not node.picture: + og_picture = get_file(project.picture_header, api=api) + project.picture_square = get_file(project.picture_square, api=api) + + # Append _theatre to load the proper template + theatre = '_theatre' if theatre_mode else '' + + return render_template('projects/view{}.html'.format(theatre), + api=api, + project=project, + node=node, + show_node=True, + show_project=False, + og_picture=og_picture) + + +def find_project_or_404(project_url, embedded=None, api=None): + """Aborts with a NotFound exception when the project cannot be found.""" + + params = {'where': {"url": project_url}} + if embedded: + params['embedded'] = embedded + + try: + project = Project.find_one(params, api=api) + except ResourceNotFound: + raise wz_exceptions.NotFound('No such project') + + return project + + +@blueprint.route('//search') +def search(project_url): + """Search into a project""" + api = system_util.pillar_api() + project = find_project_or_404(project_url, api=api) + project.picture_square = get_file(project.picture_square, api=api) + project.picture_header = get_file(project.picture_header, api=api) + + return render_template('nodes/search.html', + project=project, + og_picture=project.picture_header) + + +@blueprint.route('//about') +def about(project_url): + """About page of a project""" + + # TODO: Duplicated code from view function, we could re-use view instead + + api = system_util.pillar_api() + project = find_project_or_404(project_url, + embedded={'header_node': 1}, + api=api) + + # Load the header video file, if there is any. + header_video_file = None + header_video_node = None + if project.header_node and project.header_node.node_type == 'asset' and \ + project.header_node.properties.content_type == 'video': + header_video_node = project.header_node + header_video_file = get_file(project.header_node.properties.file) + header_video_node.picture = get_file(header_video_node.picture) + + return render_project(project, api, + extra_context={'title': 'about', + 'header_video_file': header_video_file, + 'header_video_node': header_video_node}) + + +@blueprint.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(project_url): + api = system_util.pillar_api() + # Fetch the Node or 404 + try: + project = Project.find_one({'where': {'url': project_url}}, api=api) + # project = Project.find(project_url, api=api) + except ResourceNotFound: + abort(404) + attach_project_pictures(project, api) + form = ProjectForm( + project_id=project._id, + name=project.name, + url=project.url, + summary=project.summary, + description=project.description, + is_private=u'GET' not in project.permissions.world, + category=project.category, + status=project.status, + ) + + if form.validate_on_submit(): + project = Project.find(project._id, api=api) + project.name = form.name.data + project.url = form.url.data + project.summary = form.summary.data + project.description = form.description.data + project.category = form.category.data + project.status = form.status.data + if form.picture_square.data: + project.picture_square = form.picture_square.data + if form.picture_header.data: + project.picture_header = form.picture_header.data + + # Update world permissions from is_private checkbox + if form.is_private.data: + project.permissions.world = [] + else: + project.permissions.world = [u'GET'] + + project.update(api=api) + # Reattach the pictures + attach_project_pictures(project, api) + else: + if project.picture_square: + form.picture_square.data = project.picture_square._id + if project.picture_header: + form.picture_header.data = project.picture_header._id + + # List of fields from the form that should be hidden to regular users + if current_user.has_role('admin'): + hidden_fields = [] + else: + hidden_fields = ['url', 'status', 'is_private', 'category'] + + return render_template('projects/edit.html', + form=form, + hidden_fields=hidden_fields, + project=project, + api=api) + + +@blueprint.route('//edit/node-type') +@login_required +def edit_node_types(project_url): + api = system_util.pillar_api() + # Fetch the project or 404 + try: + project = Project.find_one({ + 'where': '{"url" : "%s"}' % (project_url)}, api=api) + except ResourceNotFound: + return abort(404) + + attach_project_pictures(project, api) + + return render_template('projects/edit_node_types.html', + api=api, + project=project) + + +@blueprint.route('//e/node-type/', methods=['GET', 'POST']) +@login_required +def edit_node_type(project_url, node_type_name): + api = system_util.pillar_api() + # Fetch the Node or 404 + try: + project = Project.find_one({ + 'where': '{"url" : "%s"}' % (project_url)}, api=api) + except ResourceNotFound: + return abort(404) + attach_project_pictures(project, api) + node_type = project.get_node_type(node_type_name) + form = NodeTypeForm() + if form.validate_on_submit(): + # Update dynamic & form schemas + dyn_schema = json.loads(form.dyn_schema.data) + node_type.dyn_schema = dyn_schema + form_schema = json.loads(form.form_schema.data) + node_type.form_schema = form_schema + + # Update permissions + permissions = json.loads(form.permissions.data) + node_type.permissions = permissions + + project.update(api=api) + elif request.method == 'GET': + form.project_id.data = project._id + if node_type: + form.name.data = node_type.name + form.description.data = node_type.description + form.parent.data = node_type.parent + + dyn_schema = node_type.dyn_schema.to_dict() + form_schema = node_type.form_schema.to_dict() + if 'permissions' in node_type: + permissions = node_type.permissions.to_dict() + else: + permissions = {} + + form.form_schema.data = json.dumps(form_schema, indent=4) + form.dyn_schema.data = json.dumps(dyn_schema, indent=4) + form.permissions.data = json.dumps(permissions, indent=4) + return render_template('projects/edit_node_type.html', + form=form, + project=project, + api=api, + node_type=node_type) + + +@blueprint.route('//edit/sharing', methods=['GET', 'POST']) +@login_required +def sharing(project_url): + api = system_util.pillar_api() + # Fetch the project or 404 + try: + project = Project.find_one({ + 'where': '{"url" : "%s"}' % (project_url)}, api=api) + except ResourceNotFound: + return abort(404) + + # Fetch users that are part of the admin group + users = project.get_users(api=api) + for user in users['_items']: + user['avatar'] = gravatar(user['email']) + + if request.method == 'POST': + user_id = request.form['user_id'] + action = request.form['action'] + try: + if action == 'add': + user = project.add_user(user_id, api=api) + elif action == 'remove': + user = project.remove_user(user_id, api=api) + except ResourceNotFound: + log.info('/p/%s/edit/sharing: User %s not found', project_url, user_id) + return jsonify({'_status': 'ERROR', + 'message': 'User %s not found' % user_id}), 404 + + # Add gravatar to user + user['avatar'] = gravatar(user['email']) + return jsonify(user) + + attach_project_pictures(project, api) + + return render_template('projects/sharing.html', + api=api, + project=project, + users=users['_items']) + + +@blueprint.route('/e/add-featured-node', methods=['POST']) +@login_required +def add_featured_node(): + """Feature a node in a project. This method belongs here, because it affects + the project node itself, not the asset. + """ + api = system_util.pillar_api() + node = Node.find(request.form['node_id'], api=api) + action = project_update_nodes_list(node, project_id=node.project, list_name='featured') + return jsonify(status='success', data=dict(action=action)) + + +@blueprint.route('/e/move-node', methods=['POST']) +@login_required +def move_node(): + """Move a node within a project. While this affects the node.parent prop, we + keep it in the scope of the project.""" + node_id = request.form['node_id'] + dest_parent_node_id = request.form.get('dest_parent_node_id') + + api = system_util.pillar_api() + node = Node.find(node_id, api=api) + # Get original parent id for clearing template fragment on success + previous_parent_id = node.parent + if dest_parent_node_id: + node.parent = dest_parent_node_id + elif node.parent: + node.parent = None + node.update(api=api) + return jsonify(status='success', data=dict(message='node moved')) + + +@blueprint.route('/e/delete-node', methods=['POST']) +@login_required +def delete_node(): + """Delete a node""" + api = system_util.pillar_api() + node = Node.find(request.form['node_id'], api=api) + if not node.has_method('DELETE'): + return abort(403) + + node.delete(api=api) + + return jsonify(status='success', data=dict(message='Node deleted')) + + +@blueprint.route('/e/toggle-node-public', methods=['POST']) +@login_required +def toggle_node_public(): + """Give a node GET permissions for the world. Later on this can turn into + a more powerful permission management function. + """ + api = system_util.pillar_api() + node = Node.find(request.form['node_id'], api=api) + if node.has_method('PUT'): + if node.permissions and 'world' in node.permissions.to_dict(): + node.permissions = {} + message = "Node is not public anymore." + else: + node.permissions = dict(world=['GET']) + message = "Node is now public!" + node.update(api=api) + return jsonify(status='success', data=dict(message=message)) + else: + return abort(403) + + +@blueprint.route('/e/toggle-node-project-header', methods=['POST']) +@login_required +def toggle_node_project_header(): + """Sets this node as the project header, or removes it if already there. + """ + + api = system_util.pillar_api() + node_id = request.form['node_id'] + + try: + node = Node.find(node_id, {'projection': {'project': 1}}, api=api) + except ResourceNotFound: + log.info('User %s trying to toggle non-existing node %s as project header', + current_user.objectid, node_id) + return jsonify(_status='ERROR', message='Node not found'), 404 + + try: + project = Project.find(node.project, api=api) + except ResourceNotFound: + log.info('User %s trying to toggle node %s as project header, but project %s not found', + current_user.objectid, node_id, node.project) + return jsonify(_status='ERROR', message='Project not found'), 404 + + # Toggle header node + if project.header_node == node_id: + log.debug('Un-setting header node of project %s', node.project) + project.header_node = None + action = 'unset' + else: + log.debug('Setting node %s as header of project %s', node_id, node.project) + project.header_node = node_id + action = 'set' + + # Save the project + project.update(api=api) + + return jsonify({'_status': 'OK', + 'action': action}) + + +def project_update_nodes_list(node, project_id=None, list_name='latest'): + """Update the project node with the latest edited or favorited node. + The list value can be 'latest' or 'featured' and it will determined where + the node reference will be placed in. + """ + if node.properties.status and node.properties.status == 'published': + if not project_id and 'current_project_id' in session: + project_id = session['current_project_id'] + elif not project_id: + return None + project_id = node.project + if type(project_id) is not unicode: + project_id = node.project._id + api = system_util.pillar_api() + project = Project.find(project_id, api=api) + if list_name == 'latest': + nodes_list = project.nodes_latest + elif list_name == 'blog': + nodes_list = project.nodes_blog + else: + nodes_list = project.nodes_featured + + if not nodes_list: + node_list_name = 'nodes_' + list_name + project[node_list_name] = [] + nodes_list = project[node_list_name] + elif len(nodes_list) > 5: + nodes_list.pop(0) + + if node._id in nodes_list: + # Pop to put this back on top of the list + nodes_list.remove(node._id) + if list_name == 'featured': + # We treat the action as a toggle and do not att the item back + project.update(api=api) + return "removed" + + nodes_list.append(node._id) + project.update(api=api) + return "added" + + +@blueprint.route('/create') +@login_required +def create(): + """Create a new project. This is a multi step operation that involves: + - initialize basic node types + - initialize basic permissions + - create and connect storage space + """ + api = system_util.pillar_api() + project_properties = dict( + name='My project', + user=current_user.objectid, + category='assets', + status='pending' + ) + project = Project(project_properties) + project.create(api=api) + + return redirect(url_for('projects.edit', + project_url="p-{}".format(project['_id']))) + + +@blueprint.route('/delete', methods=['POST']) +@login_required +def delete(): + """Unapologetically deletes a project""" + api = system_util.pillar_api() + project_id = request.form['project_id'] + project = Project.find(project_id, api=api) + project.delete(api=api) + return jsonify(dict(staus='success', data=dict( + message='Project deleted {}'.format(project['_id'])))) diff --git a/pillar/web/redirects/__init__.py b/pillar/web/redirects/__init__.py new file mode 100644 index 00000000..c4afba1f --- /dev/null +++ b/pillar/web/redirects/__init__.py @@ -0,0 +1,64 @@ +import logging +import string +import urlparse + +from flask import Blueprint, redirect, current_app +from werkzeug.exceptions import NotFound +import pillarsdk + +from pillar.web import system_util +from pillar.web.nodes.routes import url_for_node + +blueprint = Blueprint('redirects', __name__) +log = logging.getLogger(__name__) + +short_code_chars = string.ascii_letters + string.digits + + +@blueprint.route('/') +def redirect_to_path(path): + redirects = current_app.config.get('REDIRECTS', {}) + + # Try our dict of redirects first. + try: + url = redirects[path] + except KeyError: + pass + else: + return redirect(url, code=307) + + # The path may be a node short-code. + resp = redirect_with_short_code(path) + if resp is not None: + return resp + + log.warning('Non-existing redirect %r requested', path) + raise NotFound() + + +def redirect_with_short_code(short_code): + if any(c not in short_code_chars for c in short_code): + # Can't be a short code + return + + log.debug('Path %s may be a short-code', short_code) + + api = system_util.pillar_api() + try: + node = pillarsdk.Node.find_one({'where': {'short_code': short_code}, + 'projection': {'_id': 1}}, + api=api) + except pillarsdk.ResourceNotFound: + log.debug("Nope, it isn't.") + return + + # Redirect to 'theatre' view for the node. + url = url_for_node(node=node) + url = urlparse.urljoin(url, '?t') + + log.debug('Found short code %s, redirecting to %s', short_code, url) + return redirect(url, code=307) + + +def setup_app(app, url_prefix): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/web/static/assets/css/vendor/bootstrap.min.css b/pillar/web/static/assets/css/vendor/bootstrap.min.css new file mode 100644 index 00000000..8a770ee6 --- /dev/null +++ b/pillar/web/static/assets/css/vendor/bootstrap.min.css @@ -0,0 +1,14 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=3f4d4ab5b209f6196ab3) + * Config saved to config.json and https://gist.github.com/3f4d4ab5b209f6196ab3 + *//*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent !important;color:#000 !important;-webkit-box-shadow:none !important;box-shadow:none !important;text-shadow:none !important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000 !important}.label{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #ddd !important}}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:hover,a:focus{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#777}h1,.h1,h2,.h2,h3,.h3{margin-top:20px;margin-bottom:10px}h1 small,.h1 small,h2 small,.h2 small,h3 small,.h3 small,h1 .small,.h1 .small,h2 .small,.h2 .small,h3 .small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:10px;margin-bottom:10px}h4 small,.h4 small,h5 small,.h5 small,h6 small,.h6 small,h4 .small,.h4 .small,h5 .small,.h5 .small,h6 .small,.h6 .small{font-size:75%}h1,.h1{font-size:36px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}small,.small{font-size:85%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:hover,a.text-primary:focus{color:#286090}.text-success{color:#3c763d}a.text-success:hover,a.text-success:focus{color:#2b542c}.text-info{color:#31708f}a.text-info:hover,a.text-info:focus{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:hover,a.text-warning:focus{color:#66512c}.text-danger{color:#a94442}a.text-danger:hover,a.text-danger:focus{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:hover,a.bg-primary:focus{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:hover,a.bg-success:focus{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:hover,a.bg-info:focus{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.42857143}dt{font-weight:bold}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse footer:before,blockquote.pull-right footer:before,.blockquote-reverse small:before,blockquote.pull-right small:before,.blockquote-reverse .small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,blockquote.pull-right footer:after,.blockquote-reverse small:after,blockquote.pull-right small:after,.blockquote-reverse .small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.row{margin-left:-15px;margin-right:-15px}.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>tbody>tr>td.active,.table>tfoot>tr>td.active,.table>thead>tr>th.active,.table>tbody>tr>th.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>tbody>tr.active>td,.table>tfoot>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr.active>th,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>tbody>tr>td.success,.table>tfoot>tr>td.success,.table>thead>tr>th.success,.table>tbody>tr>th.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>tbody>tr.success>td,.table>tfoot>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr.success>th,.table>tfoot>tr.success>th{background-color:#dff0d8}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d0e9c6}.table>thead>tr>td.info,.table>tbody>tr>td.info,.table>tfoot>tr>td.info,.table>thead>tr>th.info,.table>tbody>tr>th.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>tbody>tr.info>td,.table>tfoot>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr.info>th,.table>tfoot>tr.info>th{background-color:#d9edf7}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#c4e3f3}.table>thead>tr>td.warning,.table>tbody>tr>td.warning,.table>tfoot>tr>td.warning,.table>thead>tr>th.warning,.table>tbody>tr>th.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>tbody>tr.warning>td,.table>tfoot>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr.warning>th,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>tbody>tr>td.danger,.table>tfoot>tr>td.danger,.table>thead>tr>th.danger,.table>tbody>tr>th.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>tbody>tr.danger>td,.table>tfoot>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr.danger>th,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:0.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:34px}input[type="date"].input-sm,input[type="time"].input-sm,input[type="datetime-local"].input-sm,input[type="month"].input-sm,.input-group-sm input[type="date"],.input-group-sm input[type="time"],.input-group-sm input[type="datetime-local"],.input-group-sm input[type="month"]{line-height:30px}input[type="date"].input-lg,input[type="time"].input-lg,input[type="datetime-local"].input-lg,input[type="month"].input-lg,.input-group-lg input[type="date"],.input-group-lg input[type="time"],.input-group-lg input[type="datetime-local"],.input-group-lg input[type="month"]{line-height:46px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],input[type="radio"].disabled,input[type="checkbox"].disabled,fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,.checkbox-inline.disabled,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,.checkbox.disabled label,fieldset[disabled] .radio label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:34px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:46px;height:46px;line-height:46px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;border-color:#3c763d;background-color:#dff0d8}.has-success .form-control-feedback{color:#3c763d}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;border-color:#8a6d3b;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;border-color:#a94442;background-color:#f2dede}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}@media (min-width:768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.333333px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn:active:focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn.active.focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:focus,.btn-default.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active:hover,.btn-default.active:hover,.open>.dropdown-toggle.btn-default:hover,.btn-default:active:focus,.btn-default.active:focus,.open>.dropdown-toggle.btn-default:focus,.btn-default:active.focus,.btn-default.active.focus,.open>.dropdown-toggle.btn-default.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled.focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default.focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary:active:hover,.btn-primary.active:hover,.open>.dropdown-toggle.btn-primary:hover,.btn-primary:active:focus,.btn-primary.active:focus,.open>.dropdown-toggle.btn-primary:focus,.btn-primary:active.focus,.btn-primary.active.focus,.open>.dropdown-toggle.btn-primary.focus{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled.focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary.focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active:hover,.btn-success.active:hover,.open>.dropdown-toggle.btn-success:hover,.btn-success:active:focus,.btn-success.active:focus,.open>.dropdown-toggle.btn-success:focus,.btn-success:active.focus,.btn-success.active.focus,.open>.dropdown-toggle.btn-success.focus{color:#fff;background-color:#398439;border-color:#255625}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled.focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success.focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active:hover,.btn-info.active:hover,.open>.dropdown-toggle.btn-info:hover,.btn-info:active:focus,.btn-info.active:focus,.open>.dropdown-toggle.btn-info:focus,.btn-info:active.focus,.btn-info.active.focus,.open>.dropdown-toggle.btn-info.focus{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled.focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info.focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active:hover,.btn-warning.active:hover,.open>.dropdown-toggle.btn-warning:hover,.btn-warning:active:focus,.btn-warning.active:focus,.open>.dropdown-toggle.btn-warning:focus,.btn-warning:active.focus,.btn-warning.active.focus,.open>.dropdown-toggle.btn-warning.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled.focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning.focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active:hover,.btn-danger.active:hover,.open>.dropdown-toggle.btn-danger:hover,.btn-danger:active:focus,.btn-danger.active:focus,.open>.dropdown-toggle.btn-danger:focus,.btn-danger:active.focus,.btn-danger.active.focus,.open>.dropdown-toggle.btn-danger.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled.focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger.focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#337ab7;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#777;text-decoration:none}.btn-lg,.btn-group-lg>.btn{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-sm,.btn-group-sm>.btn{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs,.btn-group-xs>.btn{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height, visibility;-o-transition-property:height, visibility;transition-property:height, visibility;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid \9;border-right:4px solid transparent;border-left:4px solid transparent}.dropup,.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:14px;text-align:left;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);-webkit-background-clip:padding-box;background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{text-decoration:none;color:#262626;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#fff;text-decoration:none;outline:0;background-color:#337ab7}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#777}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{left:auto;right:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid \9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{left:auto;right:0}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-bottom-left-radius:4px;border-top-right-radius:0;border-top-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle="buttons"]>.btn input[type="radio"],[data-toggle="buttons"]>.btn-group>.btn input[type="radio"],[data-toggle="buttons"]>.btn input[type="checkbox"],[data-toggle="buttons"]>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-left:0;padding-right:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn,select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn,select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:normal;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group-btn:last-child>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:first-child>.btn-group:not(:first-child)>.btn{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:hover,.input-group-btn>.btn:focus,.input-group-btn>.btn:active{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#777;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#555;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block !important;height:auto !important;padding-bottom:0;overflow:visible !important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-left:0;padding-right:0}}.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:200px}}.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:15px 15px;font-size:18px;line-height:20px;height:50px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:8px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{margin-left:-15px;margin-right:-15px;padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);margin-top:8px;margin-bottom:8px}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn,.navbar-form .input-group .form-control{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .radio label,.navbar-form .checkbox label{padding-left:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-left:15px;margin-right:15px}}@media (min-width:768px){.navbar-left{float:left !important}.navbar-right{float:right !important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#e7e7e7;color:#555}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:hover,.navbar-default .btn-link:focus{color:#333}.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:hover,.navbar-default .btn-link[disabled]:focus,fieldset[disabled] .navbar-default .btn-link:focus{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{background-color:#080808;color:#fff}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:hover,.navbar-inverse .btn-link:focus{color:#fff}.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:hover,.navbar-inverse .btn-link[disabled]:focus,fieldset[disabled] .navbar-inverse .btn-link:focus{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{content:"/\00a0";padding:0 5px;color:#ccc}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;line-height:1.42857143;text-decoration:none;color:#337ab7;background-color:#fff;border:1px solid #ddd;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:4px;border-top-right-radius:4px}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{z-index:3;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7;cursor:default}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#777;background-color:#fff;border-color:#ddd;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:3px;border-top-right-radius:3px}.pager{padding-left:0;margin:20px 0;list-style:none;text-align:center}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#777;background-color:#fff;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:hover,a.label:focus{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:hover,.label-default[href]:focus{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:hover,.label-success[href]:focus{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:hover,.label-info[href]:focus{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:bold;color:#fff;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-xs .badge,.btn-group-xs>.btn .badge{top:0;padding:1px 5px}a.badge:hover,a.badge:focus{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{background-color:#dff0d8;border-color:#d6e9c6;color:#3c763d}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{background-color:#d9edf7;border-color:#bce8f1;color:#31708f}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{background-color:#fcf8e3;border-color:#faebcc;color:#8a6d3b}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{background-color:#f2dede;border-color:#ebccd1;color:#a94442}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{overflow:hidden;height:20px;margin-bottom:20px;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0%;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-striped .progress-bar,.progress-bar-striped{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress.active .progress-bar,.progress-bar.active{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-left,.media-right,.media-body{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{text-decoration:none;color:#555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:hover,.list-group-item.disabled:focus{background-color:#eee;color:#777;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>.small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:hover .list-group-item-text,.list-group-item.active:focus .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:hover,button.list-group-item-success:hover,a.list-group-item-success:focus,button.list-group-item-success:focus{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,button.list-group-item-success.active,a.list-group-item-success.active:hover,button.list-group-item-success.active:hover,a.list-group-item-success.active:focus,button.list-group-item-success.active:focus{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:hover,button.list-group-item-info:hover,a.list-group-item-info:focus,button.list-group-item-info:focus{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,button.list-group-item-info.active,a.list-group-item-info.active:hover,button.list-group-item-info.active:hover,a.list-group-item-info.active:focus,button.list-group-item-info.active:focus{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:hover,button.list-group-item-warning:hover,a.list-group-item-warning:focus,button.list-group-item-warning:focus{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,button.list-group-item-warning.active,a.list-group-item-warning.active:hover,button.list-group-item-warning.active:hover,a.list-group-item-warning.active:focus,button.list-group-item-warning.active:focus{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:hover,button.list-group-item-danger:hover,a.list-group-item-danger:focus,button.list-group-item-danger:focus{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,button.list-group-item-danger.active,a.list-group-item-danger.active:hover,button.list-group-item-danger.active:hover,a.list-group-item-danger.active:focus,button.list-group-item-danger.active:focus{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>a,.panel-title>small,.panel-title>.small,.panel-title>small>a,.panel-title>.small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:3px;border-top-left-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.table,.panel>.table-responsive>.table,.panel>.panel-collapse>.table{margin-bottom:0}.panel>.table caption,.panel>.table-responsive>.table caption,.panel>.panel-collapse>.table caption{padding-left:15px;padding-right:15px}.panel>.table:first-child,.panel>.table-responsive:first-child>.table:first-child{border-top-right-radius:3px;border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table:last-child,.panel>.table-responsive:last-child>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child th,.panel>.table>tbody:first-child>tr:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.panel-body,.panel-group .panel-heading+.panel-collapse>.list-group{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.modal-open{overflow:hidden}.modal{display:none;overflow:hidden;position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0, -25%);-ms-transform:translate(0, -25%);-o-transform:translate(0, -25%);transform:translate(0, -25%);-webkit-transition:-webkit-transform 0.3s ease-out;-o-transition:-o-transform 0.3s ease-out;transition:transform 0.3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0, 0);-ms-transform:translate(0, 0);-o-transform:translate(0, 0);transform:translate(0, 0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);-webkit-background-clip:padding-box;background-clip:padding-box;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5;min-height:16.42857143px}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:12px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{bottom:0;right:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:14px;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#999;border-top-color:rgba(0,0,0,0.25);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#fff}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#999;border-right-color:rgba(0,0,0,0.25)}.popover.right>.arrow:after{content:" ";left:1px;bottom:-10px;border-left-width:0;border-right-color:#fff}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,0.25);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,0.25)}.popover.left>.arrow:after{content:" ";right:1px;border-right-width:0;border-left-color:#fff;bottom:-10px}.clearfix:before,.clearfix:after,.dl-horizontal dd:before,.dl-horizontal dd:after,.container:before,.container:after,.container-fluid:before,.container-fluid:after,.row:before,.row:after,.form-horizontal .form-group:before,.form-horizontal .form-group:after,.btn-toolbar:before,.btn-toolbar:after,.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after,.nav:before,.nav:after,.navbar:before,.navbar:after,.navbar-header:before,.navbar-header:after,.navbar-collapse:before,.navbar-collapse:after,.pager:before,.pager:after,.panel-body:before,.panel-body:after,.modal-footer:before,.modal-footer:after{content:" ";display:table}.clearfix:after,.dl-horizontal dd:after,.container:after,.container-fluid:after,.row:after,.form-horizontal .form-group:after,.btn-toolbar:after,.btn-group-vertical>.btn-group:after,.nav:after,.navbar:after,.navbar-header:after,.navbar-collapse:after,.pager:after,.panel-body:after,.modal-footer:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right !important}.pull-left{float:left !important}.hide{display:none !important}.show{display:block !important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none !important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,.visible-sm,.visible-md,.visible-lg{display:none !important}.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block{display:none !important}@media (max-width:767px){.visible-xs{display:block !important}table.visible-xs{display:table !important}tr.visible-xs{display:table-row !important}th.visible-xs,td.visible-xs{display:table-cell !important}}@media (max-width:767px){.visible-xs-block{display:block !important}}@media (max-width:767px){.visible-xs-inline{display:inline !important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block !important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block !important}table.visible-sm{display:table !important}tr.visible-sm{display:table-row !important}th.visible-sm,td.visible-sm{display:table-cell !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block !important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block !important}table.visible-md{display:table !important}tr.visible-md{display:table-row !important}th.visible-md,td.visible-md{display:table-cell !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block !important}}@media (min-width:1200px){.visible-lg{display:block !important}table.visible-lg{display:table !important}tr.visible-lg{display:table-row !important}th.visible-lg,td.visible-lg{display:table-cell !important}}@media (min-width:1200px){.visible-lg-block{display:block !important}}@media (min-width:1200px){.visible-lg-inline{display:inline !important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block !important}}@media (max-width:767px){.hidden-xs{display:none !important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none !important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none !important}}@media (min-width:1200px){.hidden-lg{display:none !important}}.visible-print{display:none !important}@media print{.visible-print{display:block !important}table.visible-print{display:table !important}tr.visible-print{display:table-row !important}th.visible-print,td.visible-print{display:table-cell !important}}.visible-print-block{display:none !important}@media print{.visible-print-block{display:block !important}}.visible-print-inline{display:none !important}@media print{.visible-print-inline{display:inline !important}}.visible-print-inline-block{display:none !important}@media print{.visible-print-inline-block{display:inline-block !important}}@media print{.hidden-print{display:none !important}} \ No newline at end of file diff --git a/pillar/web/static/assets/font/config.json b/pillar/web/static/assets/font/config.json new file mode 100644 index 00000000..c7a4da2e --- /dev/null +++ b/pillar/web/static/assets/font/config.json @@ -0,0 +1,1062 @@ +{ + "name": "pillar-font", + "css_prefix_text": "pi-", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "991710797f37a1d64135f7531a428a89", + "css": "collection-plus", + "code": 59392, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M166.7 250H83.3V833.3C83.3 879.2 120.8 916.7 166.7 916.7H750V833.3H166.7V250ZM833.3 83.3H333.3C287.5 83.3 250 120.8 250 166.7V666.7C250 712.5 287.5 750 333.3 750H833.3C879.2 750 916.7 712.5 916.7 666.7V166.7C916.7 120.8 879.2 83.3 833.3 83.3ZM791.7 458.3H625V625H541.7V458.3H375V375H541.7V208.3H625V375H791.7V458.3Z", + "width": 1000 + }, + "search": [ + "collection-plus material-design" + ] + }, + { + "uid": "053a214a098a9453877363eeb45f004e", + "css": "log-in", + "code": 59393, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 62.5C313.4 62.5 154.2 179.3 91.3 343.8H158.9C177.3 303.7 202.8 266.9 234.8 234.8 305.7 164 399.8 125 500 125 600.1 125 694.3 164 765.2 234.8 836 305.7 875 399.8 875 500 875 600.2 836 694.3 765.2 765.2 694.3 836 600.1 875 500 875 399.8 875 305.7 836 234.8 765.2 202.8 733.1 177.3 696.3 158.9 656.3H91.3C154.2 820.7 313.4 937.5 500 937.5 741.6 937.5 937.5 741.6 937.5 500 937.5 258.4 741.6 62.5 500 62.5ZM404 632.6L448.2 676.8 625 500 448.2 323.2 404 367.4 505.4 468.8 62.5 468.8 62.5 531.3 505.4 531.3Z", + "width": 1000 + }, + "search": [ + "log-in" + ] + }, + { + "uid": "f603c4a31f5a386102a33b5bb215451c", + "css": "log-out", + "code": 59394, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M716.5 632.6L760.7 676.8 937.5 500 760.7 323.2 716.5 367.4 817.9 468.8 375 468.8 375 531.3 817.9 531.3ZM764.6 765.2C693.8 836 599.6 875 499.5 875 399.3 875 305.1 836 234.3 765.2 163.5 694.3 125 600.2 125 500 125 399.8 163.5 305.7 234.3 234.8 305.1 164 399.3 125 499.5 125 599.6 125 693.8 164 764.6 234.8 769.6 239.8 774.3 244.8 779 250H858.5C779.5 136.7 648.1 62.5 499.5 62.5 257.9 62.5 62.5 258.4 62.5 500 62.5 741.6 257.9 937.5 499.5 937.5 648.1 937.5 779.5 863.3 858.5 750H779C774.3 755.2 769.6 760.2 764.6 765.2Z", + "width": 1000 + }, + "search": [ + "log-out" + ] + }, + { + "uid": "8789b43e72e03ba7abd42bf3f69cb8a4", + "css": "blender-cloud", + "code": 59401, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M310.2 930.7C189.5 904.6 88.6 801 65 678.8 50.6 604.2 67.1 511.8 106.2 448.8L123.4 420.9 94.4 400.1C78.4 388.7 65.4 377.1 65.4 374.4 65.4 369.8 88.4 364.2 160.7 351.6 193.1 345.9 216.7 350.1 217 361.7 217.1 364 222 392.9 228 426 240.8 496.7 237.6 501.9 198.6 473.7L173.3 455.4 160.1 471.8C131.6 507.3 121 547.2 120.9 619.3 120.9 683.8 121.6 687.9 140.7 727.1 177.4 802.6 251.5 861.3 328.4 875.9 345.6 879.1 494.2 881.6 677.3 881.6 860.4 881.6 1009 879.1 1026.2 875.9 1103.3 861.3 1177.7 802.2 1213.4 727.2 1230.1 692.2 1233.2 678.8 1235.4 631.1 1237.6 584.8 1235.9 569.4 1225.3 538.1 1190.6 435.7 1104.9 365 1008 358.6 956.3 355.2 942.2 347.2 936.8 318.1 925.9 259 862.4 176.3 806 147.6 735.9 111.8 646.2 108.7 578.2 139.7 471.3 188.5 406.8 306.7 426.3 418.3 448.2 544.3 556.8 636.8 682.6 636.8 715.1 636.8 773.2 625.3 789.1 615.7 791.3 614.4 786.7 598.7 778.8 580.9 770.9 563.2 765.9 546.3 767.7 543.5 769.4 540.7 795 547.2 824.6 557.9 899.2 585.1 897.5 584.3 901 593.6 904.8 603.5 861.6 727.1 854.3 727.1 851.5 727.1 841.9 713.5 833 696.9L816.8 666.7 783.5 678.5C736.2 695.2 660.8 699 613.2 687 493.4 656.7 401.2 565.6 373.8 450.3 348.6 344.3 380.1 234.6 458.5 155.5 520.6 92.8 598 59.9 683.1 59.9 812.8 59.9 939.9 148 979.9 265.6L990 295.3 1032.8 302.5C1153.4 322.8 1255 418.2 1286.2 540.4 1330.1 712.8 1216.5 894.1 1041.5 931 983.3 943.3 367.1 943 310.2 930.7Z", + "width": 1356 + }, + "search": [ + "blender-cloud" + ] + }, + { + "uid": "f7021c7c6c03d3857e10ee41807173fc", + "css": "markdown", + "code": 59404, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M874.3 187.5L125.7 187.5C90.8 187.5 62.5 215.8 62.5 250.6L62.5 749.3C62.5 784.2 90.8 812.5 125.7 812.5L874.3 812.5C909.2 812.5 937.5 784.2 937.5 749.3L937.5 250.6C937.5 215.8 909.2 187.5 874.3 187.5ZM554.7 687.5L445.3 687.5 445.3 500 363.2 605.2 281.1 500 281.1 687.5 171.6 687.5 171.6 312.5 281.1 312.5 363.2 445.3 445.3 312.5 554.7 312.5 554.7 687.5 554.7 687.5ZM718.1 687.5L582.1 500 664.2 500 664.2 312.5 773.6 312.5 773.6 500 855.7 500 718.1 687.5 718.1 687.5Z", + "width": 1000 + }, + "search": [ + "markdown" + ] + }, + { + "uid": "936e0ffd8d1686c34a6512ff0abc8d20", + "css": "folder", + "code": 59405, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M416.7 187.5H145.8C100 187.5 62.5 225 62.5 270.8V729.2C62.5 775 100 812.5 145.8 812.5H854.2C900 812.5 937.5 775 937.5 729.2V364.6C937.5 318.7 900 281.3 854.2 281.3H500L416.7 187.5Z", + "width": 1000 + }, + "search": [ + "folder" + ] + }, + { + "uid": "dc83b24bb37ffe7d1dca4f60298998be", + "css": "more-vertical", + "code": 59406, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M578.1 265.6C578.1 222.7 543 187.5 500 187.5S421.9 222.7 421.9 265.6 457 343.8 500 343.8 578.1 308.6 578.1 265.6ZM578.1 734.4C578.1 691.4 543 656.3 500 656.3S421.9 691.4 421.9 734.4 457 812.5 500 812.5 578.1 777.3 578.1 734.4ZM578.1 500C578.1 457 543 421.9 500 421.9S421.9 457 421.9 500 457 578.1 500 578.1 578.1 543 578.1 500Z", + "width": 1000 + }, + "search": [ + "more-vertical" + ] + }, + { + "uid": "efd42cb1bc6716867c92f2e011108f53", + "css": "blender-network", + "code": 59408, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M992.7 199.9L879.4 240.6 760.7 151.8 805.2 77.5 750.2 41.4 652.6 81.5 603.7 158 639.4 188.3 487.7 297.6 386.8 332.8 380.1 294.2 428.7 222.2 392.3 182.9 308.2 217.6 256 291.3 285.7 333.4 173.1 496.4 100.6 522.5 41.4 606.3 64.8 665.8 153.6 636.8 213.4 548.1 325.2 438.7 356 483.8 302.4 744.1 215.1 769.5 148.1 874.3 181.4 958.6 290.4 931.6 351.1 830.3 591 765.6 649.5 848.2 792 813 857.2 682.8 780.6 600.4 657.9 636.2 504.9 466.7 571.7 357.4 823.9 346.1 886.9 399.7 1020.2 356.2 1072.3 252.2 992.7 199.9ZM526.4 342.6L498.3 307.5 649 198.9 562.1 341 526.4 342.6ZM319.1 424.7L207.3 534.1 188.5 499.3 307.4 327.1 367.3 303.9 373.9 342.2 319.1 424.7ZM463.9 452.6L481.7 472.3 321.6 734.2 373.2 483.3 463.9 452.6ZM643.1 651.1L591.7 743.7 353.8 807.9 326.8 757.9 493.5 485.3 643.1 651.1ZM493.1 453.7L478.7 437.6 526.7 359.4 551.5 358.3 493.1 453.7ZM673.4 191.1L747.1 162.6 865.9 251.4 822.6 329.4 582.3 340.1 673.4 191.1Z", + "width": 1114 + }, + "search": [ + "blender-network" + ] + }, + { + "uid": "f9b25a3a20a4967337f37f70db67eb50", + "css": "blender-logo", + "code": 59409, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M620.9 3.3C616 3.2 610.9 3.4 604.8 3.8 595.9 4.5 586.7 5.6 580.2 7.1 574.2 8.4 566.8 10.9 560.5 13.5 553.8 16.2 545.1 20.5 538.6 24.3 531.7 28.3 522.7 34.7 516 40.3 509.4 45.8 502.1 52.7 497.3 58.3 493.3 63.1 488.5 69.5 485.2 74.6 481.3 80.6 477.6 88.3 474.9 95.5 472.1 102.6 469.7 111 468.5 118 467.1 125.8 466.9 135 467.4 145.6 467.8 154.9 468.5 163.8 470.6 171.7 471.1 173.5 471.9 175.6 472.6 177.7L406.9 177.7C381.3 177.7 358.3 177.8 340.8 178.1 323.3 178.3 312.5 178.5 306.2 179.1 298.4 179.9 287.3 181.6 279.8 183.1 272.4 184.6 262.6 187 256 189.1 248.8 191.3 239 195.4 231.3 199.1 223.8 202.8 214.9 207.6 209.1 211.4 201.8 216.1 193.6 223.7 186.1 231.3 177.4 240.1 170.3 248.6 165.3 256.9 161.2 263.8 156.8 273.1 154 280.6 151.1 288.3 148.3 298.5 146.9 306.8 145.2 316.7 144.8 325.6 145.4 337.2 145.8 347.1 147 357.3 149.1 365.3 151 372.6 154.3 381.4 157.7 388.5 161.1 395.6 166 403.8 170.5 410 175 416 181.3 422.9 186.9 428.1 192.1 432.8 198.4 438 203.7 441.5 209 445 216.5 449 223.2 452.1 224 452.5 224.6 452.9 225.6 453.4 224.5 454.3 224 454.7 222.8 455.7 210 465.8 191 480.6 166.4 499.4 124.5 531.6 87.4 560.3 79.9 566.5 73.5 571.8 63.9 580.9 56.9 588.1 49.9 595.4 41.7 604.7 36.9 611.2 32.6 617.1 27.2 625.3 23.8 631.3 20.4 637.4 16.5 645.4 14 651.5 11.6 657.5 8.9 665.9 7.2 672.5 5.1 680.5 3.8 690.4 3.4 699.5 3 708.3 3.3 718.9 4.4 726.9 5.5 734.9 8 745.1 10.7 753 13.4 761.1 17.8 771 21.9 778.3 25.8 785.1 31.5 793.6 36.3 799.7 41.3 806 48.7 813.6 55.2 819.2 61.4 824.6 69.4 830.5 75.7 834.3 81.6 837.8 89.5 841.9 95.8 844.5 102.2 847.1 110.9 849.9 117.8 851.6 127.6 854.1 135.7 854.8 154.4 854.8L176.6 854.9 178.7 854.9 180.6 854.4 192.4 851.5C199.5 849.7 208.1 847.1 213.8 845 219.7 842.8 227.7 839 234.1 835.6 242.5 831.2 254.6 822.6 265.4 814.2 275.8 806 300.5 786.3 320.6 770.2 329 763.5 336.1 757.9 342.4 753.1 342.6 753.5 342.7 753.7 342.9 754.1 345.6 759.7 348.8 766.3 352.3 772.8 360.6 788.6 370 803.8 380.3 818.3 390.7 833 401.9 846.6 414.3 859.8 426 872.2 440.3 885.6 453.5 896.5 467.5 908 482.2 918.6 498.1 928.5 514.6 938.8 529.6 946.8 547.5 954.9 565.4 963.1 581.1 969.1 600 974.9 619.5 981 634.4 984.7 654.9 988.4 675.6 992.1 690.4 994 711.1 995.4 718.7 995.9 726.4 996.2 732.9 996.5 739.3 996.7 743.6 996.9 747.7 996.6 751.6 996.4 765.3 995.6 777.8 994.9 795 993.9 809.6 992.1 829.8 988.5 851.1 984.6 864.8 981.2 886.1 974.5 905.9 968.3 921.5 962.3 938.6 954.4 954.9 947 970.9 938.3 987.2 928.2 1004.1 917.7 1018.1 907.6 1032.8 895.5 1047 883.8 1060.1 871.4 1072.6 857.9 1082.7 847 1091.9 836.9 1096.7 830.2 1097.1 829.5 1097.6 829 1098.1 828.3 1099.1 828.3 1099.6 828.2 1100.9 828.2 1107.9 828.1 1118.9 828 1134.6 827.9 1166.2 827.6 1216.9 827.5 1294.3 827.4 1448.9 827.2 1709.6 827.1 2134.7 827.2 2483.3 827.2 2745 827.1 2920.7 826.9 3008.5 826.8 3074.8 826.7 3119.7 826.5 3142.2 826.4 3159.3 826.4 3171.1 826.3 3176.9 826.2 3181.5 826.2 3184.8 826.1 3188.1 826.1 3188.7 826.2 3191.8 825.9 3200.3 825.1 3212.7 822.9 3222.1 820.8L3222.3 820.7C3231.9 818.4 3245.6 814.3 3255 810.8 3264.5 807.2 3277.6 801.3 3286.5 796.6 3295.4 791.9 3308.1 784.1 3316.7 778.1 3325.4 772.1 3337.1 762.9 3344.8 756 3352.3 749.3 3362.7 738.8 3369.5 731.2 3376.3 723.6 3385.4 712.3 3391 704.3 3396.7 696.2 3404.2 683.9 3409.1 674.9 3414 665.7 3420.2 651.9 3424 642.1 3427.7 632.2 3432.1 617.9 3434.4 607.9 3436.8 597.3 3438.9 583.2 3439.8 571.9 3440.9 558.4 3440.9 547.7 3439.8 534.2 3438.9 522.8 3436.8 508.8 3434.4 498.2 3432.2 488.3 3428.3 475.2 3425.1 466.5 3421.9 458 3416.3 445.4 3411.8 436.5L3411.7 436.5C3407 427.2 3399 413.7 3392.6 404.4 3386.2 395.1 3376.6 382.7 3369.6 374.9 3362.8 367.3 3352.3 356.8 3344.8 350 3337.1 343.2 3325.4 333.9 3316.7 327.9 3308 321.9 3295.2 314.1 3286.2 309.3 3277.2 304.6 3264 298.7 3254.7 295.2 3245.2 291.6 3230.3 287.2 3219.2 284.6L3200.7 280.3 3198.9 279.9 3197.2 279.9 3120.1 278.9C3076.1 278.3 2588.5 277.8 2033.4 277.8L1029.6 277.6C1028 276.3 1019.9 269.8 1012.9 264 1005.4 257.8 987.1 243.2 971.4 230.9 955.7 218.7 923.8 194.1 900.3 176 876.9 158 828.8 121.3 793.5 94.5 775.8 81 758.4 67.9 744.6 57.6 730.8 47.4 721.4 40.4 716.8 37.3 709.5 32.5 698.8 26.4 691.1 22.5 683.3 18.6 672.4 14 664.2 11.3 656 8.6 644.8 6 636.2 4.8 630.7 4 625.9 3.5 620.9 3.3ZM619.9 35.8C623.4 35.9 626.7 36.2 631.4 36.9 637.1 37.8 648.7 40.5 654.1 42.3 659.6 44.1 670.9 48.8 676.5 51.6 682.3 54.5 693.2 60.8 698.9 64.5 700.6 65.7 711.5 73.5 725.2 83.7 738.9 93.9 756.2 107 773.8 120.4 809.1 147.2 857.2 183.9 880.5 201.9 903.9 219.8 935.8 244.5 951.4 256.6 966.9 268.7 985.5 283.5 992.1 289 999.2 294.9 1006.6 301 1009.8 303.4L1014.2 306.8 1018.6 310.2 1024.1 310.2 2033.4 310.3C2588.5 310.4 3078.7 310.9 3119.6 311.4L3194.9 312.4 3211.8 316.3C3221.1 318.5 3236.3 323 3243.3 325.6 3250.4 328.3 3264 334.4 3271 338.1 3278.1 341.8 3291.2 349.8 3298.1 354.7 3305.1 359.5 3317.2 369 3323.1 374.3 3329.2 379.7 3339.8 390.4 3345.3 396.6 3350.6 402.5 3360.6 415.2 3365.8 422.8 3371 430.4 3379.2 444.2 3382.7 451.2 3386.5 458.6 3392.2 471.5 3394.6 477.9 3396.9 484.1 3400.9 497.6 3402.7 505.4 3404.6 513.7 3406.7 527.4 3407.4 536.7 3408.4 549 3408.4 557 3407.4 569.3 3406.7 578.6 3404.6 592.3 3402.7 600.6 3401 608.1 3396.5 622.8 3393.6 630.5 3390.6 638.3 3384.2 652.3 3380.4 659.5 3376.5 666.7 3368.7 679.3 3364.4 685.6 3360 691.8 3350.8 703.4 3345.3 709.5 3339.8 715.6 3329.1 726.3 3323.1 731.7 3317.2 737.1 3305.1 746.6 3298.1 751.4 3291.1 756.3 3278.1 764.2 3271.3 767.9 3264.4 771.5 3251 777.5 3243.6 780.3 3236.2 783 3222.2 787.3 3214.9 789 3207.3 790.8 3194.3 793 3188.6 793.6 3190.6 793.4 3187.5 793.6 3184.3 793.6 3181.2 793.7 3176.7 793.7 3170.8 793.7 3159.1 793.8 3142 793.9 3119.6 794 3074.8 794.2 3008.5 794.3 2920.7 794.4 2745.1 794.6 2483.3 794.6 2134.7 794.6 1709.6 794.6 1448.9 794.6 1294.2 794.8 1216.9 794.9 1166.1 795.1 1134.4 795.4 1118.6 795.5 1107.6 795.6 1100.4 795.7 1096.7 795.8 1094.1 795.8 1092.2 795.9 1091.2 796 1090.4 796 1089.5 796 1089.1 796.1 1088.6 796.1 1087.8 796.3 1087.4 796.3 1086.8 796.4 1085.9 796.7 1084.9 796.9 1083.8 796.6 1079.9 799.8 1076.3 802.7 1076.2 803.5 1074.7 805.3 1073.2 807.1 1071.7 809.2 1070.2 811.3 1069.6 812.2 1058.3 825.5 1048.7 835.8 1037.3 848.2 1025.2 859.6 1012.1 870.5 998.4 881.8 985.8 890.7 970 900.6 954.8 910.1 940.1 918 925 924.9 909.1 932.2 895.1 937.5 876.4 943.4 855.8 949.9 844.5 952.7 824 956.4 804.7 959.9 792 961.5 776 962.4 763.4 963.1 749.9 963.9 745.6 964.2 745.7 964.2 740.2 964.2 734.1 964 728 963.7 720.5 963.4 713.2 962.9 693.3 961.6 680.6 960 660.8 956.4 641.2 952.9 628.3 949.7 609.6 943.9 591.9 938.4 577.8 933 560.9 925.3 544.1 917.6 530.9 910.6 515.3 900.9 500.6 891.7 487 881.9 474.2 871.4 462.2 861.5 448.6 848.8 438 837.5 426.6 825.3 416.4 812.9 406.8 799.4 397.3 786.1 388.8 772.2 381.1 757.6 377.9 751.5 374.7 745.2 372.2 740.1 369.8 735 367.8 729.9 368.1 730.9 367.3 728 366.2 725.5 364.8 723 364.2 721.7 363.4 720.5 362.1 718.8 360.7 717.2 359.5 713.9 351.6 712.5 346.3 711.5 344.8 712.7 343.6 713.1 342.4 713.5 341.7 713.8 341.2 714 340.2 714.5 339.7 714.9 339.1 715.2 338 715.9 337 716.6 335.8 717.4 333.4 719.1 330.5 721.2 326.9 723.9 319.8 729.3 310.4 736.7 300.2 744.8 280.2 760.9 255.4 780.7 245.3 788.5 235.7 796 221.8 805.3 218.8 806.9 213.9 809.5 205.4 813.4 202.5 814.5 199.4 815.7 190.2 818.4 184.4 819.9L174.8 822.3 154.5 822.3C136.3 822.2 133.4 822 125.7 820.1 121.1 818.9 112 816 108.2 814.4 104.4 812.8 96.2 808.6 92.5 806.4 89.4 804.6 80.8 798.4 76.4 794.5 72.1 790.9 64.4 782.9 61.7 779.5 58.7 775.7 52.8 766.8 50.2 762.2 47.8 758 43.2 747.7 41.5 742.6 39.8 737.5 37.2 726.8 36.7 722.6 36 718.1 35.6 707.3 35.9 701 36.2 694.5 37.6 684.8 38.7 680.8 39.9 676.1 42.8 667.2 44.3 663.5 45.7 659.9 49.7 651.5 52.1 647.4 54.4 643.3 59.9 634.8 63.1 630.5 65.8 626.8 74.4 616.9 80.3 610.7 86.3 604.5 96.3 595.2 100.7 591.5 104.1 588.7 144.4 557.4 186.2 525.2 210.9 506.3 230 491.5 243 481.1 249.6 476 254.6 471.9 258.1 468.9 259.9 467.4 261.2 466.2 262.5 465 263.1 464.4 263.7 463.9 264.5 462.9 264.9 462.4 265.4 461.9 266.2 460.7 266.9 459.5 268.8 458.4 268.8 451.8 268.8 446 266.9 443.6 265.7 441.7 264.5 439.9 263.4 438.7 262.3 437.6 260.1 435.4 257.8 433.7 255.2 432.1 250.5 429.2 243.4 425.7 237 422.7 231.9 420.3 223.7 415.7 221.7 414.3 219.6 412.9 212.6 407.5 208.9 404.1 205.7 401.1 199.1 393.8 196.7 390.6 194.4 387.4 189.2 378.8 187.1 374.5 185 370.1 181.6 360.7 180.6 357.1 179.7 353.3 178.2 343.5 177.9 335.7 177.4 325.5 177.7 320.2 179 312.3 179.9 307.2 182.8 296.5 184.4 292.1 186.2 287.4 190.9 277.7 193.3 273.5 196.1 268.8 201.8 261.6 209.2 254.2 215.3 248 225.4 239.6 226.9 238.6 230 236.6 239.4 231.3 245.5 228.4 251.3 225.6 261.7 221.4 265.7 220.2 270.3 218.7 280.4 216.1 286.3 215 292 213.8 303.3 212.1 309.5 211.5 310.8 211.4 324 210.9 341.3 210.6 358.6 210.4 381.4 210.3 406.9 210.3L493.2 210.3 504.4 210.3 508.4 199.8 509.1 198C511.6 191.5 510.2 190.4 510 188.8 509.8 187.2 509.6 185.9 509.2 184.6 508.5 181.9 507.6 179.3 506.5 176.5 505.1 172.9 502.7 166 501.9 163.1 501.9 162.9 500.2 151.5 499.9 144.1 499.5 135.1 499.9 127 500.5 123.6 501 120.9 503.5 111.7 505.3 107.1 507 102.6 511.3 94.1 512.5 92.2 514.4 89.3 519.4 82.6 522.2 79.4 524.2 77 531.9 69.4 536.9 65.3 541.7 61.3 551.1 54.6 555 52.3 559.4 49.8 568.5 45.3 572.6 43.7 577.1 41.9 585.2 39.3 587.4 38.8 589 38.4 599.8 36.9 607.3 36.3 612.9 35.9 616.5 35.7 619.9 35.8ZM760.6 900.9C743.9 901.4 722.3 901.2 712.6 900.4 703 899.6 685.3 897.2 673.5 895 661.6 892.7 642.5 888 631 884.3 619.5 880.7 601.3 873.7 590.5 868.9 579.8 864.1 562.3 854.6 551.8 848 541.2 841.3 524.6 829.2 515 821.1 505.3 813 491.8 800.2 485 792.6 478.2 785.1 467.1 771.1 460.4 761.5 453.6 752 444 736.1 439 726.1 434 716.2 427 700 423.4 690.1 419.9 680.2 415 662.6 412.5 650.9 410.1 639.2 407.5 621.4 406.9 611.2 406.3 601 405.4 590.9 404.9 588.6 404.4 586.3 402.8 584.4 401.2 584.4 399.7 584.4 367.9 609.2 330.6 639.5 293.3 669.8 246.5 707.5 226.6 723.2 206.8 739 186.8 753.4 182.3 755.2 177.8 757.1 167.8 759 160 759.5 149.9 760.1 143.4 759.5 136.8 757.3 131.7 755.6 124.1 751.3 119.9 747.7 115.7 744.1 110 737.4 107.4 732.8 103.8 726.8 102.2 721 101.6 711.7L100.7 698.8 107.7 684.7C112.9 674.1 118.4 666.7 129.6 655.6 139.3 645.9 205.1 594.1 315.4 509.5 409.4 437.4 487.8 377 489.6 375.1 491.4 373.3 492.4 371 491.8 370.1 491.1 368.9 451.1 368.3 385.9 368.3 296.8 368.3 279.6 367.8 271.4 365.4 266.1 363.9 259 360.1 255.5 357 252 354 247.8 347.8 246 343.2 243.8 337.5 243.2 332.2 244 325.9 244.6 320.9 246.7 313.6 248.7 309.6 250.8 305.6 255.4 299.7 259 296.5 262.7 293.3 269 289 273.2 286.9 277.3 284.8 287 281.3 294.8 279.1L309 275.1 506.5 274.1C635.7 273.5 704.2 272.5 704.6 271.3 704.9 270.3 704.6 268.6 703.8 267.5 703.1 266.4 674.9 243.4 641.3 216.2 607.6 189.1 577.9 163.7 575.3 160 572.7 156.2 569.7 150.7 568.6 147.9 567.6 145 566.7 140.6 566.7 138.1 566.7 135.6 568.3 130.3 570.3 126.4 572.3 122.5 577.1 116.8 580.9 113.8 584.8 110.8 591.7 106.4 596.2 104 602.8 100.6 607.4 99.7 618.4 99.7 628.3 99.8 635.1 101 642.2 103.9 647.7 106.1 658 111.7 665.1 116.3 672.2 120.9 716.3 153.9 763.2 189.6 810.1 225.3 870.8 271.9 898.3 293.3 925.7 314.6 956 338.5 965.7 346.6 975.3 354.6 991.9 369.6 1002.4 380 1013 390.3 1026 404.2 1031.4 411 1036.8 417.7 1045 429.2 1049.8 436.5 1054.6 443.9 1061.2 455.8 1064.5 463.1 1067.9 470.4 1072.7 483 1075.3 491.2 1077.8 499.3 1081.3 512.5 1082.9 520.5 1084.5 528.5 1086.7 545 1087.6 557.3 1088.6 570.9 1088.6 589 1087.6 603.6 1086.6 616.9 1084.1 636.4 1081.9 647 1079.8 657.6 1074.7 675.9 1070.7 687.7 1066.6 699.5 1058.7 717.8 1053 728.5 1047.3 739.2 1037.2 755.4 1030.7 764.5 1024.1 773.7 1012.4 787.8 1004.7 795.9 997 804.1 984 816.2 975.8 823 967.6 829.7 953.5 840 944.4 845.9 935.3 851.8 917.9 861.3 905.7 867.1 893.5 872.9 873.9 880.6 862.2 884.2 850.6 887.9 829.8 892.9 816.1 895.4 798.8 898.6 781.8 900.3 760.6 900.9ZM755.1 758.7C763.7 758.2 777.3 756.6 785.2 755 793.1 753.5 805.2 750.3 812 748 818.8 745.7 831.9 740 841 735.4 850.1 730.8 863.6 722.8 870.8 717.7 878 712.6 889.2 703.6 895.6 697.7 901.9 691.8 911.4 681.5 916.7 674.9 921.9 668.3 929.3 657.8 933.1 651.5 936.8 645.3 942.4 634 945.5 626.6 948.6 619.1 952.8 606 954.9 597.5 957.4 586.7 958.6 575.6 958.6 561.5 958.6 548.9 957.4 535.6 955.4 527.1 953.7 519.5 949.8 507.1 946.8 499.4 943.8 491.8 938.4 480.6 934.7 474.5 931.1 468.4 924.1 458.4 919.3 452.2 914.5 446 904.3 435.2 896.8 428 889.3 420.8 875.5 410 866.2 403.8 856.8 397.7 841.3 389.2 831.7 385 822.1 380.8 806.1 375.2 796.2 372.6 783.4 369.3 771.3 367.7 754.2 367 736.4 366.3 725.6 366.8 712.7 369.1 703.1 370.7 687.7 374.6 678.5 377.7 669.3 380.8 655 386.9 646.7 391.3 638.3 395.7 626.8 402.5 621 406.3 615.3 410.1 603.5 420 594.8 428.2 586.1 436.4 574.3 449.4 568.5 457.1 562.8 464.8 554.6 478.2 550.4 486.9 546.2 495.6 540.5 510.9 537.8 520.9 533.8 535.8 532.9 543.2 532.9 561.3 532.9 579.5 533.8 586.8 537.9 602 540.6 612.1 546.5 628 550.9 637.2 555.4 646.4 564 660.6 569.9 668.6 575.9 676.7 587.6 689.6 595.8 697.3 604.1 705.1 616.9 715.4 624.2 720.2 631.6 725.1 644.7 732.6 653.4 736.8 662.1 741.1 674.6 746.3 681.2 748.5 687.8 750.6 697.4 753.3 702.5 754.5 707.5 755.6 717.9 757.2 725.5 758 733.2 758.9 746.5 759.1 755.1 758.7ZM1355.5 736.6C1344.8 737 1332.3 737 1327.8 736.6 1323.2 736.2 1313.6 734.5 1306.5 732.8 1299.4 731.2 1287.8 727.1 1280.7 723.7 1273.5 720.4 1262.5 713.4 1256 708.2 1249.4 702.8 1243.1 699.1 1241.7 699.7 1239.8 700.4 1239.1 704.2 1239.1 713.7L1239.1 726.7 1201.4 726.7 1163.8 726.7 1162.6 723.7C1162 722.1 1161.5 641.5 1161.5 544.5L1161.5 368.2 1164.2 367.1C1165.6 366.6 1178.7 369.2 1193.3 372.9 1207.8 376.6 1224.1 380.9 1229.4 382.4L1239.1 385.2 1239.1 432.7C1239.1 466.3 1239.7 480.9 1241.2 482.4 1242.9 484 1245.1 483.2 1250.3 479 1254.1 476 1262.5 471.1 1269 468 1275.5 465 1288.3 460.8 1297.4 458.7 1306.9 456.6 1324.5 454.5 1338.8 453.9 1354.2 453.2 1369 453.6 1377.3 454.9 1384.8 456.1 1396.7 459 1403.7 461.5 1410.8 464 1421.8 469.1 1428.1 472.9 1434.4 476.7 1445.4 485.7 1452.7 492.9 1463.2 503.4 1467.4 509.3 1474.2 523.5 1478.8 533.1 1483.8 546.8 1485.4 553.9 1486.9 561 1488.8 573.2 1489.5 580.9 1490.2 589.2 1489.8 602.1 1488.5 612.3 1487.2 621.9 1484.6 635.1 1482.6 641.7 1480.6 648.3 1476 659.4 1472.5 666.4 1469 673.4 1462.7 683.5 1458.7 688.9 1454.6 694.3 1446.5 702.6 1440.8 707.4 1435 712.2 1423.6 719.3 1415.5 723.2 1407.4 727.1 1394.9 731.6 1387.8 733.1 1380.7 734.6 1366.1 736.2 1355.5 736.6ZM1323.1 676.6C1326.2 676.7 1334.1 675.9 1340.7 674.8 1347.3 673.8 1357.3 670.8 1362.9 668.3 1368.5 665.7 1375.8 661 1379.2 657.7 1382.6 654.5 1388.1 648.1 1391.5 643.4 1394.9 638.8 1399.7 630.1 1402 624 1405.6 614.8 1406.3 609.7 1406.2 591.8L1406.1 570.6 1401.1 558.5C1398.3 551.9 1392.7 542.6 1388.5 537.7 1384.1 532.6 1376.5 526.6 1370.5 523.6 1364.7 520.6 1356.5 517.3 1352.2 516.2 1347.9 515 1337.5 514 1329 513.9 1320.6 513.9 1308.7 514.8 1302.7 516 1296.7 517.3 1286.6 520.7 1280.4 523.6 1274.1 526.6 1266.4 531.9 1263.3 535.5 1260.1 539.1 1255 547 1252 553.1 1248.9 559.1 1245.2 568.8 1243.7 574.6 1242.2 580.4 1240.9 589.7 1240.9 595.2 1240.9 600.6 1242.2 610.2 1243.8 616.5 1245.4 622.7 1250 633.3 1253.9 640 1257.9 646.8 1264 654.8 1267.6 657.9 1271.1 661.1 1278 665.4 1282.9 667.6 1287.8 669.8 1297.6 672.7 1304.7 674 1311.8 675.4 1320.1 676.5 1323.1 676.6ZM2453.7 736.7C2441.5 737.2 2426.6 736.8 2420.6 735.8 2414.6 734.9 2404.8 732.9 2398.9 731.4 2393.1 729.8 2382.6 725.8 2375.7 722.3 2368.8 718.8 2358.8 712.4 2353.5 708.1 2348.2 703.8 2340.1 695.4 2335.4 689.4 2330.7 683.5 2323.7 671.7 2319.7 663.2 2315.7 654.8 2311.2 642.9 2309.6 636.9 2308 630.9 2305.9 618.1 2304.9 608.6 2303.9 599 2303.6 585.7 2304.2 579 2304.9 572.3 2307 560.4 2308.9 552.5 2310.9 544.5 2314.4 533.7 2316.8 528.4 2319.1 523.2 2324 514.2 2327.6 508.5 2331.3 502.9 2338.9 493.9 2344.7 488.7 2350.4 483.4 2359.9 476.4 2365.6 473 2371.4 469.7 2380.7 465.1 2386.3 463 2391.9 460.8 2399.7 458.1 2403.8 456.9 2407.9 455.8 2422.4 454.4 2436.1 453.8 2454.8 453 2466.1 453.6 2481 455.9 2492 457.6 2505.3 460.5 2510.6 462.4 2515.9 464.2 2524.5 467.8 2529.7 470.5 2534.8 473.1 2541.7 477.4 2544.8 480.1 2548 482.7 2551.4 484.4 2552.5 483.8 2553.7 483 2554.4 465 2554.4 433.9L2554.4 385.2 2557.6 384.2C2559.4 383.6 2575.4 379.4 2593.2 374.8 2610.9 370.2 2626.9 366.4 2628.7 366.4L2632 366.4 2631.5 546.1 2631 725.7 2592.7 726.2 2554.4 726.7 2554.4 713.7C2554.4 704.2 2553.7 700.4 2551.8 699.7 2550.3 699.1 2544 702.8 2537.5 708.2 2531 713.4 2520.4 720.2 2513.8 723.4 2507.2 726.5 2496 730.6 2488.8 732.4 2481.4 734.4 2466.3 736.2 2453.7 736.7ZM2466.6 676.5C2469.7 676.6 2477.7 675.9 2484.5 674.9 2491.3 673.9 2501.4 671.4 2506.9 669.4 2512.5 667.3 2520.9 662.2 2525.5 658.1 2530.2 654 2537 645.3 2540.7 638.7 2544.3 632.2 2548.5 622.1 2549.9 616.3 2551.2 610.4 2552.4 600.7 2552.5 594.6 2552.5 588.5 2551 578.1 2549.1 571.4 2547.2 564.8 2543.9 556.1 2541.8 552 2539.8 548 2535 541.2 2531.3 536.9 2527.2 532.1 2520.1 527 2513.2 523.7 2506.9 520.8 2497.6 517.4 2492.5 516.2 2487.5 514.9 2476.2 513.9 2467.5 513.9 2458.9 513.9 2447.6 515 2442.6 516.2 2437.5 517.4 2428.5 520.9 2422.6 524 2416.7 527.1 2409 533 2405.5 537.1 2402 541.2 2397 549 2394.5 554.4 2391.9 559.7 2388.9 568.9 2387.8 574.7 2386.7 580.6 2386.3 591.6 2386.9 599.4 2387.5 607.1 2389.8 618.3 2392 624.3 2394.2 630.3 2399.6 639.8 2403.9 645.4 2408.2 651 2413.7 657.4 2416.1 659.6 2418.5 661.8 2425.1 665.7 2430.7 668.3 2436.3 670.9 2445.5 673.8 2451 674.7 2456.5 675.6 2463.6 676.4 2466.6 676.5ZM1790.5 733.3C1762.9 733.9 1754.6 733.4 1741.6 730.5 1732.9 728.6 1720.1 724.9 1713 722.2 1705.9 719.5 1695.1 714.5 1688.9 711 1682.8 707.6 1673.3 701.2 1667.9 696.8 1662.5 692.4 1654.3 683.8 1649.7 677.7 1645.2 671.6 1638.9 661.8 1635.9 655.8 1632.9 649.9 1628.8 638.6 1626.7 630.9 1623.8 620.3 1622.8 610.9 1622.8 593.6 1622.7 575.4 1623.6 567.1 1626.9 553.9 1629.3 544.8 1633.1 533 1635.4 527.7 1637.7 522.5 1642.9 513.3 1647 507.4 1651.1 501.5 1657.6 493.7 1661.5 490.1 1665.4 486.4 1671.9 481 1676 477.9 1680 474.9 1688.2 469.7 1694.1 466.4 1700 463.2 1710.4 458.5 1717.2 456.1 1724 453.6 1736.6 450.2 1745.1 448.5 1757.4 446.1 1766.9 445.6 1790.4 446.4 1814.3 447.1 1823.7 448.2 1838.4 452.1 1848.6 454.8 1863.9 460.4 1872.6 464.7 1883.5 470.1 1892.4 476.4 1901.6 485.1 1908.9 492.1 1918.3 502.9 1922.4 509.3 1926.6 515.7 1932 526 1934.6 532.2 1937.1 538.5 1940.4 550.8 1942 559.6 1943.5 568.4 1944.8 583.4 1944.8 593L1944.8 610.3 1831.3 610.3C1745.5 610.3 1717.4 610.8 1716.4 612.5 1715.7 613.7 1715.2 618.3 1715.5 622.8 1715.7 627.3 1717.6 635.4 1719.6 640.9 1722.3 648 1726 653.1 1732.6 659 1737.8 663.5 1746.2 669.3 1751.5 671.9 1756.7 674.6 1765.8 677.6 1771.7 678.7 1779.3 680.1 1786.2 680.1 1794.9 678.8 1801.8 677.7 1811 675.6 1815.5 674 1820 672.4 1827.6 668.6 1832.4 665.5 1837.3 662.4 1844.2 655.7 1847.9 650.7 1851.6 645.7 1855.1 641.9 1855.8 642.3 1856.5 642.6 1870.2 650.3 1886.3 659.4 1902.5 668.4 1916 676.9 1916.5 678.1 1917 679.4 1914.8 683.8 1911.5 687.9 1908.3 692 1900.4 699 1894.1 703.5 1887.8 708 1876.8 714.3 1869.8 717.5 1862.8 720.7 1849.5 725.3 1840.4 727.9L1823.8 732.6 1790.5 733.3ZM1786.9 556.7L1853.8 556.7 1855 553.7C1855.7 552 1854.9 546.3 1853.4 541 1851.8 535.6 1847.9 527.3 1844.6 522.3 1839.7 515 1835.7 511.9 1824 505.9L1809.4 498.5 1787 498.5 1764.7 498.6 1755 502.9C1749.6 505.3 1741.7 511.1 1736.9 516.3 1732.3 521.2 1726 530.5 1723 536.9 1720 543.2 1717.5 549.7 1717.5 551.3 1717.5 552.9 1718.1 554.8 1718.8 555.5 1719.5 556.1 1750.1 556.7 1786.9 556.7ZM2828.7 733.3C2801.1 733.9 2792.8 733.4 2779.7 730.5 2771.1 728.6 2758.2 724.9 2751.1 722.2 2744.1 719.5 2733.2 714.4 2727 710.9 2720.7 707.5 2711.7 701.3 2706.8 697.3 2702 693.3 2694.4 685.6 2690 680.2 2685.5 674.9 2679.5 665.9 2676.5 660.2 2673.5 654.6 2669.3 644.7 2667.2 638.1 2665.1 631.6 2662.5 619 2661.4 610.1 2660 598.6 2660 588.8 2661.4 576 2662.6 565.9 2666 550.4 2669.2 540.5 2672.7 530 2678.5 517.7 2683.8 509.8 2688.5 502.6 2695.7 493.7 2699.6 490.1 2703.5 486.4 2710.1 480.9 2714.2 477.8 2718.2 474.7 2728.2 468.8 2736.3 464.6 2744.5 460.4 2758.6 454.7 2767.7 452L2784.4 446.9 2821.3 447 2858.2 447 2876.6 452C2886.7 454.7 2902.1 460.4 2910.8 464.7 2921.7 470.1 2930.6 476.4 2939.8 485.1 2947.1 492.1 2956.4 502.9 2960.6 509.3 2964.7 515.7 2970.2 526 2972.7 532.2 2975.3 538.5 2978.6 550.8 2980.1 559.6 2981.7 568.4 2982.9 583.4 2982.9 593L2982.9 610.2 2868.9 610.7 2754.8 611.2 2754.2 619.1C2753.9 623.5 2755 631.8 2756.7 637.6 2759.1 645.8 2761.8 650.1 2769 657.1 2774.4 662.3 2784.1 668.8 2792.3 672.7L2806.3 679.4 2821.2 679.3C2829.9 679.3 2841 677.8 2847.8 675.9 2854.2 674 2864.3 669.4 2870.2 665.6 2876.1 661.7 2883.5 654.9 2886.8 650.2L2892.6 641.7 2898.5 644.7C2901.8 646.4 2914 653.2 2925.7 659.8 2937.4 666.4 2949.2 673.4 2952 675.4L2957.1 678.9 2950.3 687.2C2946.5 691.8 2938.3 699.2 2932 703.8 2925.7 708.3 2915 714.4 2908.2 717.5 2901.5 720.6 2888.3 725.2 2878.9 727.8L2861.9 732.6 2828.7 733.3ZM2825.1 556.7L2892 556.7 2893.2 553.7C2893.8 552 2893.1 546.4 2891.6 541.2 2890.1 536 2886.4 527.9 2883.4 523.1 2880.3 518.3 2874.5 512.3 2870.5 509.7 2866.5 507.2 2857.9 503.3 2851.5 501.2 2841.7 497.8 2836.8 497.3 2821.3 498 2807.6 498.6 2800.4 499.9 2793.2 503 2787.5 505.5 2780.1 511 2775.1 516.3 2770.4 521.2 2764.2 530.5 2761.2 536.9 2758.2 543.2 2755.7 549.7 2755.7 551.3 2755.7 552.9 2756.3 554.8 2757 555.5 2757.6 556.1 2788.3 556.7 2825.1 556.7ZM1559.6 726.7L1523.6 726.7 1523.6 557.6 1523.6 388.6 1559.6 388.6 1595.6 388.6 1595.6 557.6 1595.6 726.7ZM2011.7 726.2L1972.4 726.7 1972.9 601.5 1973.4 476.3 2012.2 476.3 2051 476.3 2051.5 486C2051.9 492.1 2052.9 495.7 2054.3 495.7 2055.5 495.7 2060.2 491.9 2064.6 487.3 2069.1 482.7 2077.1 476.1 2082.4 472.7 2087.7 469.3 2097.8 464 2104.8 460.9 2111.8 457.9 2124.5 454 2133.1 452.2 2145 449.8 2153 449.4 2167.3 450.3 2177.5 451 2192.5 453.5 2200.7 455.9 2208.8 458.3 2220.5 462.7 2226.6 465.8 2232.6 468.8 2241.7 474.7 2246.7 478.9 2251.8 483 2258.5 490.6 2261.7 495.7 2264.9 500.7 2268.5 509.7 2269.7 515.6 2271.3 523.2 2271.7 554.3 2271.4 626L2270.8 725.7 2232.9 725.7 2195.1 725.7 2194.1 637.1 2193.2 548.4 2189 539.2C2186.6 534.1 2182.5 527.6 2179.7 524.7 2177 521.8 2171.4 517.6 2167.4 515.5 2161.1 512.1 2156.9 511.5 2139.7 511.5 2123.3 511.5 2117 512.3 2107.3 515.7 2100.7 518 2091.5 522.7 2086.8 526.1 2082.1 529.5 2075.2 536.1 2071.5 540.7 2067.7 545.3 2062 555.5 2058.8 563.5L2053 577.9 2052 651.8 2051 725.7ZM3048.1 726.2L3010.6 726.7 3010.6 602 3010.6 477.3 3047.6 477.3 3084.5 477.3 3084.5 484.5C3084.5 488.6 3085.4 492.3 3086.4 493 3087.4 493.6 3095.1 487.2 3103.5 478.6 3115.1 466.8 3122 461.5 3132.6 456.3 3140.2 452.6 3151.4 448.7 3157.5 447.7 3163.6 446.7 3173.6 445.9 3179.8 445.9 3186 445.9 3191.9 446.8 3193.2 448.1 3194.7 449.6 3195.4 459.3 3195.4 479.9 3195.3 496.3 3194.7 510.5 3194 511.7 3193.1 512.9 3184 514.1 3170.3 514.5L3147.9 515.3 3132.1 522.9C3122.3 527.6 3113.3 533.6 3108.5 538.5 3104.3 542.8 3098.9 550.2 3096.6 554.8 3094.3 559.4 3091.3 566.5 3089.9 570.6 3088.2 575.9 3087.2 598.6 3086.5 651.8L3085.5 725.7ZM751.6 678.5C740.3 678.5 728.8 677.1 720 674.8 712.4 672.9 700.1 668.3 692.7 664.7 685.4 661 673.3 653.3 665.8 647.4 658.4 641.5 648.5 631.5 643.7 625.1 639 618.8 632.5 608.3 629.4 601.8 626.3 595.3 622.5 583.9 620.9 576.5 619 567.8 618.4 558.6 619.1 550.1 619.7 543 621.8 531.8 623.8 525.2 625.9 518.7 630.7 507.9 634.5 501.3 638.4 494.7 646.9 483.9 653.5 477.3 660.1 470.7 671 461.7 677.7 457.4 684.4 453.1 696.4 447.1 704.5 444.2 712.5 441.3 724.8 437.9 731.8 436.6 739.7 435.2 750.3 434.7 759.5 435.4 767.8 436 779.5 437.9 785.6 439.5 791.7 441.1 803.4 445.7 811.6 449.7 819.8 453.7 832.1 461.6 838.9 467.2 845.6 472.8 855 482.1 859.6 487.9 864.2 493.8 870.3 503.5 873.3 509.6 876.2 515.7 879.9 525.8 881.5 532 883.1 538.3 884.4 548.8 884.4 555.5 884.4 562.2 883.5 572 882.4 577.4 881.3 582.8 877.2 593.8 873.4 602 869.6 610.1 863.1 621.1 858.8 626.4 854.6 631.7 845.6 640.7 838.8 646.2 832.1 651.8 820.1 659.7 812.1 663.9 804.2 668 791.3 673 783.5 675 774 677.4 763.4 678.6 751.6 678.5ZM3244.4 456.9C3242.1 456.9 3241.6 452.2 3241.1 429.7L3240.6 402.5 3231.5 401.5C3226.5 401 3222.1 399.5 3221.7 398.2 3221.1 396.4 3225.9 396 3244.2 396.4 3258.1 396.7 3267.4 397.7 3267.5 398.8 3267.5 399.8 3264 400.9 3259.6 401.2 3255.3 401.5 3250.7 402.7 3249.4 403.7 3247.8 405.1 3247.1 412.8 3247.1 431.3 3247.1 452.7 3246.6 456.9 3244.4 456.9ZM3285.1 455C3283.9 455.4 3279.9 456.1 3279.4 454.7 3278.8 453.3 3278.6 439.7 3278.9 424.5L3279.4 396.9 3284 396.9 3288.7 396.9 3297 420.7C3301.5 433.8 3305.9 444.5 3306.7 444.4 3307.5 444.3 3308.8 444 3309.6 443.7 3310.4 443.4 3314.8 432.7 3319.3 420 3326.2 400.7 3324.7 398.1 3328.1 397.6 3331.1 397.2 3336.1 395.7 3336.9 398.9 3337.4 401.2 3337.5 415.9 3337.2 430.1L3336.7 456 3333.5 456.6 3330.2 456.2 3330.2 436.8C3330.2 425.5 3329.5 416.3 3328.6 416.3 3327.7 416.3 3326.2 417.3 3325.2 418.6 3324.2 419.9 3320.6 429.1 3317.4 439L3311.4 457.2 3307.6 456.6C3304.6 456.1 3302.5 452 3297.2 436.7 3293.5 426.1 3289.5 417.2 3288.3 416.8 3286.6 416.2 3286.4 421.8 3286 436.4 3285.6 450.2 3286.7 454.5 3285.1 455Z", + "width": 3444 + }, + "search": [ + "blender-logo" + ] + }, + { + "uid": "d39e4697810cd706bad736e291eb081c", + "css": "blender-cloud-logo", + "code": 59410, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M343.5 855.1C244 833.6 160.9 748.2 141.4 647.4 129.6 585.9 143.2 509.8 175.3 457.8L189.5 434.8 165.6 417.7C152.5 408.2 141.7 398.7 141.7 396.5 141.7 392.6 160.7 388.1 220.3 377.7 247.1 373 266.5 376.5 266.7 386 266.8 387.9 270.9 411.7 275.8 439 286.4 497.3 283.7 501.6 251.5 478.3L230.7 463.2 219.8 476.8C196.3 506 187.6 538.9 187.5 598.4 187.5 651.5 188.1 655 203.8 687.3 234.1 749.5 295.2 797.9 358.5 809.9 372.7 812.6 495.3 814.6 646.2 814.6 797.2 814.6 919.7 812.6 933.9 809.9 997.5 797.9 1058.8 749.2 1088.2 687.4 1102 658.5 1104.5 647.4 1106.4 608.1 1108.2 569.9 1106.8 557.2 1098 531.4 1069.4 447 998.8 388.7 918.9 383.4 876.3 380.6 864.7 374 860.2 350 851.2 301.4 798.9 233.2 752.3 209.4 694.6 180 620.6 177.4 564.5 203 476.4 243.2 423.2 340.6 439.3 432.7 457.4 536.5 546.9 612.8 650.6 612.8 677.4 612.8 725.3 603.3 738.4 595.4 740.2 594.3 736.4 581.4 729.9 566.8 723.4 552.1 719.3 538.2 720.7 535.9 722.2 533.5 743.3 538.9 767.7 547.8 829.2 570.2 827.8 569.5 830.7 577.2 833.8 585.3 798.2 687.3 792.2 687.3 789.9 687.3 782 676.1 774.6 662.4L761.3 637.5 733.8 647.2C694.8 661 632.6 664.1 593.4 654.2 494.6 629.3 418.6 554.1 396 459.1 375.2 371.6 401.2 281.2 465.9 216 517.1 164.3 580.9 137.2 651 137.2 758 137.2 862.7 209.8 895.7 306.7L904 331.2 939.3 337.2C1038.8 353.9 1122.6 432.6 1148.2 533.3 1184.4 675.5 1090.8 825 946.5 855.4 898.5 865.5 390.5 865.3 343.5 855.1ZM1504.1 459.6L1600.9 459.6C1615.8 459.6 1628.5 456.4 1639.1 450 1649.6 443.4 1657.9 434.4 1663.7 423 1669.5 411.4 1673.7 398.8 1676.4 385.3 1679 371.8 1680.4 357 1680.4 340.8 1680.4 255.1 1647.8 212.2 1582.7 212.2L1504.1 212.2 1504.1 459.6M1504.1 803.4L1612.1 803.4C1625.8 803.4 1637.9 800.6 1648.2 795.1 1658.5 789.2 1666.9 781.7 1673.2 772.4 1679.6 763.2 1684.6 751.5 1688.3 737.5 1692.3 723.5 1694.9 709.2 1696.2 694.6 1697.8 680.1 1698.6 663.8 1698.6 645.8 1698.6 629.6 1698 615.2 1696.6 602.5 1695.3 589.8 1692.7 577.2 1688.7 564.8 1685 552.3 1680 542 1673.6 533.8 1667.5 525.3 1659.2 518.6 1648.6 513.6 1638.3 508.5 1626.1 506 1612.1 506L1504.1 506 1504.1 803.4M1443.7 849.5L1443.7 166.1 1590.6 166.1C1614.2 166.1 1635.1 169.5 1653.4 176.1 1671.6 182.7 1686.4 191.4 1697.8 202.3 1709.2 213.1 1718.5 226.2 1725.6 241.6 1733 256.9 1738.1 272.6 1740.7 288.4 1743.6 304.3 1745.1 321.4 1745.1 339.7 1745.1 368 1738.5 395.6 1725.2 422.6 1712.2 449.6 1694.2 468.4 1671.2 479 1687.4 485.6 1701.4 495.4 1713.3 508.4 1725.2 521.1 1734.3 535.4 1740.7 551.3 1747.1 567.2 1751.7 583 1754.6 598.9 1757.5 614.5 1759 630.2 1759 645.8 1759 669.6 1757.8 690.6 1755.4 708.9 1753.3 727.2 1748.8 745.6 1741.9 764.1 1735.3 782.4 1726.4 797.4 1715.3 809.4 1704.2 821.3 1689.1 830.9 1670 838.3 1651 845.7 1628.5 849.5 1602.5 849.5L1443.7 849.5M1845.5 849.5L1845.5 166.1 1905.1 166.1 1905.1 849.5 1845.5 849.5M2067.1 570.7L2198.9 570.7C2198.6 560.1 2198.6 549.4 2198.9 538.6 2199.2 527.7 2199 516.9 2198.5 506 2198 494.9 2197.2 484.3 2196.1 474.3 2195.3 464.2 2193.6 454.9 2190.9 446.5 2188.3 437.7 2185 430.2 2181 423.8 2177 417.5 2171.6 412.4 2164.7 408.7 2157.9 405 2149.9 403.2 2140.9 403.2 2126.4 403.2 2114.3 405.8 2104.8 411.1 2095.5 416.4 2088.2 422.6 2083 429.8 2077.9 436.9 2074.2 449 2071.8 465.9 2069.5 482.9 2068 497.8 2067.5 510.8 2067.2 523.5 2067.1 543.5 2067.1 570.7M2006.7 630.3L2006.7 564C2007 530.9 2009.4 502.3 2013.9 478.2 2018.4 453.9 2024.2 434.4 2031.3 419.9 2038.7 405.3 2048.1 393.8 2059.5 385.3 2071.2 376.6 2083.1 370.8 2095.3 367.8 2107.4 364.7 2121.7 363.1 2138.1 363.1 2180.8 363.1 2211.3 378.2 2229.9 408.3 2248.6 438.5 2258 486.2 2258 551.3 2258 571.1 2257.8 587.4 2257.3 600.1L2066.7 600.1 2066.7 672.8C2066.7 704 2068.1 729.7 2071 749.8 2074.2 769.6 2079.1 784.6 2085.7 794.7 2092.3 804.7 2099.6 811.5 2107.6 814.9 2115.5 818.4 2125.6 820.1 2137.7 820.1 2155.5 820.1 2170.3 811.2 2182.2 793.5 2194.4 775.5 2200.6 745.6 2200.9 703.7L2256.5 703.7C2254.9 721.7 2252.8 737.8 2250.1 751.8 2247.5 765.8 2243.2 779.8 2237.4 793.9 2231.8 807.9 2224.8 819.5 2216.4 828.8 2208.2 838.1 2197.4 845.6 2184.2 851.4 2171 857.3 2155.7 860.2 2138.5 860.2 2104.9 860.2 2078.7 853.6 2059.9 840.3 2041.4 826.8 2027.8 803.5 2019 770.4 2010.3 737.1 2006.2 690.4 2006.7 630.3M2340.2 849.5L2340.2 373.8 2399.8 373.8 2399.8 418.7C2412.5 400.1 2427.7 386.2 2445.4 377 2463.2 367.7 2481.6 363.1 2500.6 363.1 2524.2 363.1 2544.8 373 2562.6 392.9 2580.6 412.4 2589.6 440.1 2589.6 475.8L2589.6 849.5 2529.2 849.5 2529.2 474.6C2529.2 451.1 2524.6 434 2515.3 423.4 2506.1 412.6 2493.9 407.2 2478.8 407.2 2466.4 407.2 2453 411.5 2438.7 420.3 2424.7 428.7 2411.7 440.4 2399.8 455.2L2399.8 849.5 2340.2 849.5M2870.7 768.1L2870.7 454.4C2842.4 422.9 2815.8 407.2 2790.9 407.2 2785.1 407.2 2780.2 407.7 2776.2 408.7 2772.5 409.8 2768.1 412.2 2763.1 415.9 2758.3 419.6 2754.3 425.7 2751.2 434.1 2748.3 442.4 2745.2 453.2 2742 466.7 2739.1 479.9 2737 497.4 2735.7 519.1 2734.4 540.8 2733.7 566.2 2733.7 595.3L2733.7 633.1C2733.7 657.9 2734.2 680.1 2735.3 699.4 2736.6 718.7 2738.2 734.7 2740.1 747.4 2741.9 760.1 2744.4 771 2747.6 780 2750.8 788.7 2753.8 795.5 2756.7 800.2 2759.6 805 2763.3 808.6 2767.8 810.9 2772.6 813.3 2776.6 814.8 2779.8 815.3 2783.2 815.8 2787.6 816.1 2792.9 816.1 2804.8 816.1 2817.7 811.7 2831.8 803 2845.8 794 2858.8 782.4 2870.7 768.1M2870.7 849.5L2870.7 804.6C2846.9 841.6 2814.8 860.2 2774.6 860.2 2767.4 860.2 2761 859.5 2755.1 858.2 2749.3 857.1 2742.8 854.6 2735.7 850.6 2728.5 846.9 2722.2 841.9 2716.6 835.6 2711.1 828.9 2705.5 819.9 2700 808.6 2694.4 796.9 2689.8 783.4 2686.1 768.1 2682.6 752.4 2679.7 733.1 2677.3 710.1 2675.2 687.1 2674.1 661.3 2674.1 632.7L2674.1 595C2674.1 553.9 2676.3 518.7 2680.5 489.3 2685 459.7 2690.4 436.9 2696.8 421 2703.1 405.2 2711.3 392.7 2721.4 383.7 2731.5 374.5 2740.7 368.8 2749.2 366.7 2757.7 364.3 2767.6 363.1 2779 363.1 2795.6 363.1 2811.9 367.8 2827.8 377.4 2843.9 386.6 2858.2 400.4 2870.7 418.7L2870.7 166.1 2930.2 166.1 2930.2 849.5 2870.7 849.5M3081.9 570.7L3213.7 570.7C3213.5 560.1 3213.5 549.4 3213.7 538.6 3214 527.7 3213.9 516.9 3213.3 506 3212.8 494.9 3212 484.3 3210.9 474.3 3210.2 464.2 3208.4 454.9 3205.8 446.5 3203.1 437.7 3199.8 430.2 3195.9 423.8 3191.9 417.5 3186.5 412.4 3179.6 408.7 3172.7 405 3164.8 403.2 3155.8 403.2 3141.2 403.2 3129.2 405.8 3119.6 411.1 3110.4 416.4 3103.1 422.6 3097.8 429.8 3092.8 436.9 3089.1 449 3086.7 465.9 3084.3 482.9 3082.8 497.8 3082.3 510.8 3082 523.5 3081.9 543.5 3081.9 570.7M3021.6 630.3L3021.6 564C3021.8 530.9 3024.2 502.3 3028.7 478.2 3033.2 453.9 3039 434.4 3046.2 419.9 3053.6 405.3 3063 393.8 3074.4 385.3 3086 376.6 3097.9 370.8 3110.1 367.8 3122.3 364.7 3136.6 363.1 3153 363.1 3195.6 363.1 3226.2 378.2 3244.7 408.3 3263.5 438.5 3272.9 486.2 3272.9 551.3 3272.9 571.1 3272.6 587.4 3272.1 600.1L3081.5 600.1 3081.5 672.8C3081.5 704 3083 729.7 3085.9 749.8 3089.1 769.6 3093.9 784.6 3100.6 794.7 3107.2 804.7 3114.5 811.5 3122.4 814.9 3130.3 818.4 3140.4 820.1 3152.6 820.1 3170.3 820.1 3185.1 811.2 3197 793.5 3209.2 775.5 3215.4 745.6 3215.7 703.7L3271.3 703.7C3269.7 721.7 3267.6 737.8 3264.9 751.8 3262.3 765.8 3258.1 779.8 3252.2 793.9 3246.7 807.9 3239.7 819.5 3231.2 828.8 3223 838.1 3212.3 845.6 3199 851.4 3185.8 857.3 3170.6 860.2 3153.4 860.2 3119.8 860.2 3093.6 853.6 3074.8 840.3 3056.2 826.8 3042.6 803.5 3033.9 770.4 3025.1 737.1 3021 690.4 3021.6 630.3M3355.1 849.5L3355.1 373.8 3414.6 373.8 3414.6 440.9C3422 416.3 3435.4 397.9 3454.7 385.7 3474.1 373.3 3494 366.8 3514.7 366.3L3514.7 426.2C3511.8 425.2 3508.7 424.6 3505.5 424.6 3444.9 424.6 3414.6 447.9 3414.6 494.5L3414.6 849.5 3355.1 849.5M3727.5 651.3L3727.5 365.1C3727.5 340.7 3729.1 318.6 3732.3 298.8 3735.4 278.9 3741 260 3748.9 242 3757.1 224 3767.6 208.9 3780.3 196.7 3793 184.3 3809.2 174.5 3828.7 167.3 3848.6 160.2 3871.5 156.6 3897.4 156.6 4004.4 156.6 4057.8 226.1 4057.8 365.1L4057.8 392.9 4000.3 392.9 4000.3 351.6C4000.3 329.6 3998.5 310.1 3995.1 293.2 3991.9 276.3 3986.5 261 3978.8 247.5 3971.1 233.8 3960.4 223.3 3946.7 216.2 3933.2 208.8 3916.8 205.1 3897.4 205.1 3880 205.1 3864.7 207.6 3851.8 212.6 3838.8 217.4 3828.2 224 3820 232.5 3812.1 240.9 3805.6 251.5 3800.6 264.2 3795.8 276.9 3792.5 290.3 3790.6 304.3 3788.8 318.1 3787.8 333.8 3787.8 351.6L3787.8 664.8C3787.8 687.3 3789.4 706.9 3792.6 723.6 3795.8 740 3801.2 755.2 3808.9 769.3 3816.8 783 3828.2 793.6 3843 801 3857.9 808.2 3876 811.7 3897.4 811.7 3916.8 811.7 3933.2 808.2 3946.7 801 3960.4 793.6 3971.1 783 3978.8 769.3 3986.5 755.5 3991.9 740.1 3995.1 723.2 3998.5 706.3 4000.3 686.8 4000.3 664.8L4000.3 618 4057.8 618 4057.8 651.3C4057.8 675.4 4056.4 697.4 4053.5 717.2 4050.6 737.1 4045.3 756.2 4037.6 774.4 4030.2 792.4 4020.5 807.6 4008.6 820.1 3996.7 832.2 3981.3 842 3962.5 849.5 3943.7 856.6 3922 860.2 3897.4 860.2 3871 860.2 3847.7 856.6 3827.5 849.5 3807.7 842 3791.4 832.2 3778.7 820.1 3766.3 807.6 3756.1 792.4 3748.1 774.4 3740.5 756.2 3735 737.2 3731.9 717.6 3729 697.8 3727.5 675.7 3727.5 651.3M4149.9 849.5L4149.9 166.1 4209.5 166.1 4209.5 849.5 4149.9 849.5M4310.4 623.9L4310.4 599.3C4310.4 578.1 4310.5 562 4310.7 550.9 4311 539.5 4311.8 524.1 4313.1 504.8 4314.7 485.2 4317 470 4319.9 459.2 4323.1 448.3 4327.7 435.9 4333.8 421.8 4339.9 407.8 4347.3 397.2 4356 390.1 4365 382.7 4376.3 376.3 4389.8 371 4403.3 365.7 4418.7 363.1 4436.2 363.1 4453.7 363.1 4469.2 365.7 4482.7 371 4496.2 376.3 4507.3 382.7 4516 390.1 4525 397.2 4532.6 407.8 4538.7 421.8 4544.7 435.9 4549.2 448.3 4552.1 459.2 4555.3 470 4557.6 485.2 4558.9 504.8 4560.5 524.1 4561.4 539.5 4561.7 550.9 4561.9 562 4562.1 578.1 4562.1 599.3L4562.1 623.9C4562.1 641.7 4561.9 655 4561.7 664 4561.7 673 4561.3 685.6 4560.5 701.8 4560 717.6 4558.9 729.9 4557.3 738.7 4556 747.4 4553.9 758.3 4551 771.2 4548 784.2 4544.5 794.3 4540.2 801.4 4536.3 808.6 4531.1 816.5 4524.8 825.2 4518.4 833.7 4511.1 840.2 4502.9 844.7 4494.7 848.9 4484.9 852.5 4473.5 855.4 4462.4 858.6 4450 860.2 4436.2 860.2 4422.4 860.2 4409.9 858.6 4398.5 855.4 4387.4 852.5 4377.7 848.9 4369.5 844.7 4361.3 840.2 4354 833.7 4347.7 825.2 4341.3 816.5 4336 808.6 4331.8 801.4 4327.8 794.3 4324.4 784.2 4321.5 771.2 4318.6 758.3 4316.3 747.4 4314.7 738.7 4313.4 729.9 4312.3 717.6 4311.5 701.8 4311 685.6 4310.6 673 4310.4 664 4310.4 655 4310.4 641.7 4310.4 623.9M4369.9 599.3L4369.9 623.9C4369.9 698.6 4374.3 749.9 4383 778 4391.7 806 4409.5 820.1 4436.2 820.1 4462.9 820.1 4480.7 806 4489.4 778 4498.2 749.9 4502.5 698.6 4502.5 623.9L4502.5 599.3C4502.5 574.2 4502.3 553 4501.7 535.8 4501.2 518.6 4500 502.2 4498.2 486.6 4496.6 470.7 4494.2 458 4491 448.4 4488.1 438.9 4484.1 430.6 4479.1 423.4 4474.3 416 4468.4 410.9 4461.2 407.9 4454.3 404.8 4446 403.2 4436.2 403.2 4426.4 403.2 4417.9 404.8 4410.8 407.9 4403.9 410.9 4398 416 4392.9 423.4 4387.9 430.6 4383.9 438.9 4381 448.4 4378.1 457.7 4375.7 470.4 4373.9 486.6 4372.3 502.4 4371.2 518.9 4370.7 535.8 4370.2 552.7 4369.9 573.9 4369.9 599.3M4649.4 747.4L4649.4 373.8 4709.8 373.8 4709.8 748.6C4709.8 772.2 4714.4 789.4 4723.7 800.2 4732.9 810.8 4745.1 816.1 4760.2 816.1 4772.6 816.1 4785.9 811.9 4799.9 803.4 4814.2 794.7 4827.3 782.9 4839.2 768.1L4839.2 373.8 4898.8 373.8 4898.8 849.5 4839.2 849.5 4839.2 804.6C4826.5 823.1 4811.3 837 4793.6 846.3 4775.8 855.5 4757.4 860.2 4738.4 860.2 4714.8 860.2 4694 850.4 4676 830.8 4658.3 810.9 4649.4 783.1 4649.4 747.4M5195.8 768.1L5195.8 454.4C5167.4 422.9 5140.8 407.2 5115.9 407.2 5110.1 407.2 5105.2 407.7 5101.3 408.7 5097.6 409.8 5093.2 412.2 5088.2 415.9 5083.4 419.6 5079.4 425.7 5076.2 434.1 5073.3 442.4 5070.3 453.2 5067.1 466.7 5064.2 479.9 5062.1 497.4 5060.8 519.1 5059.4 540.8 5058.8 566.2 5058.8 595.3L5058.8 633.1C5058.8 657.9 5059.3 680.1 5060.4 699.4 5061.7 718.7 5063.3 734.7 5065.1 747.4 5067 760.1 5069.5 771 5072.7 780 5075.8 788.7 5078.9 795.5 5081.8 800.2 5084.7 805 5088.4 808.6 5092.9 810.9 5097.7 813.3 5101.7 814.8 5104.8 815.3 5108.3 815.8 5112.6 816.1 5117.9 816.1 5129.8 816.1 5142.8 811.7 5156.8 803 5170.9 794 5183.8 782.4 5195.8 768.1M5195.8 849.5L5195.8 804.6C5171.9 841.6 5139.9 860.2 5099.7 860.2 5092.5 860.2 5086 859.5 5080.2 858.2 5074.4 857.1 5067.9 854.6 5060.8 850.6 5053.6 846.9 5047.3 841.9 5041.7 835.6 5036.1 828.9 5030.6 819.9 5025 808.6 5019.5 796.9 5014.8 783.4 5011.1 768.1 5007.7 752.4 5004.8 733.1 5002.4 710.1 5000.3 687.1 4999.2 661.3 4999.2 632.7L4999.2 595C4999.2 553.9 5001.3 518.7 5005.6 489.3 5010.1 459.7 5015.5 436.9 5021.8 421 5028.2 405.2 5036.4 392.7 5046.5 383.7 5056.5 374.5 5065.8 368.8 5074.3 366.7 5082.7 364.3 5092.7 363.1 5104 363.1 5120.7 363.1 5137 367.8 5152.9 377.4 5169 386.6 5183.3 400.4 5195.8 418.7L5195.8 166.1 5255.3 166.1 5255.3 849.5 5195.8 849.5", + "width": 5384 + }, + "search": [ + "blender-cloud-logo" + ] + }, + { + "uid": "f37fb714b7c145f3eb738d39c560f49d", + "css": "notifications-active", + "code": 59411, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M274.2 149.2L214.6 89.6C115 165.4 49.2 283.3 42.9 416.7H126.3C132.5 306.3 189.2 209.6 274.2 149.2ZM832.1 416.7H915.4C909.2 283.3 843.3 165.4 743.3 89.6L683.7 149.2C769.2 209.6 825.8 306.2 832.1 416.7ZM750 437.5C750 309.6 661.2 202.5 541.7 174.2V145.8C541.7 111.2 513.7 83.3 479.2 83.3S416.7 111.2 416.7 145.8V174.2C297.1 202.5 208.3 309.6 208.3 437.5V666.7L125 750V791.7H833.3V750L750 666.7V437.5ZM479.2 916.7C485 916.7 490.4 916.3 495.8 915 522.9 909.6 545.4 890.8 555.8 865.8 560 855.8 562.5 845 562.5 833.3H395.8C395.8 879.2 433.3 916.7 479.2 916.7Z", + "width": 1000 + }, + "search": [ + "notifications-active" + ] + }, + { + "uid": "ca63a83e2608f22c048cac93801aea38", + "css": "notifications-off", + "code": 59412, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M479.2 916.7C525 916.7 562.5 879.2 562.5 833.3H395.8C395.8 879.2 433.3 916.7 479.2 916.7ZM750 437.5C750 309.6 661.3 202.5 541.7 174.2V145.8C541.7 111.2 513.8 83.3 479.2 83.3S416.7 111.2 416.7 145.8V174.2C395.4 179.2 375.4 187.5 356.2 197.5L750 590.8V437.5ZM738.8 791.7L822.1 875 875 822.1 177.9 125 125 177.9 246.7 299.6C222.5 340 208.3 387.1 208.3 437.5V666.7L125 750V791.7H738.8Z", + "width": 1000 + }, + "search": [ + "notifications-off" + ] + }, + { + "uid": "d2884cf82f0df019e89b923a796424cd", + "css": "notifications-none", + "code": 59413, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M479.2 916.7C525 916.7 562.5 879.2 562.5 833.3H395.8C395.8 879.2 433.3 916.7 479.2 916.7ZM750 666.7V437.5C750 309.6 661.3 202.5 541.7 174.2V145.8C541.7 111.2 513.8 83.3 479.2 83.3S416.7 111.2 416.7 145.8V174.2C297.1 202.5 208.3 309.6 208.3 437.5V666.7L125 750V791.7H833.3V750L750 666.7ZM666.7 708.3H291.7V437.5C291.7 333.8 375.4 250 479.2 250S666.7 333.8 666.7 437.5V708.3Z", + "width": 1000 + }, + "search": [ + "notifications-none" + ] + }, + { + "uid": "c64623255a4a7c72436b199b05296c4f", + "css": "happy", + "code": 59414, + "src": "fontelico" + }, + { + "uid": "53ed8570225581269cd7eff5795e8bea", + "css": "unhappy", + "code": 59415, + "src": "fontelico" + }, + { + "uid": "043b585886018be7408f4c77c1a3759c", + "css": "displeased", + "code": 59416, + "src": "fontelico" + }, + { + "uid": "cbc328d1c779a2b9e3b1199d904199a5", + "css": "grin", + "code": 59417, + "src": "fontelico" + }, + { + "uid": "2bd5f98482d86649958312ea2ab5bb40", + "css": "laugh", + "code": 59418, + "src": "fontelico" + }, + { + "uid": "9bd60140934a1eb9236fd7a8ab1ff6ba", + "css": "spin", + "code": 59419, + "src": "fontelico" + }, + { + "uid": "9dd9e835aebe1060ba7190ad2b2ed951", + "css": "search", + "code": 59420, + "src": "fontawesome" + }, + { + "uid": "872d9516df93eb6b776cc4d94bd97dac", + "css": "film-thick", + "code": 59421, + "src": "fontawesome" + }, + { + "uid": "381da2c2f7fd51f8de877c044d7f439d", + "css": "image", + "code": 59422, + "src": "fontawesome" + }, + { + "uid": "0f4cae16f34ae243a6144c18a003f2d8", + "css": "cancel-circle", + "code": 59423, + "src": "fontawesome" + }, + { + "uid": "5e2ab018e3044337bcef5f7e94098ea1", + "css": "thumbs-up", + "code": 59424, + "src": "fontawesome" + }, + { + "uid": "ddcd918b502642705838815d40aea9e3", + "css": "thumbs-down", + "code": 59425, + "src": "fontawesome" + }, + { + "uid": "f5999a012fc3752386635ec02a858447", + "css": "download-cloud", + "code": 59426, + "src": "fontawesome" + }, + { + "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6", + "css": "edit", + "code": 59427, + "src": "fontawesome" + }, + { + "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", + "css": "document", + "code": 59428, + "src": "fontawesome" + }, + { + "uid": "e80ae555c1413a4ec18b33fb348b4049", + "css": "file-archive", + "code": 59429, + "src": "fontawesome" + }, + { + "uid": "745f12abe1472d14f8f658de7e5aba66", + "css": "angle-double-left", + "code": 59430, + "src": "fontawesome" + }, + { + "uid": "fdfbd1fcbd4cb229716a810801a5f207", + "css": "angle-double-right", + "code": 59431, + "src": "fontawesome" + }, + { + "uid": "cb36cbe4e2dac9545e32c3d80a7c6e03", + "css": "whoosh", + "code": 59432, + "src": "fontawesome" + }, + { + "uid": "2a4e6c99b732a57da40e32fa2a7800a4", + "css": "toggle-off", + "code": 59433, + "src": "fontawesome" + }, + { + "uid": "3256ef03b16e7ab51235bc7378b53bb5", + "css": "toggle-on", + "code": 59434, + "src": "fontawesome" + }, + { + "uid": "130380e481a7defc690dfb24123a1f0c", + "css": "circle-filled", + "code": 59435, + "src": "fontawesome" + }, + { + "uid": "422e07e5afb80258a9c4ed1706498f8a", + "css": "circle-empty", + "code": 59436, + "src": "fontawesome" + }, + { + "uid": "5774d0a0e50f6eefc8be01bd761e5dd3", + "css": "circle", + "code": 59437, + "src": "fontawesome" + }, + { + "uid": "4ffd8122933b9ee0183b925e1554969f", + "css": "circle-notch", + "code": 59438, + "src": "fontawesome" + }, + { + "uid": "81bb68665e8e595505272a746db07c7a", + "css": "circle-dot", + "code": 59439, + "src": "fontawesome" + }, + { + "uid": "e335adbc2d898c7d85d40c507796e7b4", + "css": "email", + "code": 59440, + "src": "entypo" + }, + { + "uid": "5e9f01871d44e56b45ecbfd00f4dbc3a", + "css": "layout", + "code": 59441, + "src": "entypo" + }, + { + "uid": "70370693ada58ef0a60fa0984fe8d52a", + "css": "plus", + "code": 59442, + "src": "entypo" + }, + { + "uid": "3ba4275937db277075fc47d6b5a69a2e", + "css": "back", + "code": 59443, + "src": "entypo" + }, + { + "uid": "5d595124cecf472869d1cdc020da0ccc", + "css": "upload-cloud", + "code": 59444, + "src": "entypo" + }, + { + "uid": "457c8e2b305e7af74c1be4f07a01ca92", + "css": "vcard", + "code": 59445, + "src": "entypo" + }, + { + "uid": "3e617d8049807e128c80d0344ba09e37", + "css": "rss", + "code": 59446, + "src": "entypo" + }, + { + "uid": "91426c82d94428a33353e495418435e3", + "css": "share", + "code": 59447, + "src": "entypo" + }, + { + "uid": "f11c9e95ae5eaa84d193e8fa1d38c6f9", + "css": "angle-down", + "code": 59448, + "src": "entypo" + }, + { + "uid": "592717bd601645d61517d2a584d04127", + "css": "angle-left", + "code": 59449, + "src": "entypo" + }, + { + "uid": "37f6cfbb4062ed0d01b351ec35c334ff", + "css": "angle-right", + "code": 59450, + "src": "entypo" + }, + { + "uid": "9e251fb8e9e1c71ab09683468e0479a3", + "css": "angle-up", + "code": 59451, + "src": "entypo" + }, + { + "uid": "3626b3f3a0b284e7f4166b815719aece", + "css": "list", + "code": 59452, + "src": "entypo" + }, + { + "uid": "c5cd6ea1981cecdd85c42d9d209bc3b8", + "css": "credit-card", + "code": 59453, + "src": "entypo" + }, + { + "uid": "3b00728aa97ad1a2581d414bd9d650bc", + "css": "heart", + "code": 59454, + "src": "typicons" + }, + { + "uid": "hi76m8qggwn5lbl286oeqp64q0n8kusy", + "css": "heart-filled", + "code": 59455, + "src": "typicons" + }, + { + "uid": "bcdc294bb787b15203b82f2be8096548", + "css": "star", + "code": 59456, + "src": "typicons" + }, + { + "uid": "vyuzsm6wijlfwtjo4ifkoblfmsepk6g8", + "css": "star-filled", + "code": 59457, + "src": "typicons" + }, + { + "uid": "cdfalpadi7huwv9ah4fef2gpfpb4c6qm", + "css": "resize-full", + "code": 59458, + "src": "typicons" + }, + { + "uid": "8d2bc2d959a55e76466bbef6e84c8373", + "css": "resize-normal", + "code": 59459, + "src": "typicons" + }, + { + "uid": "jskdfgyhjwd003ndercv4r08h0ai3dfg", + "css": "replay", + "code": 59492, + "src": "typicons" + }, + { + "uid": "xoidjr6q3rzi7tpw6hci1k6srz15g11l", + "css": "puzzle", + "code": 59493, + "src": "typicons" + }, + { + "uid": "fc94b92194752796654c96c7b7dccebb", + "css": "cog", + "code": 59494, + "src": "iconic" + }, + { + "uid": "be25d23b15c3294588947a6b959811ec", + "css": "move", + "code": 59495, + "src": "iconic" + }, + { + "uid": "f47srtt9pew19q6kg9jniwtzsb8q1rhy", + "css": "warning", + "code": 59496, + "src": "modernpics" + }, + { + "uid": "c55fdf978f617992b43ee7cbaa6ef87e", + "css": "menu", + "code": 59465, + "src": "mfglabs" + }, + { + "uid": "ce50292e85eb5d6ee3be61b32bf2bdf3", + "css": "check", + "code": 59466, + "src": "mfglabs" + }, + { + "uid": "06301c50d89b5d3e651bd07ebd6d7de7", + "css": "cancel", + "code": 59467, + "src": "mfglabs" + }, + { + "uid": "15ba51cbb4d05e6fb88f03a31a7c711c", + "css": "info", + "code": 59468, + "src": "mfglabs" + }, + { + "uid": "7997220c4eb58edae98e04de570089c2", + "css": "lock", + "code": 59469, + "src": "mfglabs" + }, + { + "uid": "8167f6441d2557a90d717a7fc3248760", + "css": "lock-open", + "code": 59470, + "src": "mfglabs" + }, + { + "uid": "348b04ea17f646fbc6a46e20ebe4fe12", + "css": "download", + "code": 59471, + "src": "mfglabs" + }, + { + "uid": "189edfc41118cccbeb25788fcbb403da", + "css": "attention", + "code": 59472, + "src": "mfglabs" + }, + { + "uid": "3711151b8b34536498e2a7f4d5188ed2", + "css": "play", + "code": 59473, + "src": "mfglabs" + }, + { + "uid": "3ab2abf6f936d3e53ee8c184cedaed82", + "css": "trash", + "code": 59474, + "src": "elusive" + }, + { + "uid": "68c7be0f4cab6b82d5725b1ba7cec4d6", + "css": "zoom-in", + "code": 59475, + "src": "elusive" + }, + { + "uid": "295287b96e50d494ba38a1ed9e06d8db", + "css": "zoom-out", + "code": 59476, + "src": "elusive" + }, + { + "uid": "38575a803c4da31ce20d77e1e1236bcb", + "css": "paper-plane", + "code": 59477, + "src": "fontawesome" + }, + { + "uid": "b7bd78e163801889af145e513d6c5383", + "css": "mic-outline", + "code": 59478, + "src": "typicons" + }, + { + "uid": "7b27a22ac88559bffb2bc36e026d1b9f", + "css": "license-cc-nc", + "code": 59479, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M585-54.7C725.5-54.7 844-6.5 940.4 89.9 1037.4 186.4 1085.9 304.8 1085.9 445.3 1085.9 585.8 1038.3 702.8 943 796.2 841.9 895.6 722.5 945.3 585 945.3 449.3 945.3 332.1 895.9 233.3 797.1 135.1 698.9 85.9 581.6 85.9 445.3 85.9 308.4 135.1 190 233.3 90 329.7-6.5 446.9-54.7 585-54.7ZM198.4 310.5C183.6 351.6 176.1 396.5 176.1 445.3 176.1 556 216.6 651.9 297.5 732.8 379.1 813.2 475.5 853.4 586.8 853.4 699.3 853.4 796.3 812.6 877.9 731 907.1 703.1 930 673.9 946.6 643.5L758.2 559.6C751.7 591.2 735.7 616.9 710.5 636.8 685.1 656.8 655.2 668.2 620.7 671.2V748H562.7V671.2C507.3 670.6 456.7 650.7 410.9 611.4L479.7 541.8C512.4 572.1 549.6 587.3 591.3 587.3 608.5 587.3 623.3 583.4 635.5 575.7 647.7 567.9 653.8 555.2 653.8 537.3 653.8 524.7 649.3 514.6 640.4 506.9L592.2 486.4 533.3 459.6 453.8 424.7 198.4 310.5ZM586.8 34.6C473.1 34.6 377 74.8 298.4 155.1 278.8 174.8 260.3 197.1 243.1 222.1L434.2 307.8C442.5 281.6 458.3 260.6 481.5 244.9 504.7 229.1 531.8 220.3 562.7 218.5V141.7H620.8V218.5C666.6 220.9 708.3 236.4 745.8 265L680.6 331.9C652.6 312.3 624 302.5 594.9 302.5 579.4 302.5 565.6 305.5 553.4 311.4 541.2 317.3 535.1 327.5 535.1 341.8 535.1 345.9 536.5 350.1 539.5 354.3L602.9 382.8 646.7 402.5 727 438.2 983.2 552.5C991.6 517.3 995.8 481.6 995.8 445.3 995.8 330.4 955.9 233.7 876.1 155.1 797 74.8 700.5 34.6 586.8 34.6Z", + "width": 1000 + }, + "search": [ + "nc" + ] + }, + { + "uid": "2bbe96e5647656263ebd3ff219081cd2", + "css": "license-cc-sa", + "code": 59480, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M585-54.7C724.9-54.7 843.3-6.2 940.4 90.9 1037.4 187.3 1085.9 305.4 1085.9 445.3 1085.9 585.2 1038.3 702.5 943 797.1 842.5 895.9 723.1 945.3 585 945.3 449.9 945.3 332.7 896.2 233.3 798 135.1 699.8 85.9 582.3 85.9 445.3 85.9 309 135.1 190.9 233.3 90.9 330.3-6.2 447.5-54.7 585-54.7ZM586.8 35.5C473.1 35.5 377 75.7 298.4 156 216.9 238.8 176.1 335.2 176.1 445.3 176.1 556.6 216.6 652.5 297.5 732.8 378.5 813.8 474.9 854.2 586.8 854.2 698.1 854.2 795.1 813.5 877.9 731.9 956.5 655.7 995.7 560.2 995.7 445.3 995.7 331 955.8 234.6 876.1 156 796.9 75.7 700.5 35.5 586.8 35.5ZM363.6 374.8C373.1 313.5 397.8 266 437.7 232.4 477.6 198.7 526.1 181.9 583.2 181.9 661.8 181.9 724.3 207.2 770.7 257.8 817.2 308.4 840.4 373.3 840.4 452.4 840.4 529.2 816.3 593.1 768.1 643.9 719.8 694.8 657.4 720.3 580.5 720.3 524 720.3 475.2 703.3 434.1 669.4 393 635.5 368.4 587.3 360 524.8H485.9C488.9 585.5 525.5 615.8 595.8 615.8 630.9 615.8 659.2 600.6 680.6 570.3 702 540 712.8 499.5 712.8 448.9 712.8 395.9 702.9 355.6 683.3 327.9 663.6 300.2 635.4 286.4 598.5 286.4 531.8 286.4 494.3 315.8 486 374.8H522.6L423.5 473.9 324.4 374.8 363.6 374.8 363.6 374.8Z", + "width": 1000 + }, + "search": [ + "sa" + ] + }, + { + "uid": "3eaa3e550a2e9bccb127fda96826226d", + "css": "license-cc-nd", + "code": 59481, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M499.1 0C639 0 757.4 48.5 854.5 145.5 951.5 242 1000 360.1 1000 500S952.4 757.1 857.1 851.8C756.5 950.6 637.2 1000 499.1 1000 364 1000 246.7 950.9 147.3 852.7 49.1 754.5 0 636.9 0 500 0 363.7 49.1 245.6 147.3 145.6 244.4 48.5 361.6 0 499.1 0ZM500.9 90.2C387.3 90.2 291.1 130.3 212.5 210.7 131 293.5 90.2 389.9 90.2 500 90.2 611.3 130.7 707.1 211.6 787.5 292.6 868.5 389 908.9 500.9 908.9 612.2 908.9 709.2 868.2 792 786.6 870.5 710.4 909.8 614.9 909.8 500 909.8 385.7 869.9 289.3 790.2 210.7 711 130.3 614.6 90.2 500.9 90.2ZM689.3 382.1V467.8H325.9V382.1H689.3ZM689.3 542.9V628.5H325.9V542.9H689.3Z", + "width": 1000 + }, + "search": [ + "nd" + ] + }, + { + "uid": "3a1381adf51b0854d73f9cc853fbf27c", + "css": "license-cc-zero", + "code": 59482, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M492.2 220C327.1 220 285.6 375.8 285.6 507.8 285.6 639.9 327.1 795.6 492.2 795.6 657.3 795.6 698.8 639.9 698.8 507.8 698.8 375.8 657.3 220 492.2 220ZM492.2 328.5C498.9 328.5 505 329.6 510.8 331 522.6 341.2 528.5 355.4 517 375.1L407.1 577.1C403.7 551.6 403.3 526.5 403.3 507.8 403.3 449.6 407.3 328.5 492.2 328.5ZM574.5 421.7C580.3 452.7 581.1 485 581.1 507.8 581.1 566 577.1 687.1 492.2 687.1 485.5 687.1 479.4 686.4 473.7 685 472.6 684.7 471.6 684.3 470.5 683.9 468.8 683.4 466.9 682.9 465.3 682.2 446.3 674.2 434.4 659.6 451.6 633.9L574.5 421.7ZM491.1 7.8C352.4 7.8 235.5 56.1 140.3 153.1 92.1 201.4 55.2 256.4 29.6 317.7 4.6 378.4-7.8 441.7-7.8 507.8-7.8 574.5 4.6 637.8 29.6 697.9 54.6 758.1 90.9 812.2 138.5 860.4 186.7 908 240.9 944.7 301 970.3 361.7 995.4 425 1007.8 491.1 1007.8 557.2 1007.8 621.5 994.9 683.4 969.3 745.3 943.7 800.5 906.9 849.3 858.6 896.4 812.8 932 760.2 955.8 700.7 980.2 640.6 992.2 576.3 992.2 507.8 992.2 440 980.2 375.7 955.8 315.6 931.4 254.9 895.7 201.2 848.6 154.2 750.4 56.5 631 7.8 491.1 7.8ZM493.2 97.8C606.3 97.8 702.8 137.9 783.1 218.3 821.8 257 851.5 301.2 871.7 350.6 892 400 902.2 452.5 902.2 507.8 902.2 622.7 862.9 718 784.9 794.2 744.4 833.5 698.9 863.7 648.3 884.5 598.3 905.4 546.8 915.7 493.2 915.7 439 915.7 387.2 905.5 337.8 885.3 288.4 864.4 244 834.7 204.7 796 165.4 756.7 135.1 712.3 113.7 662.9 92.9 612.9 82.2 561.4 82.2 507.8 82.2 453.6 92.9 401.7 113.7 352.3 135.1 302.3 165.4 257.4 204.7 217.5 282.7 137.8 378.9 97.8 493.2 97.8Z", + "width": 1000 + }, + "search": [ + "zero" + ] + }, + { + "uid": "ead929282ee360496f47bc9af668fd30", + "css": "license-cc-by", + "code": 59399, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M585-54.7C725.5-54.7 844-6.5 940.4 90 1037.4 187 1085.9 305.4 1085.9 445.3 1085.9 585.8 1038.3 702.8 943 796.2 841.9 895.6 722.5 945.3 585 945.3 449.9 945.3 332.7 896.2 233.3 798 135.1 699.8 85.9 582.2 85.9 445.3 85.9 308.4 135.1 190 233.3 90 329.7-6.5 446.9-54.7 585-54.7ZM586.8 35.5C473.1 35.5 377 75.4 298.4 155.1 216.9 238.5 176.1 335.2 176.1 445.3 176.1 556 216.6 651.9 297.5 732.8 378.5 813.8 474.9 854.2 586.8 854.2 698.1 854.2 795.1 813.5 877.9 731.9 956.5 656.3 995.7 560.8 995.7 445.3 995.7 331.6 955.8 234.9 876.1 155.1 796.4 75.4 699.9 35.5 586.8 35.5ZM720.8 321.2V525.7H663.6V768.5H508.3V525.7H451.1V321.2C451.1 312.3 454.3 304.7 460.5 298.4 466.8 292.2 474.4 289.1 483.3 289.1H688.6C697 289.1 704.4 292.2 711 298.4 717.5 304.7 720.8 312.3 720.8 321.2ZM516.3 192.6C516.3 145.6 539.5 122.1 585.9 122.1S655.6 145.6 655.6 192.6C655.6 239.1 632.4 262.3 585.9 262.3S516.3 239.1 516.3 192.6Z", + "width": 1000 + }, + "search": [ + "by" + ] + }, + { + "uid": "b9e9e2bc88497bb269ed3ab361da6f5c", + "css": "license-cc", + "code": 59400, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M585-54.7C724.9-54.7 844-5.9 942.2 91.8 989.2 138.8 1024.9 192.5 1049.3 252.9 1073.7 313.3 1085.9 377.5 1085.9 445.3 1085.9 513.8 1073.8 577.9 1049.8 637.7 1025.7 697.5 990.1 750.4 943.1 796.2 894.3 844.4 838.9 881.3 777 906.9 715.1 932.5 651.1 945.3 585 945.3S455.7 932.7 395.3 907.3C334.9 882.1 280.7 845.5 232.8 797.5 184.9 749.6 148.4 695.6 123.4 635.5S85.9 512 85.9 445.3C85.9 379.2 98.6 315.7 123.9 254.7 149.2 193.7 185.9 139.1 234.1 90.8 329.4-6.2 446.3-54.7 585-54.7ZM586.8 35.5C472.5 35.5 376.4 75.4 298.4 155.1 259.1 195 228.9 239.8 207.8 289.5 186.6 339.2 176.1 391.2 176.1 445.3 176.1 498.9 186.6 550.5 207.8 600.2 228.9 650 259.1 694.3 298.4 733.3 337.7 772.3 382 802 431.5 822.6 480.9 843.1 532.7 853.4 586.8 853.4 640.4 853.4 692.3 843 742.7 822.1 793 801.3 838.3 771.3 878.8 732 956.8 655.8 995.7 560.2 995.7 445.3 995.7 390 985.6 337.6 965.4 288.2 945.2 238.8 915.7 194.8 877 156 796.6 75.7 699.9 35.5 586.8 35.5ZM580.6 362.3L513.6 397.1C506.4 382.3 497.7 371.8 487.3 365.9 476.8 359.9 467.2 357 458.2 357 413.6 357 391.3 386.4 391.3 445.3 391.3 472.1 396.9 493.5 408.2 509.6 419.5 525.7 436.2 533.7 458.2 533.7 487.4 533.7 507.9 519.4 519.8 490.9L581.4 522.1C568.3 546.5 550.2 565.7 527 579.7 503.8 593.7 478.2 600.7 450.2 600.7 405.5 600.7 369.5 587 342.1 559.6 314.8 532.2 301.1 494.2 301.1 445.4 301.1 397.7 314.9 360 342.6 332 370.3 304 405.2 290 447.5 290 509.4 290 553.8 314.1 580.6 362.3ZM869 362.3L802.9 397.1C795.7 382.3 786.9 371.8 776.5 365.9 766.1 359.9 756.1 357 746.6 357 702 357 679.6 386.4 679.6 445.3 679.6 472.1 685.3 493.5 696.6 509.6 707.9 525.7 724.6 533.7 746.6 533.7 775.8 533.7 796.3 519.4 808.2 490.9L870.7 522.1C857 546.5 838.6 565.7 815.4 579.7 792.2 593.7 766.9 600.7 739.5 600.7 694.2 600.7 658.1 587 631 559.6 603.9 532.2 590.4 494.2 590.4 445.4 590.4 397.7 604.2 360 631.9 332 659.6 304 694.5 290 736.8 290 798.7 290 842.8 314.1 869 362.3Z", + "width": 1000 + }, + "search": [ + "cc" + ] + }, + { + "uid": "035687a1cb8b10f72b93e02648fd9b70", + "css": "license-publicdomain", + "code": 59485, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 0C223.9 0 0 223.9 0 500 0 776.1 223.9 1000 500 1000 776.1 1000 1000 776.1 1000 500 1000 223.9 776.1 0 500 0ZM500 906.2C276 906.2 93.7 724 93.7 500 93.7 453.1 101.8 408.1 116.5 366.2L275.4 436.9C271.1 458.2 268.8 480.4 268.8 503.3 268.8 702.9 417.8 759.1 511.5 759.1 566.8 759.1 614.6 741.3 652.1 715.1 659.2 710 665.6 704.9 671.6 699.7L598.7 611.1C596.5 613.5 594.3 615.9 592 617.9 563.7 644.8 532.5 644.8 525.5 644.8 449.3 644.8 417.4 564.4 416.9 499.9L854.8 694.9C855.3 694.9 855.8 694.9 856.2 695 787.1 820.8 653.3 906.2 500 906.2ZM891.5 603.8L439.8 402.2C456.1 372.7 482 350.5 520.9 350.5 543.4 350.5 561.2 358 575.2 367.4 581.1 371.6 586.2 375.8 590.4 380.1L671.8 296.3C617.2 249.3 555 240.9 514.3 240.9 418.8 240.9 351.2 282.2 311.3 344.9L160.3 277.5C233 167 358.1 93.8 500 93.8 724 93.8 906.2 276 906.2 500 906.2 536.3 901.4 571.4 892.4 604.9 892.1 604.5 891.8 604.2 891.5 603.8Z", + "width": 1000 + }, + "search": [ + "publicdomain" + ] + }, + { + "uid": "dc73bee1d58539abe8c96b958bf4c931", + "css": "fire", + "code": 59486, + "src": "elusive" + }, + { + "uid": "fda731b5a4026685e1a9c7e64228e2b2", + "css": "audio", + "code": 59487, + "src": "entypo" + }, + { + "uid": "e15f0d620a7897e2035c18c80142f6d9", + "css": "link-ext", + "code": 59488, + "src": "fontawesome" + }, + { + "uid": "5408be43f7c42bccee419c6be53fdef5", + "css": "document-text", + "code": 59489, + "src": "fontawesome" + }, + { + "uid": "4a74a0f87d4089efe7aba1825bff4193", + "css": "license-copyright", + "code": 59490, + "src": "fontawesome" + }, + { + "uid": "513ac180ff85bd275f2b736720cbbf5e", + "css": "home", + "code": 59395, + "src": "entypo" + }, + { + "uid": "627abcdb627cb1789e009c08e2678ef9", + "css": "social-twitter", + "code": 59396, + "src": "fontawesome" + }, + { + "uid": "8e04c98c8f5ca0a035776e3001ad2638", + "css": "social-facebook", + "code": 59398, + "src": "fontawesome" + }, + { + "uid": "62ce8d006804d866e326425ca2c2f8cb", + "css": "folder-create", + "code": 59397, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M833.3 250H500L416.7 166.7H166.7C120.4 166.7 83.7 203.7 83.7 250L83.3 750C83.3 796.2 120.4 833.3 166.7 833.3H833.3C879.6 833.3 916.7 796.2 916.7 750V333.3C916.7 287.1 879.6 250 833.3 250ZM791.7 583.3H666.7V708.3H583.3V583.3H458.3V500H583.3V375H666.7V500H791.7V583.3Z", + "width": 1000 + }, + "search": [ + "folder create add" + ] + }, + { + "uid": "a7bcaeca1f17b2c89af10bc63f90391b", + "css": "folder-special", + "code": 59483, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M833.3 250H500L416.7 166.7H166.7C120.8 166.7 83.3 204.2 83.3 250V750C83.3 795.8 120.8 833.3 166.7 833.3H833.3C879.2 833.3 916.7 795.8 916.7 750V333.3C916.7 287.5 879.2 250 833.3 250ZM747.5 708.3L625 636.7 502.5 708.3 535 569.6 427.1 476.2 569.2 464.2 625 333.3 680.8 464.2 822.9 476.2 715 569.6 747.5 708.3Z", + "width": 1000 + }, + "search": [ + "folder special" + ] + }, + { + "uid": "0622aee389854b88a9cc42abb23633d5", + "css": "texture", + "code": 59402, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M812.9 128.3L128.3 812.9C132.1 827.1 139.6 840 149.6 850.4 160 860.4 172.9 867.9 187.1 871.7L872.1 187.1C864.2 158.3 841.7 135.8 812.9 128.3ZM495 125L125 495V612.9L612.9 125H495ZM208.3 125C162.5 125 125 162.5 125 208.3V291.7L291.7 125H208.3ZM791.7 875C814.6 875 835.4 865.8 850.4 850.4 865.8 835.4 875 814.6 875 791.7V708.3L708.3 875H791.7ZM387.1 875H505L875 505V387.1L387.1 875Z", + "width": 1000 + }, + "search": [ + "texture" + ] + }, + { + "uid": "a73c5deb486c8d66249811642e5d719a", + "css": "refresh", + "code": 59484, + "src": "fontawesome" + }, + { + "uid": "8900d1dc900b895f8785a2d4ad400579", + "css": "folder-texture", + "code": 59403, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M166.7 166.7C120.8 166.7 83.3 204.2 83.3 250L83.3 750C83.3 795.8 120.8 833.3 166.7 833.3L833.3 833.3C879.2 833.3 916.7 795.8 916.7 750L916.7 333.3C916.7 287.5 879.2 250 833.3 250L500 250 416.7 166.7ZM663.2 366.5L786.1 366.5 786.1 494.8 663.2 494.8ZM436.9 367.8L558.5 367.8 558.5 496.1 436.9 496.1ZM209 369.1L331.9 369.1 331.9 497.4 209 497.4ZM663.2 578L786.1 578 786.1 706.3 663.2 706.3ZM436.9 579.3L558.5 579.3 558.5 708.9 436.9 708.9ZM209 580.6L331.9 580.6 331.9 708.9 209 708.9Z", + "width": 1000 + }, + "search": [ + "folder texture" + ] + }, + { + "uid": "9b4165d8bf44809bd494733a85e9f1ab", + "css": "blender", + "code": 59491, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M820.9 995.6C800.2 996.3 773.4 996 761.5 995 749.5 994 727.7 991 713 988.3 698.3 985.5 674.6 979.6 660.4 975.1 646.2 970.6 623.7 962 610.4 956 597.1 950 575.5 938.4 562.4 930.1 549.3 921.9 528.8 906.9 516.8 896.9 504.9 886.9 488.2 871 479.8 861.6 471.4 852.2 457.6 834.9 449.3 823.1 440.9 811.3 429 791.6 422.8 779.3 416.6 767 407.9 746.9 403.6 734.7 399.2 722.5 393.1 700.7 390.1 686.2 387 671.8 383.9 649.6 383.1 637.1 382.3 624.5 381.2 611.9 380.6 609 380 606.2 378 603.9 376 603.9 374.1 603.9 334.8 634.6 288.6 672.1 242.5 709.6 184.6 756.3 160 775.7 135.3 795.2 110.7 813 105.1 815.3 99.5 817.6 87.1 820 77.5 820.6 64.9 821.4 56.9 820.6 48.7 817.9 42.4 815.8 33 810.4 27.8 806 22.6 801.5 15.6 793.2 12.3 787.6 7.9 780.1 6 773 5.2 761.4L4 745.5 12.7 728C19.2 714.9 26 705.8 39.9 692 51.8 680 133.3 616 269.9 511.2 386.2 422 483.2 347.1 485.5 344.9 487.7 342.6 488.9 339.8 488.2 338.6 487.3 337.2 437.8 336.4 357 336.4 246.7 336.4 225.5 335.9 215.4 332.9 208.9 330.9 200 326.2 195.7 322.5 191.4 318.7 186.1 311 184 305.4 181.3 298.3 180.5 291.8 181.4 283.9 182.1 277.8 184.8 268.7 187.3 263.7 189.8 258.8 195.6 251.6 200.1 247.6 204.6 243.6 212.4 238.3 217.5 235.7 222.6 233.1 234.7 228.7 244.3 226L261.9 221 506.3 219.8C666.2 219.1 751 217.9 751.5 216.4 751.9 215.1 751.5 213 750.6 211.7 749.6 210.3 714.8 181.8 673.1 148.2 631.5 114.6 594.8 83.2 591.5 78.6 588.3 73.9 584.6 67.1 583.2 63.6 581.9 60 580.8 54.6 580.8 51.5 580.8 48.4 582.9 41.9 585.3 37 587.8 32.2 593.7 25.2 598.5 21.4 603.2 17.7 611.8 12.2 617.4 9.3 625.6 5.1 631.3 4 644.9 4 657.2 4.1 665.5 5.5 674.3 9.1 681.1 11.9 693.8 18.8 702.6 24.5 711.4 30.2 766.1 71 824.1 115.2 882.1 159.4 957.3 217.2 991.2 243.5 1025.2 269.9 1062.8 299.6 1074.7 309.5 1086.6 319.5 1107.1 338 1120.2 350.8 1133.3 363.6 1149.4 380.9 1156 389.2 1162.7 397.5 1172.9 411.8 1178.8 420.9 1184.7 430 1192.9 444.8 1197 453.8 1201.2 462.8 1207.1 478.4 1210.3 488.5 1213.5 498.6 1217.7 514.9 1219.8 524.8 1221.8 534.7 1224.4 555.2 1225.6 570.3 1226.8 587.2 1226.8 609.5 1225.5 627.7 1224.4 644.1 1221.2 668.2 1218.6 681.4 1215.9 694.5 1209.6 717.2 1204.6 731.8 1199.6 746.3 1189.8 769 1182.7 782.2 1175.7 795.5 1163.2 815.5 1155.1 826.8 1147 838.1 1132.5 855.6 1123 865.7 1113.4 875.8 1097.3 890.8 1087.2 899.2 1077.1 907.5 1059.6 920.3 1048.3 927.6 1037 934.9 1015.5 946.7 1000.4 953.8 985.3 960.9 961.1 970.5 946.7 975 932.2 979.5 906.5 985.7 889.5 988.9 868.1 992.8 847.1 994.9 820.9 995.6ZM814 819.6C824.7 819 841.5 817 851.3 815.1 861.1 813.2 876 809.3 884.5 806.4 892.9 803.5 909 796.5 920.4 790.8 931.7 785.1 948.3 775.2 957.2 768.9 966.2 762.5 980 751.4 987.9 744.1 995.8 736.8 1007.5 724.1 1014 715.9 1020.5 707.8 1029.6 694.7 1034.3 687 1039 679.2 1045.9 665.3 1049.7 656.1 1053.6 646.9 1058.8 630.7 1061.3 620.1 1064.5 606.8 1065.9 593.1 1065.9 575.5 1065.9 560 1064.4 543.5 1062 533 1059.9 523.6 1055.1 508.2 1051.4 498.7 1047.6 489.3 1040.9 475.4 1036.4 467.9 1031.9 460.3 1023.3 447.9 1017.3 440.3 1011.3 432.7 998.8 419.2 989.4 410.3 980.1 401.4 963 388 951.5 380.4 940 372.8 920.8 362.3 908.9 357.1 897 351.8 877.2 345 864.9 341.8 849 337.7 834.1 335.7 812.9 334.8 790.9 333.9 777.5 334.6 761.5 337.4 749.7 339.4 730.6 344.2 719.3 348 707.9 351.8 690.1 359.4 679.8 364.9 669.5 370.4 655.3 378.7 648.1 383.5 641 388.2 626.4 400.4 615.6 410.5 604.8 420.7 590.2 436.8 583.1 446.3 576 455.9 565.9 472.5 560.7 483.3 555.4 494 548.4 513 545.1 525.3 540.2 543.8 539.1 552.9 539.1 575.3 539.1 597.8 540.2 606.9 545.2 625.6 548.5 638.2 555.8 657.8 561.3 669.3 566.9 680.7 577.5 698.2 584.9 708.1 592.3 718.1 606.7 734.1 616.9 743.7 627.1 753.3 643 766 652.1 772 661.2 778 677.4 787.3 688.2 792.5 698.9 797.8 714.4 804.3 722.6 807 730.8 809.6 742.6 813 748.9 814.4 755.2 815.8 768 817.8 777.5 818.8 786.9 819.8 803.4 820.2 814 819.6ZM809.7 720.4C795.8 720.3 781.5 718.6 770.6 715.8 761.2 713.4 746 707.7 736.9 703.2 727.7 698.8 712.8 689.1 703.6 681.8 694.4 674.5 682.1 662.2 676.2 654.3 670.3 646.5 662.3 633.5 658.5 625.4 654.6 617.3 649.9 603.3 647.9 594.2 645.6 583.4 644.9 572 645.7 561.5 646.4 552.6 649.1 538.7 651.6 530.6 654.1 522.5 660 509.2 664.8 501.1 669.5 492.9 680.1 479.6 688.3 471.3 696.5 463.1 709.9 452 718.2 446.7 726.5 441.3 741.4 434 751.4 430.4 761.3 426.8 776.5 422.6 785.2 421 794.9 419.2 808 418.6 819.5 419.5 829.7 420.3 844.2 422.5 851.8 424.5 859.3 426.5 873.8 432.2 884 437.2 894.1 442.2 909.3 451.9 917.7 458.8 926.1 465.8 937.6 477.3 943.3 484.5 949 491.7 956.7 503.8 960.3 511.3 963.9 518.8 968.5 531.3 970.5 539.1 972.5 546.8 974.1 559.9 974.1 568.1 974.1 576.4 973 588.6 971.6 595.2 970.2 601.9 965.2 615.6 960.5 625.6 955.8 635.7 947.7 649.3 942.4 655.9 937.2 662.5 926 673.5 917.7 680.4 909.3 687.3 894.5 697.1 884.6 702.2 874.8 707.3 858.9 713.5 849.2 716 837.4 719 824.4 720.5 809.7 720.4Z", + "width": 1231 + }, + "search": [ + "blender" + ] + }, + { + "uid": "47be9d1ed577164f7604c14c04c89b10", + "css": "comment", + "code": 59460, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M916.2 166.7C916.2 120.8 879.2 83.3 833.3 83.3H166.7C120.8 83.3 83.3 120.8 83.3 166.7V666.7C83.3 712.5 120.8 750 166.7 750H750L916.7 916.7 916.2 166.7ZM750 583.3H250V500H750V583.3ZM750 458.3H250V375H750V458.3ZM750 333.3H250V250H750V333.3Z", + "width": 1000 + }, + "search": [ + "comment" + ] + }, + { + "uid": "0fb2dbf1b74d3febe4255d9a424b7809", + "css": "picture", + "code": 59461, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M875 791.7V208.3C875 162.5 837.5 125 791.7 125H208.3C162.5 125 125 162.5 125 208.3V791.7C125 837.5 162.5 875 208.3 875H791.7C837.5 875 875 837.5 875 791.7ZM354.2 562.5L458.3 687.9 604.2 500 791.7 750H208.3L354.2 562.5Z", + "width": 1000 + }, + "search": [ + "picture" + ] + }, + { + "uid": "6ad745d3bf336f43c5cc7a45286eff6e", + "css": "picture-album", + "code": 59462, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M916.7 666.7V166.7C916.7 120.8 879.2 83.3 833.3 83.3H333.3C287.5 83.3 250 120.8 250 166.7V666.7C250 712.5 287.5 750 333.3 750H833.3C879.2 750 916.7 712.5 916.7 666.7ZM458.3 500L542.9 612.9 666.7 458.3 833.3 666.7H333.3L458.3 500ZM83.3 250V833.3C83.3 879.2 120.8 916.7 166.7 916.7H750V833.3H166.7V250H83.3Z", + "width": 1000 + }, + "search": [ + "picture-album" + ] + }, + { + "uid": "8a02612f9aef94147b7cba3f4d1064cb", + "css": "filter", + "code": 59463, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M125 83.3H875V83.3H875V166.7H871.7L625 413.3V954.6L375 704.6V412.9L128.8 166.7H125V83.3M458.3 670L541.7 753.3V375H545.4L753.8 166.7H246.7L455 375H458.3V670Z", + "width": 1000 + }, + "search": [ + "filter" + ] + }, + { + "uid": "64a640c0d939dabd991835a708e72ee0", + "css": "filter-remove", + "code": 59464, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M613.8 867.9L732.5 750 613.8 632.1 672.9 573.3 791.7 690.4 908.3 573.3 967.5 632.1 850.4 750 967.5 867.9 908.3 926.7 791.7 808.3 672.9 926.7 613.8 867.9M83.3 83.3H833.3V83.3H833.3V166.7H830L583.3 413.3V954.6L333.3 704.6V412.9L87.1 166.7H83.3V83.3M416.7 670L500 753.3V375H503.7L712.1 166.7H205L413.3 375H416.7V670Z", + "width": 1000 + }, + "search": [ + "filter-remove" + ] + }, + { + "uid": "a0822560c22f7d7e2947d9ddfd940b90", + "css": "sort", + "code": 59497, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M125 541.7H625V458.3H125M125 250V333.3H875V250M125 750H375V666.7H125V750Z", + "width": 1000 + }, + "search": [ + "sort" + ] + }, + { + "uid": "c2314ed1d2314b89f9285c53bcbf2548", + "css": "character", + "code": 59498, + "src": "fontawesome" + }, + { + "uid": "862129f833b09f3d34ae39acf8484a7b", + "css": "heart-broken", + "code": 61480, + "src": "mfglabs" + }, + { + "uid": "77bf693b887ab9243826db6e682ea973", + "css": "globe", + "code": 61465, + "src": "mfglabs" + }, + { + "uid": "8a1d446e5555e76f82ddb1c8b526f579", + "css": "tree-flow", + "code": 59499, + "src": "entypo" + }, + { + "uid": "164f35b55c3346b03fa89a711a4f3980", + "css": "pause", + "code": 61454, + "src": "mfglabs" + }, + { + "uid": "e44601720c64e6bb6a2d5cba6b0c588c", + "css": "volume-off", + "code": 59501, + "src": "fontawesome" + }, + { + "uid": "76857a03fbaa6857fe063b6c25aa98ed", + "css": "volume-on", + "code": 59500, + "src": "fontawesome" + }, + { + "uid": "032bd8bbd70adf90ead98b6813bfe446", + "css": "newspaper", + "code": 61930, + "src": "fontawesome" + }, + { + "uid": "03e6e1bfe72275c6eaa0d0898fde6c1d", + "css": "chatbubble-working", + "code": 59407, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M500 877.7C556.4 877.7 610.2 866.6 658.8 846.7 660 846.1 660.9 845.7 662.1 845.3 662.3 845.3 662.5 845.3 662.5 845.1 669.3 842.6 676.8 841.2 684.4 841.2 692.8 841.2 700.8 842.8 708 845.9L872.1 906.3 828.9 733.6C828.9 723.2 831.8 713.5 836.5 705.1 836.5 705.1 836.5 705.1 836.5 705.1 838.1 702.5 839.6 700 841.4 697.9 882.2 636.7 905.9 564.1 905.9 486.1 906.3 269.3 724.4 93.8 500 93.8 275.6 93.8 93.8 269.3 93.8 485.7 93.8 702.3 275.6 877.7 500 877.7ZM687.5 437.5C722.1 437.5 750 465.4 750 500 750 534.6 722.1 562.5 687.5 562.5 652.9 562.5 625 534.6 625 500 625 465.4 652.9 437.5 687.5 437.5ZM500 437.5C534.6 437.5 562.5 465.4 562.5 500 562.5 534.6 534.6 562.5 500 562.5 465.4 562.5 437.5 534.6 437.5 500 437.5 465.4 465.4 437.5 500 437.5ZM312.5 437.5C347.1 437.5 375 465.4 375 500 375 534.6 347.1 562.5 312.5 562.5 277.9 562.5 250 534.6 250 500 250 465.4 277.9 437.5 312.5 437.5Z", + "width": 1000 + }, + "search": [ + "chatbubble-working" + ] + }, + { + "uid": "04e1e08d55b00883c647759c07de26e8", + "css": "film", + "code": 59395, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M109.4 171.9V828.1H890.6V171.9H109.4ZM250 796.9H140.6V703.1H250V796.9ZM250 671.9H140.6V578.1H250V671.9ZM250 546.9H140.6V453.1H250V546.9ZM250 421.9H140.6V328.1H250V421.9ZM250 296.9H140.6V203.1H250V296.9ZM718.8 796.9H281.3V515.6H718.8V796.9ZM718.8 484.4H281.3V203.1H718.8V484.4ZM859.4 796.9H750V703.1H859.4V796.9ZM859.4 671.9H750V578.1H859.4V671.9ZM859.4 546.9H750V453.1H859.4V546.9ZM859.4 421.9H750V328.1H859.4V421.9ZM859.4 296.9H750V203.1H859.4V296.9Z", + "width": 1000 + }, + "search": [ + "ios-film-outline" + ] + }, + { + "uid": "33558f4f0c7fad4ad8aa41f12f229e61", + "css": "body-outline", + "code": 59396, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M500 93.7C534.5 93.7 562.5 121.7 562.5 156.2 562.5 190.6 534.5 218.6 500 218.6S437.5 190.6 437.5 156.2C437.5 121.7 465.5 93.7 500 93.7M500 62.4C448.2 62.4 406.3 104.4 406.3 156.2 406.3 207.9 448.2 249.9 500 249.9S593.8 207.9 593.8 156.2C593.8 104.4 551.8 62.4 500 62.4L500 62.4ZM828.1 281.3H171.9C146 281.3 125 302.2 125 328.1S146 375 171.9 375H368.1C378.9 375 393.8 383.4 401.7 403.7 410.8 427.3 406.3 468.8 400.6 504.2L393 545.7C392.9 545.9 392.6 545.9 392.6 546.1L329.6 882.5C325.1 908 342.3 932.3 367.8 936.8 370.5 937.3 373.3 937.5 376 937.5 398.2 937.5 416.9 921.5 420.9 898.8L460.9 664.5V664.9C460.9 664.9 475.1 603.5 498.9 603.5H501.1C525.4 603.5 535.2 664.9 535.2 664.9V664.7L577.1 898.9C581.1 921.6 601.2 937.6 623.5 937.6 626.2 937.6 629.1 937.3 631.9 936.9 657.4 932.4 674.5 908 670 882.5L606.8 546.1C606.8 546.1 606.8 546.1 606.8 546.1 606.8 545.8 606.8 545.6 606.7 545.4L599.4 503.6C593.7 468.2 589.2 427.3 598.3 403.7 606.2 383.4 622.1 375 631.9 375H828.1C854 375 875 354 875 328.1S854 281.3 828.1 281.3ZM171.9 344C163.3 344 156.3 336.8 156.3 328.2 156.3 319.6 163.3 312.5 171.9 312.5H828.1C836.7 312.5 843.8 319.5 843.8 328.1S836.7 343.7 828.1 343.7H629.9C599.5 343.7 576.3 372.5 569.4 390.5 559.9 415 559.6 452.9 568.6 509L568.6 509.2 568.6 509.4 575.2 546.8 576.3 553.1 639.3 888.3C640.3 893.9 638.1 898 636.7 900 635.3 902 632.1 905.5 626.6 906.5 625.6 906.6 624.7 906.7 623.8 906.7 616.2 906.7 609.7 901.6 608.4 894.1L566.9 660.2H566.9C566.3 656.3 563.3 640.5 557.1 623.5 552.6 611.4 547.6 601.9 541.7 594.2 527.9 575.9 512 572.3 501.1 572.3H498.9C488.2 572.3 472.6 575.6 458.5 593.6 452.5 601.2 447.3 610.6 442.5 622.5 435.4 640.2 431.7 657 431.3 658.9L431.1 659.6 390.6 893.8C389.3 901.4 383.3 906.7 376.1 906.7 375.2 906.7 374.3 906.6 373.4 906.4 367.8 905.5 364.7 902 363.3 900 361.9 898 359.7 893.9 360.7 888.3L423.7 553.3 423.7 553.2 424 551.4 431.4 509.7 431.5 509.5 431.5 509.3C440.5 452.9 440.3 414.9 430.8 390.3 423.9 372.4 404.3 344 368.8 344", + "width": 1000 + }, + "search": [ + "ios-body-outline" + ] + }, + { + "uid": "5b90df81a0b256ed335000ff53550765", + "css": "box-outline", + "code": 59397, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M562.1 468.8C579.3 468.8 593.6 482.8 593.6 500S579.9 531.3 562.5 531.3H437.5C420.3 531.3 406.3 517.2 406.3 500S420.3 468.8 437.5 468.8H560.5M562.5 437.5H437.5C403.1 437.5 375 465.6 375 500S403.1 562.5 437.5 562.5H562.5C596.9 562.5 625 534.4 625 500S596.9 437.5 562.5 437.5L562.5 437.5ZM812.5 218.8H187.5V375H218.8V781.3H781.3V375H812.5V218.8ZM750 750H250V375H750V750ZM781.3 343.8H218.8V250H781.3V343.8Z", + "width": 1000 + }, + "search": [ + "ios-box-outline" + ] + }, + { + "uid": "b63e1ecd9fee37c1d22bfc964afcefb3", + "css": "camera-shots-outline", + "code": 59398, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M589.5 622.3C563.9 643.8 532.9 656.2 500 656.2 429 656.2 370.5 599.6 361.5 535.2H421.3L345.4 437.5 272.1 535.2H329.7C338.8 617.2 411.2 687.5 500 687.5 540.8 687.5 580.4 672.7 611.5 646.1L616.1 641.8 593.5 619.1 589.5 622.3ZM616.7 388.4C585.1 359.6 543.6 343.8 500 343.8 459.2 343.8 419.6 358.6 388.5 385.3L383.9 389.3 406.5 411.9 410.5 408.6C435.8 387.4 467.5 375.4 500 375.4 571 375.4 629.4 431.6 638.5 500H578.6L654.5 600 728 500H670.3C665.9 460.9 647 416.1 616.7 388.4ZM815.4 312.5H694.9C632.2 242.2 612.4 218.8 588.4 218.8H415.5C391.5 218.8 372.2 242.2 309 312.5H283.2V281.3H216.8V312.5H190.4C156 312.5 125 338.3 125 372.4V716.2C125 750.3 156 781.3 190.4 781.3H815.4C849.9 781.3 875 750.3 875 716.2V372.4C875 338.3 849.9 312.5 815.4 312.5ZM843.8 716.2C843.8 734.3 831.6 750 815.4 750H190.4C173.4 750 156.3 733.1 156.3 716.2V372.4C156.3 356.5 172.1 343.8 190.4 343.8H309C309 343.8 317.1 343.8 321 343.8S327.3 343.4 332.3 337.5 347.3 318 353.9 310.6C376 285.8 391.9 267.8 403.4 257.6 412.6 249.3 415.5 250 415.5 250H588.4C588.4 250 591.5 249.3 601.5 258.2 613.5 269 630.2 291.1 653.4 317.2 659 323.6 667.4 333.1 671.6 337.6S679.9 343.8 682.8 343.8 694.9 343.8 694.9 343.8H815.4C832.7 343.8 843.8 355.4 843.8 372.4V716.2Z", + "width": 1000 + }, + "search": [ + "ios-reverse-camera-outline" + ] + }, + { + "uid": "c8388cae1ba05fec948ec5af83771377", + "css": "people-outline", + "code": 59399, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M500 218.8L500 218.8 500 218.8ZM682 653.9C655.7 644.7 627.1 644.1 600.8 635 592.8 632.2 577 628.9 573.6 619.7 570.5 610.7 570.5 600.2 569.9 590.8 569.5 583.4 569.3 576 569.3 568.6 569.3 563.7 581.8 553.3 584.6 548.8 595.1 531.3 596.1 507.6 598 487.7 615 492.4 617.2 460.9 620.1 451.4 622.3 444.7 635.4 399 615 405.3 619.9 396.7 621.9 386.1 623.2 376.6 627.1 351.6 628.7 324.2 621.1 299.8 605.3 249 556.6 220.5 505.3 218.9 453.1 217.2 400.8 242.2 381.3 292.8 371.9 317.4 372.7 344.1 375.8 369.9 377.1 381.6 379.1 394.7 385 405.3 366 399.6 376.2 439.8 378.3 446.9 381.4 456.8 384.2 492.6 402 487.7 403.5 503.5 405.3 519.7 409.6 535.2 412.5 545.5 418.6 554.3 425.6 562.3 429.1 566.2 430.9 566.6 430.7 571.7 430.5 586.9 430.9 603.3 427 618.2 423 633 390.4 639.3 377.3 642 342.2 649.2 309.8 652.5 280.5 674.4 246.3 699.6 228.5 738.9 228.5 781.3 391.2 781.3 553.9 781.3 716.6 781.3 735 781.3 753.1 781.3 771.5 781.3 771.5 723.6 736.7 672.9 682 653.9 660.2 646.3 704.1 661.5 682 653.9ZM287.3 710.2C298.2 698.6 312.1 688.5 327.1 682.8 347.7 675 370.9 675.4 392.2 669.9 409.8 665.4 431.6 658.4 445.3 645.7 457.8 634 459.2 616 460.4 599.8 461.3 586.1 461.1 572.7 461.1 559 461.1 549.4 450 543.9 444.5 536.3 435.7 524.4 435.4 506.6 433.6 492.4 432.8 486.1 432.6 478.1 427.3 473.8 421.5 469.1 417.2 466.6 414.3 459.2 410.4 449 409 438.1 405.9 427.5 403.9 420.7 410.7 414.3 413.5 408.6 418.6 398.2 409.8 382.2 408 371.5 402.5 339.5 402.7 304.5 425.8 279.1 472.1 227.9 578.1 244.1 593.4 316 598 338.5 595.1 364.6 588.9 386.5 586.1 395.9 583 402.7 588.5 411.9 596.7 425.2 593.2 433.8 589.3 448.6 586.1 460.9 582.2 466.4 572.9 474 565 480.3 566.2 495.9 564.8 504.9 563.3 515.8 562.3 527.3 555.7 536.5 553.1 540 539.3 551 539.3 554.7 539.3 576.2 538.3 598 542 619.3 547.1 649.6 572.5 656.8 597.9 667.2 623.6 677.1 653.5 673.4 678.5 685.7 704.3 698.4 729.1 721.7 736.5 750.4 581.6 750.4 426.8 750.4 271.9 750.4 269.1 750.4 266.4 750.4 263.7 750.4 267.4 734.2 276.4 721.9 287.3 710.2 305.3 691.2 275.8 722.5 287.3 710.2ZM281.4 628.9C295.1 622.1 310 620.5 325 618.9 330.5 618.4 333 614.6 328.9 609.4 321.1 599.4 294.1 597.5 282.8 593 275.8 590.2 273.8 587.7 273.2 579.9 273 576.4 271.1 560.7 273.8 558.2 275.8 556.3 288.1 557 290.8 556.6 302 555.3 313.3 552.9 323.8 548.8 328.3 547.1 332.6 544.9 336.5 542.2 341.2 538.7 333 530.1 330.9 525.4 324.2 510.7 321.3 494.7 320.3 478.7 318.4 447.3 323.2 415.6 317.4 384.4 308.6 336.5 271.7 312.5 224.6 312.5 195.5 312.5 166.8 322.5 150.6 347.9 132.6 375.8 133.6 410.5 134.6 442.4 135.2 460.5 135.9 478.9 133.4 497.1 132.2 504.9 130.5 512.5 127.7 519.9 125.6 525.6 114.6 539.6 118.9 542.6 135.2 554.1 162.5 558 182 556.4 182.6 566 184.4 578.3 180.9 587.3 175.4 601.4 134.6 605.1 122.3 609.2 87.9 620.7 62.5 649.4 62.5 687.5 98.2 687.5 133.8 687.5 169.5 687.5 185.5 687.5 201.6 687.5 217.8 687.5 220.3 687.5 230.1 669.3 232.8 666.4 246.5 651.2 263.3 638.1 281.4 628.9 299 620.1 252.9 643.4 281.4 628.9ZM200.8 656.3C168.9 656.3 137.1 656.3 105.3 656.3 119.1 633.2 153.9 634.8 176.8 626.6 197.9 618.9 211.3 606.1 213.5 583.6 213.7 581.1 213.9 526.6 211.7 526.6 194.7 526 177 526.4 160 523.8 173.4 480.3 160.5 435.4 167.8 391.4 173 359.6 193.4 342 225.8 342 256.8 342 279.9 356.4 286.5 387.7 295.9 433 282.6 479.3 297.1 524.2 286.3 527 275 527.3 263.9 527.7 258.4 527.9 252.7 528.1 247.3 528.3 243.8 528.5 244.9 534.6 244.7 537.5 242.8 558.8 235 592.4 252 609.6 233.2 621.3 212.9 637.5 200.8 656.3ZM780.9 687.5C833 687.5 885.4 687.5 937.5 687.5 937.5 649.4 911.9 620.5 877.5 609.2 861.7 604.1 831.3 602.5 819.9 588.9 814.3 582 817.4 564.6 818 556.4 826.6 557.2 835.9 555.9 844.7 554.7 852.7 553.5 860.5 552 868.2 549.2 871.7 547.9 875.2 546.5 878.5 544.5 886.1 540 882.6 539.3 878.7 532.6 857.4 496.9 867 451.6 866 412.3 865.2 379.7 856.6 343.9 827 325.6 800.2 309 760.5 308.4 731.8 320.1 649 353.3 697.9 463.1 669.5 526 664.6 536.5 657.6 540.2 669.9 546.5 676.8 550 684.2 552.3 691.6 554.1 702.9 556.8 714.6 558.4 726.4 558.8 728.3 558.8 727 583.4 726.4 585.9 724.2 595.5 703.3 598.2 695.5 600.4 687.5 602.5 674.2 603.1 670.3 611.5 664.5 624 689.6 620.9 695.9 622.1 716 625.8 733.8 636.9 749.4 649.6 760.9 659 776.6 672.3 780.9 687.5ZM780.1 634.6C770.1 625.4 759.8 616.4 748.2 609.2 765.4 592 757.4 558.6 755.5 537.1 754.3 524.8 751.6 527.9 739.3 527.5 727.5 527.1 714.3 527.7 703.1 524 717.2 480.5 705.7 435.7 712.9 391.4 718.4 358 741.2 341.6 774.4 341.6 804.5 341.6 825.2 356.6 831.4 386.5 841 431.6 826.4 478.3 840.2 523.2 823.6 525.8 806.4 525.2 789.6 525.8 785.5 526 785.9 574.8 786.3 579.1 787.7 602 798.2 615.8 819.9 624.6 843.6 634.2 880.5 631.6 894.9 655.7 876.8 655.7 858.8 655.7 840.6 655.7 829.7 655.7 818.8 655.7 807.6 655.7 793.8 655.9 790.2 643.8 780.1 634.6 770.3 625.6 787.9 641.6 780.1 634.6Z", + "width": 1000 + }, + "search": [ + "ios-people-outline" + ] + }, + { + "uid": "e0e95da4d6acf1e4156bad46756af697", + "css": "social-twitter", + "code": 59402, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M960.9 213.9C927 228.9 890.6 239.1 852.3 243.8 891.4 220.3 921.5 183.2 935.5 139.1 899 160.7 858.6 176.6 815.4 185 780.9 148 731.4 125 677.3 125 572.9 125 488.3 209.8 488.3 314.3 488.3 329.1 489.8 343.6 493.2 357.4 335.9 349.6 196.3 274.2 103.1 159.6 86.9 187.5 77.5 220.1 77.5 254.7 77.5 320.3 110.9 378.3 161.7 412.3 130.9 411.5 101.6 402.9 76.2 388.7 76.2 389.5 76.2 390.2 76.2 391 76.2 482.8 141.4 559.2 227.9 576.6 212.1 580.9 195.3 583.2 178.1 583.2 166 583.2 154.1 582 142.6 579.7 166.6 654.9 236.5 709.6 319.3 711.1 254.7 761.9 173 792.2 84.4 792.2 69.1 792.2 54.1 791.2 39.3 789.5 122.7 843.8 222.1 875 328.7 875 676.8 875 867.2 586.5 867.2 336.3 867.2 328.1 867 319.9 866.6 311.9 903.3 285.2 935.5 252 960.9 213.9ZM848 286.5L834.4 296.5 835.2 313.3C835.5 320.7 835.7 328.5 835.7 336.3 835.7 395.3 824.2 457 802.5 515 779.7 576 746.9 631.1 704.9 678.7 660 729.7 606.8 769.5 546.5 797.5 480.5 828.1 407.2 843.6 328.7 843.6 272.3 843.6 216.6 834.2 163.7 815.8 183 812.1 202 806.8 220.5 800.4 262.9 785.5 302.3 763.9 337.9 735.9L406.8 681.8 319.1 680.3C268.2 679.3 222.1 654.1 193.6 614.1 207.8 613.1 221.9 610.5 235.5 606.8L354.1 567.4 233.6 546.1C175.8 534.6 130.5 491.6 113.9 437.1 129.1 441 143.9 442.6 160.5 443.6 160.5 443.6 222.7 446.3 269.5 443.4 244.1 431.3 178.9 386.3 178.9 386.3 135 356.8 108.6 307.8 108.6 254.7 108.6 242.2 110.2 229.7 112.9 217.6 155.7 261.3 204.7 297.9 259.6 326 331.8 363.3 409.8 384.2 491.2 388.3L532.6 390.4 523.2 350C520.5 338.5 519.1 326.4 519.1 313.9 519.1 227.1 590 156.3 677.1 156.3 720.7 156.3 762.7 174.4 792.4 206.3L804.3 218.9 821.3 215.6C830.1 213.9 838.7 211.9 847.3 209.6 845.3 212.3 836.1 221.5 825.8 230.7 817 238.5 791 262.9 791 262.9S816.4 270.9 832.2 272.7C848 274.4 866.2 271.5 869.1 271.1 863.7 275.6 853.9 282.4 848 286.5Z", + "width": 1000 + }, + "search": [ + "social-twitter-outline" + ] + }, + { + "uid": "4b4b163717d1f5dc645e300e81683eae", + "css": "email-outline", + "code": 59403, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M125 250V750H875V250H125ZM500 523.2L182 281.3H818L500 523.2ZM156.3 718.8V301L381.1 472.1 248 623 252 627 406.1 491 500 562.5 593.9 491 748 627 752 623 618.9 471.9 843.8 301V718.8H156.3Z", + "width": 1000 + }, + "search": [ + "email-outline" + ] + }, + { + "uid": "091509aeaf9821c3a88ae755f54067bd", + "css": "upload", + "code": 59406, + "src": "custom_icons", + "selected": false, + "svg": { + "path": "M777.5 455.5C777.5 453.1 777.9 450.8 777.9 448.4 777.9 321.5 676.8 218.8 552 218.8 461.9 218.8 384.6 272.3 348.2 349.6 332.4 341.6 314.6 336.9 295.9 336.9 238.3 336.9 190.2 379.7 181.1 435.5 111.9 459.4 62.5 525.6 62.5 603.5 62.5 701.6 140.8 781.3 237.3 781.3H437.5V625L343.4 625 500 461.5 656.6 624.8 562.5 624.8V781.1H777.9C866.2 781.1 937.5 707.8 937.5 618.2 937.5 528.5 865.8 455.7 777.5 455.5Z", + "width": 1000 + }, + "search": [ + "upload" + ] + } + ] +} \ No newline at end of file diff --git a/pillar/web/static/assets/js/vendor/jquery.fileupload.min.js b/pillar/web/static/assets/js/vendor/jquery.fileupload.min.js new file mode 100644 index 00000000..766f8ac5 --- /dev/null +++ b/pillar/web/static/assets/js/vendor/jquery.fileupload.min.js @@ -0,0 +1 @@ +!function(a){"use strict";"function"==typeof define&&define.amd?define(["jquery","jquery.ui.widget"],a):"object"==typeof exports?a(require("jquery"),require("./vendor/jquery.ui.widget")):a(window.jQuery)}(function(a){"use strict";function b(b){var c="dragover"===b;return function(d){d.dataTransfer=d.originalEvent&&d.originalEvent.dataTransfer;var e=d.dataTransfer;e&&a.inArray("Files",e.types)!==-1&&this._trigger(b,a.Event(b,{delegatedEvent:d}))!==!1&&(d.preventDefault(),c&&(e.dropEffect="copy"))}}a.support.fileInput=!(new RegExp("(Android (1\\.[0156]|2\\.[01]))|(Windows Phone (OS 7|8\\.0))|(XBLWP)|(ZuneWP)|(WPDesktop)|(w(eb)?OSBrowser)|(webOS)|(Kindle/(1\\.0|2\\.[05]|3\\.0))").test(window.navigator.userAgent)||a('').prop("disabled")),a.support.xhrFileUpload=!(!window.ProgressEvent||!window.FileReader),a.support.xhrFormDataFileUpload=!!window.FormData,a.support.blobSlice=window.Blob&&(Blob.prototype.slice||Blob.prototype.webkitSlice||Blob.prototype.mozSlice),a.widget("blueimp.fileupload",{options:{dropZone:a(document),pasteZone:void 0,fileInput:void 0,replaceFileInput:!0,paramName:void 0,singleFileUploads:!0,limitMultiFileUploads:void 0,limitMultiFileUploadSize:void 0,limitMultiFileUploadSizeOverhead:512,sequentialUploads:!1,limitConcurrentUploads:void 0,forceIframeTransport:!1,redirect:void 0,redirectParamName:void 0,postMessage:void 0,multipart:!0,maxChunkSize:void 0,uploadedBytes:void 0,recalculateProgress:!0,progressInterval:100,bitrateInterval:500,autoUpload:!0,messages:{uploadedBytes:"Uploaded bytes exceed file size"},i18n:function(b,c){return b=this.messages[b]||b.toString(),c&&a.each(c,function(a,c){b=b.replace("{"+a+"}",c)}),b},formData:function(a){return a.serializeArray()},add:function(b,c){return!b.isDefaultPrevented()&&void((c.autoUpload||c.autoUpload!==!1&&a(this).fileupload("option","autoUpload"))&&c.process().done(function(){c.submit()}))},processData:!1,contentType:!1,cache:!1},_specialOptions:["fileInput","dropZone","pasteZone","multipart","forceIframeTransport"],_blobSlice:a.support.blobSlice&&function(){var a=this.slice||this.webkitSlice||this.mozSlice;return a.apply(this,arguments)},_BitrateTimer:function(){this.timestamp=Date.now?Date.now():(new Date).getTime(),this.loaded=0,this.bitrate=0,this.getBitrate=function(a,b,c){var d=a-this.timestamp;return(!this.bitrate||!c||d>c)&&(this.bitrate=(b-this.loaded)*(1e3/d)*8,this.loaded=b,this.timestamp=a),this.bitrate}},_isXHRUpload:function(b){return!b.forceIframeTransport&&(!b.multipart&&a.support.xhrFileUpload||a.support.xhrFormDataFileUpload)},_getFormData:function(b){var c;return"function"===a.type(b.formData)?b.formData(b.form):a.isArray(b.formData)?b.formData:"object"===a.type(b.formData)?(c=[],a.each(b.formData,function(a,b){c.push({name:a,value:b})}),c):[]},_getTotal:function(b){var c=0;return a.each(b,function(a,b){c+=b.size||1}),c},_initProgressObject:function(b){var c={loaded:0,total:0,bitrate:0};b._progress?a.extend(b._progress,c):b._progress=c},_initResponseObject:function(a){var b;if(a._response)for(b in a._response)a._response.hasOwnProperty(b)&&delete a._response[b];else a._response={}},_onProgress:function(b,c){if(b.lengthComputable){var e,d=Date.now?Date.now():(new Date).getTime();if(c._time&&c.progressInterval&&d-c._time").prop("href",b.url).prop("host");b.dataType="iframe "+(b.dataType||""),b.formData=this._getFormData(b),b.redirect&&c&&c!==location.host&&b.formData.push({name:b.redirectParamName||"redirect",value:b.redirect})},_initDataSettings:function(a){this._isXHRUpload(a)?(this._chunkedUpload(a,!0)||(a.data||this._initXHRData(a),this._initProgressListener(a)),a.postMessage&&(a.dataType="postmessage "+(a.dataType||""))):this._initIframeSettings(a)},_getParamName:function(b){var c=a(b.fileInput),d=b.paramName;return d?a.isArray(d)||(d=[d]):(d=[],c.each(function(){for(var b=a(this),c=b.prop("name")||"files[]",e=(b.prop("files")||[1]).length;e;)d.push(c),e-=1}),d.length||(d=[c.prop("name")||"files[]"])),d},_initFormSettings:function(b){b.form&&b.form.length||(b.form=a(b.fileInput.prop("form")),b.form.length||(b.form=a(this.options.fileInput.prop("form")))),b.paramName=this._getParamName(b),b.url||(b.url=b.form.prop("action")||location.href),b.type=(b.type||"string"===a.type(b.form.prop("method"))&&b.form.prop("method")||"").toUpperCase(),"POST"!==b.type&&"PUT"!==b.type&&"PATCH"!==b.type&&(b.type="POST"),b.formAcceptCharset||(b.formAcceptCharset=b.form.attr("accept-charset"))},_getAJAXSettings:function(b){var c=a.extend({},this.options,b);return this._initFormSettings(c),this._initDataSettings(c),c},_getDeferredState:function(a){return a.state?a.state():a.isResolved()?"resolved":a.isRejected()?"rejected":"pending"},_enhancePromise:function(a){return a.success=a.done,a.error=a.fail,a.complete=a.always,a},_getXHRPromise:function(b,c,d){var e=a.Deferred(),f=e.promise();return c=c||this.options.context||f,b===!0?e.resolveWith(c,d):b===!1&&e.rejectWith(c,d),f.abort=e.promise,this._enhancePromise(f)},_addConvenienceMethods:function(b,c){var d=this,e=function(b){return a.Deferred().resolveWith(d,b).promise()};c.process=function(b,f){return(b||f)&&(c._processQueue=this._processQueue=(this._processQueue||e([this])).pipe(function(){return c.errorThrown?a.Deferred().rejectWith(d,[c]).promise():e(arguments)}).pipe(b,f)),this._processQueue||e([this])},c.submit=function(){return"pending"!==this.state()&&(c.jqXHR=this.jqXHR=d._trigger("submit",a.Event("submit",{delegatedEvent:b}),this)!==!1&&d._onSend(b,this)),this.jqXHR||d._getXHRPromise()},c.abort=function(){return this.jqXHR?this.jqXHR.abort():(this.errorThrown="abort",d._trigger("fail",null,this),d._getXHRPromise(!1))},c.state=function(){return this.jqXHR?d._getDeferredState(this.jqXHR):this._processQueue?d._getDeferredState(this._processQueue):void 0},c.processing=function(){return!this.jqXHR&&this._processQueue&&"pending"===d._getDeferredState(this._processQueue)},c.progress=function(){return this._progress},c.response=function(){return this._response}},_getUploadedBytes:function(a){var b=a.getResponseHeader("Range"),c=b&&b.split("-"),d=c&&c.length>1&&parseInt(c[1],10);return d&&d+1},_chunkedUpload:function(b,c){b.uploadedBytes=b.uploadedBytes||0;var l,m,d=this,e=b.files[0],f=e.size,g=b.uploadedBytes,h=b.maxChunkSize||f,i=this._blobSlice,j=a.Deferred(),k=j.promise();return!(!(this._isXHRUpload(b)&&i&&(g||h=f?(e.error=b.i18n("uploadedBytes"),this._getXHRPromise(!1,b.context,[null,"error",e.error])):(m=function(){var c=a.extend({},b),k=c._progress.loaded;c.blob=i.call(e,g,g+h,e.type),c.chunkSize=c.blob.size,c.contentRange="bytes "+g+"-"+(g+c.chunkSize-1)+"/"+f,d._initXHRData(c),d._initProgressListener(c),l=(d._trigger("chunksend",null,c)!==!1&&a.ajax(c)||d._getXHRPromise(!1,c.context)).done(function(e,h,i){g=d._getUploadedBytes(i)||g+c.chunkSize,k+c.chunkSize-c._progress.loaded&&d._onProgress(a.Event("progress",{lengthComputable:!0,loaded:g-c.uploadedBytes,total:g-c.uploadedBytes}),c),b.uploadedBytes=c.uploadedBytes=g,c.result=e,c.textStatus=h,c.jqXHR=i,d._trigger("chunkdone",null,c),d._trigger("chunkalways",null,c),gd._sending)for(var e=d._slots.shift();e;){if("pending"===d._getDeferredState(e)){e.resolve();break}e=d._slots.shift()}0===d._active&&d._trigger("stop")})};return this._beforeSend(b,i),this.options.sequentialUploads||this.options.limitConcurrentUploads&&this.options.limitConcurrentUploads<=this._sending?(this.options.limitConcurrentUploads>1?(g=a.Deferred(),this._slots.push(g),h=g.pipe(j)):(this._sequence=this._sequence.pipe(j,j),h=this._sequence),h.abort=function(){return f=[void 0,"abort","abort"],e?e.abort():(g&&g.rejectWith(i.context,f),j())},this._enhancePromise(h)):j()},_onAdd:function(b,c){var n,o,p,q,d=this,e=!0,f=a.extend({},this.options,c),g=c.files,h=g.length,i=f.limitMultiFileUploads,j=f.limitMultiFileUploadSize,k=f.limitMultiFileUploadSizeOverhead,l=0,m=this._getParamName(f),r=0;if(!j||h&&void 0!==g[0].size||(j=void 0),(f.singleFileUploads||i||j)&&this._isXHRUpload(f))if(f.singleFileUploads||j||!i)if(!f.singleFileUploads&&j)for(p=[],n=[],q=0;qj||i&&q+1-r>=i)&&(p.push(g.slice(r,q+1)),o=m.slice(r,q+1),o.length||(o=m),n.push(o),r=q+1,l=0);else n=m;else for(p=[],n=[],q=0;q").append(d)[0].reset(),c.after(d).detach(),a.cleanData(c.unbind("remove")),this.options.fileInput=this.options.fileInput.map(function(a,b){return b===c[0]?d[0]:b}),c[0]===this.element[0]&&(this.element=d)},_handleFileTreeEntry:function(b,c){var i,d=this,e=a.Deferred(),f=function(a){a&&!a.entry&&(a.entry=b),e.resolve([a])},g=function(a){d._handleFileTreeEntries(a,c+b.name+"/").done(function(a){e.resolve(a)}).fail(f)},h=function(){i.readEntries(function(a){a.length?(j=j.concat(a),h()):g(j)},f)},j=[];return c=c||"",b.isFile?b._file?(b._file.relativePath=c,e.resolve(b._file)):b.file(function(a){a.relativePath=c,e.resolve(a)},f):b.isDirectory?(i=b.createReader(),h()):e.resolve([]),e.promise()},_handleFileTreeEntries:function(b,c){var d=this;return a.when.apply(a,a.map(b,function(a){return d._handleFileTreeEntry(a,c)})).pipe(function(){return Array.prototype.concat.apply([],arguments)})},_getDroppedFiles:function(b){b=b||{};var c=b.items;return c&&c.length&&(c[0].webkitGetAsEntry||c[0].getAsEntry)?this._handleFileTreeEntries(a.map(c,function(a){var b;return a.webkitGetAsEntry?(b=a.webkitGetAsEntry(),b&&(b._file=a.getAsFile()),b):a.getAsEntry()})):a.Deferred().resolve(a.makeArray(b.files)).promise()},_getSingleFileInputFiles:function(b){b=a(b);var d,e,c=b.prop("webkitEntries")||b.prop("entries");if(c&&c.length)return this._handleFileTreeEntries(c);if(d=a.makeArray(b.prop("files")),d.length)void 0===d[0].name&&d[0].fileName&&a.each(d,function(a,b){b.name=b.fileName,b.size=b.fileSize});else{if(e=b.prop("value"),!e)return a.Deferred().resolve([]).promise();d=[{name:e.replace(/^.*\\/,"")}]}return a.Deferred().resolve(d).promise()},_getFileInputFiles:function(b){return b instanceof a&&1!==b.length?a.when.apply(a,a.map(b,this._getSingleFileInputFiles)).pipe(function(){return Array.prototype.concat.apply([],arguments)}):this._getSingleFileInputFiles(b)},_onChange:function(b){var c=this,d={fileInput:a(b.target),form:a(b.target.form)};this._getFileInputFiles(d.fileInput).always(function(e){d.files=e,c.options.replaceFileInput&&c._replaceFileInput(d),c._trigger("change",a.Event("change",{delegatedEvent:b}),d)!==!1&&c._onAdd(b,d)})},_onPaste:function(b){var c=b.originalEvent&&b.originalEvent.clipboardData&&b.originalEvent.clipboardData.items,d={files:[]};c&&c.length&&(a.each(c,function(a,b){var c=b.getAsFile&&b.getAsFile();c&&d.files.push(c)}),this._trigger("paste",a.Event("paste",{delegatedEvent:b}),d)!==!1&&this._onAdd(b,d))},_onDrop:function(b){b.dataTransfer=b.originalEvent&&b.originalEvent.dataTransfer;var c=this,d=b.dataTransfer,e={};d&&d.files&&d.files.length&&(b.preventDefault(),this._getDroppedFiles(d).always(function(d){e.files=d,c._trigger("drop",a.Event("drop",{delegatedEvent:b}),e)!==!1&&c._onAdd(b,e)}))},_onDragOver:b("dragover"),_onDragEnter:b("dragenter"),_onDragLeave:b("dragleave"),_initEventHandlers:function(){this._isXHRUpload(this.options)&&(this._on(this.options.dropZone,{dragover:this._onDragOver,drop:this._onDrop,dragenter:this._onDragEnter,dragleave:this._onDragLeave}),this._on(this.options.pasteZone,{paste:this._onPaste})),a.support.fileInput&&this._on(this.options.fileInput,{change:this._onChange})},_destroyEventHandlers:function(){this._off(this.options.dropZone,"dragenter dragleave dragover drop"),this._off(this.options.pasteZone,"paste"),this._off(this.options.fileInput,"change")},_setOption:function(b,c){var d=a.inArray(b,this._specialOptions)!==-1;d&&this._destroyEventHandlers(),this._super(b,c),d&&(this._initSpecialOptions(),this._initEventHandlers())},_initSpecialOptions:function(){var b=this.options;void 0===b.fileInput?b.fileInput=this.element.is('input[type="file"]')?this.element:this.element.find('input[type="file"]'):b.fileInput instanceof a||(b.fileInput=a(b.fileInput)),b.dropZone instanceof a||(b.dropZone=a(b.dropZone)),b.pasteZone instanceof a||(b.pasteZone=a(b.pasteZone))},_getRegExp:function(a){var b=a.split("/"),c=b.pop();return b.shift(),new RegExp(b.join("/"),c)},_isRegExpOption:function(b,c){return"url"!==b&&"string"===a.type(c)&&/^\/.*\/[igm]{0,3}$/.test(c)},_initDataAttributes:function(){var b=this,c=this.options,d=this.element.data();a.each(this.element[0].attributes,function(a,e){var g,f=e.name.toLowerCase();/^data-/.test(f)&&(f=f.slice(5).replace(/-[a-z]/g,function(a){return a.charAt(1).toUpperCase()}),g=d[f],b._isRegExpOption(f,g)&&(g=b._getRegExp(g)),c[f]=g)})},_create:function(){this._initDataAttributes(),this._initSpecialOptions(),this._slots=[],this._sequence=this._getXHRPromise(!0),this._sending=this._active=0,this._initProgressObject(this),this._initEventHandlers()},active:function(){return this._active},progress:function(){return this._progress},add:function(b){var c=this;b&&!this.options.disabled&&(b.fileInput&&!b.files?this._getFileInputFiles(b.fileInput).always(function(a){b.files=a,c._onAdd(null,b)}):(b.files=a.makeArray(b.files),this._onAdd(null,b)))},send:function(b){if(b&&!this.options.disabled){if(b.fileInput&&!b.files){var f,g,c=this,d=a.Deferred(),e=d.promise();return e.abort=function(){return g=!0,f?f.abort():(d.reject(null,"abort","abort"),e)},this._getFileInputFiles(b.fileInput).always(function(a){if(!g){if(!a.length)return void d.reject();b.files=a,f=c._onSend(null,b),f.then(function(a,b,c){d.resolve(a,b,c)},function(a,b,c){d.reject(a,b,c)})}}),this._enhancePromise(e)}if(b.files=a.makeArray(b.files),b.files.length)return this._onSend(null,b)}return this._getXHRPromise(!1,b&&b.context)}})}); \ No newline at end of file diff --git a/pillar/web/static/assets/js/vendor/jquery.iframe-transport.min.js b/pillar/web/static/assets/js/vendor/jquery.iframe-transport.min.js new file mode 100644 index 00000000..d5793a2f --- /dev/null +++ b/pillar/web/static/assets/js/vendor/jquery.iframe-transport.min.js @@ -0,0 +1 @@ +!function(a){"use strict";"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports?require("jquery"):window.jQuery)}(function(a){"use strict";var b=0;a.ajaxTransport("iframe",function(c){if(c.async){var e,f,g,d=c.initialIframeSrc||"javascript:false;";return{send:function(h,i){e=a('
'),e.attr("accept-charset",c.formAcceptCharset),g=/\?/.test(c.url)?"&":"?","DELETE"===c.type?(c.url=c.url+g+"_method=DELETE",c.type="POST"):"PUT"===c.type?(c.url=c.url+g+"_method=PUT",c.type="POST"):"PATCH"===c.type&&(c.url=c.url+g+"_method=PATCH",c.type="POST"),b+=1,f=a('').bind("load",function(){var b,g=a.isArray(c.paramName)?c.paramName:[c.paramName];f.unbind("load").bind("load",function(){var b;try{if(b=f.contents(),!b.length||!b[0].firstChild)throw new Error}catch(a){b=void 0}i(200,"success",{iframe:b}),a('').appendTo(e),window.setTimeout(function(){e.remove()},0)}),e.prop("target",f.prop("name")).prop("action",c.url).prop("method",c.type),c.formData&&a.each(c.formData,function(b,c){a('').prop("name",c.name).val(c.value).appendTo(e)}),c.fileInput&&c.fileInput.length&&"POST"===c.type&&(b=c.fileInput.clone(),c.fileInput.after(function(a){return b[a]}),c.paramName&&c.fileInput.each(function(b){a(this).prop("name",g[b]||c.paramName)}),e.append(c.fileInput).prop("enctype","multipart/form-data").prop("encoding","multipart/form-data"),c.fileInput.removeAttr("form")),e.submit(),b&&b.length&&c.fileInput.each(function(c,d){var e=a(b[c]);a(d).prop("name",e.prop("name")).attr("form",e.attr("form")),e.replaceWith(d)})}),e.append(f).appendTo(document.body)},abort:function(){f&&f.unbind("load").prop("src",d),e&&e.remove()}}}}),a.ajaxSetup({converters:{"iframe text":function(b){return b&&a(b[0].body).text()},"iframe json":function(b){return b&&a.parseJSON(a(b[0].body).text())},"iframe html":function(b){return b&&a(b[0].body).html()},"iframe xml":function(b){var c=b&&b[0];return c&&a.isXMLDoc(c)?c:a.parseXML(c.XMLDocument&&c.XMLDocument.xml||a(c.body).html())},"iframe script":function(b){return b&&a.globalEval(a(b[0].body).text())}}})}); \ No newline at end of file diff --git a/pillar/web/static/assets/js/vendor/jquery.montage.min.js b/pillar/web/static/assets/js/vendor/jquery.montage.min.js new file mode 100644 index 00000000..6fbad789 --- /dev/null +++ b/pillar/web/static/assets/js/vendor/jquery.montage.min.js @@ -0,0 +1 @@ +(function(window,$,undefined){Array.max=function(array){return Math.max.apply(Math,array)};Array.min=function(array){return Math.min.apply(Math,array)};var $event=$.event,resizeTimeout;$event.special.smartresize={setup:function(){$(this).bind("resize",$event.special.smartresize.handler)},teardown:function(){$(this).unbind("resize",$event.special.smartresize.handler)},handler:function(event,execAsap){var context=this,args=arguments;event.type="smartresize";if(resizeTimeout){clearTimeout(resizeTimeout)}resizeTimeout=setTimeout(function(){jQuery.event.handle.apply(context,args)},execAsap==="execAsap"?0:50)}};$.fn.smartresize=function(fn){return fn?this.bind("smartresize",fn):this.trigger("smartresize",["execAsap"])};$.fn.imagesLoaded=function(callback){var $images=this.find('img'),len=$images.length,_this=this,blank='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';function triggerCallback(){callback.call(_this,$images)}function imgLoaded(){if(--len<=0&&this.src!==blank){setTimeout(triggerCallback);$images.unbind('load error',imgLoaded)}}if(!len){triggerCallback()}$images.bind('load error',imgLoaded).each(function(){if(this.complete||this.complete===undefined){var src=this.src;this.src=blank;this.src=src}});return this};$.Montage=function(options,element){this.element=$(element).show();this.cache={};this.heights=new Array();this._create(options)};$.Montage.defaults={liquid:true,margin:1,minw:70,minh:20,maxh:250,alternateHeight:false,alternateHeightRange:{min:100,max:300},fixedHeight:null,minsize:false,fillLastRow:false};$.Montage.prototype={_getImageWidth:function($img,h){var i_w=$img.width(),i_h=$img.height(),r_i=i_h/i_w;return Math.ceil(h/r_i)},_getImageHeight:function($img,w){var i_w=$img.width(),i_h=$img.height(),r_i=i_h/i_w;return Math.ceil(r_i*w)},_chooseHeight:function(){if(this.options.minsize){return Array.min(this.heights)}var result={},max=0,res,val,min;for(var i=0,total=this.heights.length;ithis.options.maxh)continue;result[val]=inc;if(inc>=max){max=inc;res=val}}for(var i in result){if(result[i]===max){val=i;min=min||val;if(minval)min=val;if(min===null)min=val}}if(min===undefined)min=this.heights[0];res=min;return res},_stretchImage:function($img){var prevWrapper_w=$img.parent().width(),new_w=prevWrapper_w+this.cache.space_w_left,crop={x:new_w,y:this.theHeight};var new_image_w=$img.width()+this.cache.space_w_left,new_image_h=this._getImageHeight($img,new_image_w);this._cropImage($img,new_image_w,new_image_h,crop);this.cache.space_w_left=this.cache.container_w;if(this.options.alternateHeight)this.theHeight=Math.floor(Math.random()*(this.options.alternateHeightRange.max-this.options.alternateHeightRange.min+1)+this.options.alternateHeightRange.min)},_updatePrevImage:function($nextimg){var $prevImage=this.element.find('img.montage:last');this._stretchImage($prevImage);this._insertImage($nextimg)},_insertImage:function($img){var new_w=this._getImageWidth($img,this.theHeight);if(this.options.minsize&&!this.options.alternateHeight){if(this.cache.space_w_left<=this.options.margin*2){this._updatePrevImage($img)}else{if(new_w>this.cache.space_w_left){var crop={x:this.cache.space_w_left,y:this.theHeight};this._cropImage($img,new_w,this.theHeight,crop);this.cache.space_w_left=this.cache.container_w;$img.addClass('montage')}else{var crop={x:new_w,y:this.theHeight};this._cropImage($img,new_w,this.theHeight,crop);this.cache.space_w_left-=new_w;$img.addClass('montage')}}}else{if(new_wthis.cache.space_w_left){this._updatePrevImage($img)}else{var new_w=this.options.minw,new_h=this._getImageHeight($img,new_w),crop={x:new_w,y:this.theHeight};this._cropImage($img,new_w,new_h,crop);this.cache.space_w_left-=new_w;$img.addClass('montage')}}else{if(new_w>this.cache.space_w_left&&this.cache.space_w_leftthis.cache.space_w_left&&this.cache.space_w_left>=this.options.minw){var crop={x:this.cache.space_w_left,y:this.theHeight};this._cropImage($img,new_w,this.theHeight,crop);this.cache.space_w_left=this.cache.container_w;if(this.options.alternateHeight)this.theHeight=Math.floor(Math.random()*(this.options.alternateHeightRange.max-this.options.alternateHeightRange.min+1)+this.options.alternateHeightRange.min);$img.addClass('montage')}else{var crop={x:new_w,y:this.theHeight};this._cropImage($img,new_w,this.theHeight,crop);this.cache.space_w_left-=new_w;$img.addClass('montage')}}}},_cropImage:function($img,w,h,cropParam){var dec=this.options.margin*2;var $wrapper=$img.parent('a');this._resizeImage($img,w,h);$img.css({left:-(w-cropParam.x)/2+'px',top:-(h-cropParam.y)/2+'px'});$wrapper.addClass('am-wrapper').css({width:cropParam.x-dec+'px',height:cropParam.y+'px',margin:this.options.margin})},_resizeImage:function($img,w,h){$img.css({width:w+'px',height:h+'px'})},_reload:function(){var new_el_w=this.element.width();if(new_el_w!==this.cache.container_w){this.element.hide();this.cache.container_w=new_el_w;this.cache.space_w_left=new_el_w;var instance=this;instance.$imgs.removeClass('montage').each(function(i){instance._insertImage($(this))});if(instance.options.fillLastRow&&instance.cache.space_w_left!==instance.cache.container_w){instance._stretchImage(instance.$imgs.eq(instance.totalImages-1))}instance.element.show()}},_create:function(options){this.options=$.extend(true,{},$.Montage.defaults,options);var instance=this,el_w=instance.element.width();instance.$imgs=instance.element.find('img');instance.totalImages=instance.$imgs.length;if(instance.options.liquid)$('html').css('overflow-y','scroll');if(!instance.options.fixedHeight){instance.$imgs.each(function(i){var $img=$(this),img_w=$img.width();if(img_w0&&(e.splice(u-1,2),u-=2)}e=e.join("/")}else 0===e.indexOf("./")&&(e=e.substring(2));if((h||g)&&f){for(n=e.split("/"),u=n.length;u>0;u-=1){if(r=n.slice(0,u).join("/"),h)for(d=h.length;d>0;d-=1)if(i=f[h.slice(0,d).join("/")],i&&(i=i[r])){o=i,a=u;break}if(o)break;!l&&g&&g[r]&&(l=g[r],c=u)}!o&&l&&(o=l,a=c),o&&(n.splice(0,a,o),e=n.join("/"))}return e}function s(e,n){return function(){var r=w.call(arguments,0);return"string"!=typeof r[0]&&1===r.length&&r.push(null),h.apply(t,r.concat([e,n]))}}function a(e){return function(t){return o(t,e)}}function l(e){return function(t){m[e]=t}}function c(e){if(i(v,e)){var n=v[e];delete v[e],_[e]=!0,p.apply(t,n)}if(!i(m,e)&&!i(_,e))throw new Error("No "+e);return m[e]}function u(e){var t,n=e?e.indexOf("!"):-1;return n>-1&&(t=e.substring(0,n),e=e.substring(n+1,e.length)),[t,e]}function d(e){return function(){return y&&y.config&&y.config[e]||{}}}var p,h,f,g,m={},v={},y={},_={},$=Object.prototype.hasOwnProperty,w=[].slice,b=/\.js$/;f=function(e,t){var n,r=u(e),i=r[0];return e=r[1],i&&(i=o(i,t),n=c(i)),i?e=n&&n.normalize?n.normalize(e,a(t)):o(e,t):(e=o(e,t),r=u(e),i=r[0],e=r[1],i&&(n=c(i))),{f:i?i+"!"+e:e,n:e,pr:i,p:n}},g={require:function(e){return s(e)},exports:function(e){var t=m[e];return"undefined"!=typeof t?t:m[e]={}},module:function(e){return{id:e,uri:"",exports:m[e],config:d(e)}}},p=function(e,n,r,o){var a,u,d,p,h,y,$=[],w=typeof r;if(o=o||e,"undefined"===w||"function"===w){for(n=!n.length&&r.length?["require","exports","module"]:n,h=0;h0&&(t.call(arguments,e.prototype.constructor),i=n.prototype.constructor),i.apply(this,arguments)}function i(){this.constructor=r}var o=t(n),s=t(e);n.displayName=e.displayName,r.prototype=new i;for(var a=0;a":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},n.appendMany=function(t,n){if("1.7"===e.fn.jquery.substr(0,3)){var r=e();e.map(n,function(e){r=r.add(e)}),n=r}t.append(n)},n}),t.define("select2/results",["jquery","./utils"],function(e,t){function n(e,t,r){this.$element=e,this.data=r,this.options=t,n.__super__.constructor.call(this)}return t.Extend(n,t.Observable),n.prototype.render=function(){var t=e('
    ');return this.options.get("multiple")&&t.attr("aria-multiselectable","true"),this.$results=t,t},n.prototype.clear=function(){this.$results.empty()},n.prototype.displayMessage=function(t){var n=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var r=e('
  • '),i=this.options.get("translations").get(t.message);r.append(n(i(t.args))),r[0].className+=" select2-results__message",this.$results.append(r)},n.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},n.prototype.append=function(e){this.hideLoading();var t=[];if(null==e.results||0===e.results.length)return void(0===this.$results.children().length&&this.trigger("results:message",{message:"noResults"}));e.results=this.sort(e.results);for(var n=0;n-1?t.attr("aria-selected","true"):t.attr("aria-selected","false")});var o=i.filter("[aria-selected=true]");o.length>0?o.first().trigger("mouseenter"):i.first().trigger("mouseenter")})},n.prototype.showLoading=function(e){this.hideLoading();var t=this.options.get("translations").get("searching"),n={disabled:!0,loading:!0,text:t(e)},r=this.option(n);r.className+=" loading-results",this.$results.prepend(r)},n.prototype.hideLoading=function(){this.$results.find(".loading-results").remove()},n.prototype.option=function(t){var n=document.createElement("li");n.className="select2-results__option";var r={role:"treeitem","aria-selected":"false"};t.disabled&&(delete r["aria-selected"],r["aria-disabled"]="true"),null==t.id&&delete r["aria-selected"],null!=t._resultId&&(n.id=t._resultId),t.title&&(n.title=t.title),t.children&&(r.role="group",r["aria-label"]=t.text,delete r["aria-selected"]);for(var i in r){var o=r[i];n.setAttribute(i,o)}if(t.children){var s=e(n),a=document.createElement("strong");a.className="select2-results__group";e(a);this.template(t,a);for(var l=[],c=0;c",{"class":"select2-results__options select2-results__options--nested"});p.append(l),s.append(a),s.append(p)}else this.template(t,n);return e.data(n,"data",t),n},n.prototype.bind=function(t,n){var r=this,i=t.id+"-results";this.$results.attr("id",i),t.on("results:all",function(e){r.clear(),r.append(e.data),t.isOpen()&&r.setClasses()}),t.on("results:append",function(e){r.append(e.data),t.isOpen()&&r.setClasses()}),t.on("query",function(e){r.hideMessages(),r.showLoading(e)}),t.on("select",function(){t.isOpen()&&r.setClasses()}),t.on("unselect",function(){t.isOpen()&&r.setClasses()}),t.on("open",function(){r.$results.attr("aria-expanded","true"),r.$results.attr("aria-hidden","false"),r.setClasses(),r.ensureHighlightVisible()}),t.on("close",function(){r.$results.attr("aria-expanded","false"),r.$results.attr("aria-hidden","true"),r.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=r.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e=r.getHighlightedResults();if(0!==e.length){var t=e.data("data");"true"==e.attr("aria-selected")?r.trigger("close",{}):r.trigger("select",{data:t})}}),t.on("results:previous",function(){var e=r.getHighlightedResults(),t=r.$results.find("[aria-selected]"),n=t.index(e);if(0!==n){var i=n-1;0===e.length&&(i=0);var o=t.eq(i);o.trigger("mouseenter");var s=r.$results.offset().top,a=o.offset().top,l=r.$results.scrollTop()+(a-s);0===i?r.$results.scrollTop(0):a-s<0&&r.$results.scrollTop(l)}}),t.on("results:next",function(){var e=r.getHighlightedResults(),t=r.$results.find("[aria-selected]"),n=t.index(e),i=n+1;if(!(i>=t.length)){var o=t.eq(i);o.trigger("mouseenter");var s=r.$results.offset().top+r.$results.outerHeight(!1),a=o.offset().top+o.outerHeight(!1),l=r.$results.scrollTop()+a-s;0===i?r.$results.scrollTop(0):a>s&&r.$results.scrollTop(l)}}),t.on("results:focus",function(e){e.element.addClass("select2-results__option--highlighted")}),t.on("results:message",function(e){r.displayMessage(e)}),e.fn.mousewheel&&this.$results.on("mousewheel",function(e){var t=r.$results.scrollTop(),n=r.$results.get(0).scrollHeight-r.$results.scrollTop()+e.deltaY,i=e.deltaY>0&&t-e.deltaY<=0,o=e.deltaY<0&&n<=r.$results.height();i?(r.$results.scrollTop(0),e.preventDefault(),e.stopPropagation()):o&&(r.$results.scrollTop(r.$results.get(0).scrollHeight-r.$results.height()),e.preventDefault(),e.stopPropagation())}),this.$results.on("mouseup",".select2-results__option[aria-selected]",function(t){var n=e(this),i=n.data("data");return"true"===n.attr("aria-selected")?void(r.options.get("multiple")?r.trigger("unselect",{originalEvent:t,data:i}):r.trigger("close",{})):void r.trigger("select",{originalEvent:t,data:i})}),this.$results.on("mouseenter",".select2-results__option[aria-selected]",function(t){var n=e(this).data("data");r.getHighlightedResults().removeClass("select2-results__option--highlighted"),r.trigger("results:focus",{data:n,element:e(this)})})},n.prototype.getHighlightedResults=function(){var e=this.$results.find(".select2-results__option--highlighted");return e},n.prototype.destroy=function(){this.$results.remove()},n.prototype.ensureHighlightVisible=function(){var e=this.getHighlightedResults();if(0!==e.length){var t=this.$results.find("[aria-selected]"),n=t.index(e),r=this.$results.offset().top,i=e.offset().top,o=this.$results.scrollTop()+(i-r),s=i-r;o-=2*e.outerHeight(!1),n<=2?this.$results.scrollTop(0):(s>this.$results.outerHeight()||s<0)&&this.$results.scrollTop(o)}},n.prototype.template=function(t,n){var r=this.options.get("templateResult"),i=this.options.get("escapeMarkup"),o=r(t,n);null==o?n.style.display="none":"string"==typeof o?n.innerHTML=i(o):e(n).append(o)},n}),t.define("select2/keys",[],function(){var e={BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46};return e}),t.define("select2/selection/base",["jquery","../utils","../keys"],function(e,t,n){function r(e,t){this.$element=e,this.options=t,r.__super__.constructor.call(this)}return t.Extend(r,t.Observable),r.prototype.render=function(){var t=e('');return this._tabindex=0,null!=this.$element.data("old-tabindex")?this._tabindex=this.$element.data("old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),t.attr("title",this.$element.attr("title")),t.attr("tabindex",this._tabindex),this.$selection=t,t},r.prototype.bind=function(e,t){var r=this,i=(e.id+"-container",e.id+"-results");this.container=e,this.$selection.on("focus",function(e){r.trigger("focus",e)}),this.$selection.on("blur",function(e){r._handleBlur(e)}),this.$selection.on("keydown",function(e){r.trigger("keypress",e),e.which===n.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){r.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){r.update(e.data)}),e.on("open",function(){r.$selection.attr("aria-expanded","true"),r.$selection.attr("aria-owns",i),r._attachCloseHandler(e)}),e.on("close",function(){r.$selection.attr("aria-expanded","false"),r.$selection.removeAttr("aria-activedescendant"),r.$selection.removeAttr("aria-owns"),r.$selection.focus(),r._detachCloseHandler(e)}),e.on("enable",function(){r.$selection.attr("tabindex",r._tabindex)}),e.on("disable",function(){r.$selection.attr("tabindex","-1")})},r.prototype._handleBlur=function(t){var n=this;window.setTimeout(function(){document.activeElement==n.$selection[0]||e.contains(n.$selection[0],document.activeElement)||n.trigger("blur",t)},1)},r.prototype._attachCloseHandler=function(t){e(document.body).on("mousedown.select2."+t.id,function(t){var n=e(t.target),r=n.closest(".select2"),i=e(".select2.select2-container--open");i.each(function(){var t=e(this);if(this!=r[0]){var n=t.data("element");n.select2("close")}})})},r.prototype._detachCloseHandler=function(t){e(document.body).off("mousedown.select2."+t.id)},r.prototype.position=function(e,t){var n=t.find(".selection");n.append(e)},r.prototype.destroy=function(){this._detachCloseHandler(this.container)},r.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},r}),t.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,r){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e.addClass("select2-selection--single"),e.html(''),e},i.prototype.bind=function(e,t){var n=this;i.__super__.bind.apply(this,arguments);var r=e.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",r),this.$selection.attr("aria-labelledby",r),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),e.on("selection:update",function(e){n.update(e.data)})},i.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},i.prototype.display=function(e,t){var n=this.options.get("templateSelection"),r=this.options.get("escapeMarkup");return r(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){if(0===e.length)return void this.clear();var t=e[0],n=this.$selection.find(".select2-selection__rendered"),r=this.display(t,n);n.empty().append(r),n.prop("title",t.title||t.text)},i}),t.define("select2/selection/multiple",["jquery","./base","../utils"],function(e,t,n){function r(e,t){r.__super__.constructor.apply(this,arguments)}return n.Extend(r,t),r.prototype.render=function(){var e=r.__super__.render.call(this);return e.addClass("select2-selection--multiple"),e.html('
      '),e},r.prototype.bind=function(t,n){var i=this;r.__super__.bind.apply(this,arguments),this.$selection.on("click",function(e){i.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(t){if(!i.options.get("disabled")){var n=e(this),r=n.parent(),o=r.data("data");i.trigger("unselect",{originalEvent:t,data:o})}})},r.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},r.prototype.display=function(e,t){var n=this.options.get("templateSelection"),r=this.options.get("escapeMarkup");return r(n(e,t))},r.prototype.selectionContainer=function(){var t=e('
    • ×
    • ');return t},r.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],r=0;r1;if(r||n)return e.call(this,t);this.clear();var i=this.createPlaceholder(this.placeholder);this.$selection.find(".select2-selection__rendered").append(i)},t}),t.define("select2/selection/allowClear",["jquery","../keys"],function(e,t){function n(){}return n.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),null==this.placeholder&&this.options.get("debug")&&window.console&&console.error&&console.error("Select2: The `allowClear` option should be used in combination with the `placeholder` option."),this.$selection.on("mousedown",".select2-selection__clear",function(e){r._handleClear(e)}),t.on("keypress",function(e){r._handleKeyboardClear(e,t)})},n.prototype._handleClear=function(e,t){if(!this.options.get("disabled")){var n=this.$selection.find(".select2-selection__clear");if(0!==n.length){t.stopPropagation();for(var r=n.data("data"),i=0;i0||0===n.length)){var r=e('×');r.data("data",n),this.$selection.find(".select2-selection__rendered").prepend(r)}},n}),t.define("select2/selection/search",["jquery","../utils","../keys"],function(e,t,n){function r(e,t,n){e.call(this,t,n)}return r.prototype.render=function(t){var n=e('');this.$searchContainer=n,this.$search=n.find("input");var r=t.call(this);return this._transferTabIndex(),r},r.prototype.bind=function(e,t,r){var i=this;e.call(this,t,r),t.on("open",function(){i.$search.trigger("focus")}),t.on("close",function(){i.$search.val(""),i.$search.removeAttr("aria-activedescendant"),i.$search.trigger("focus")}),t.on("enable",function(){i.$search.prop("disabled",!1),i._transferTabIndex()}),t.on("disable",function(){i.$search.prop("disabled",!0)}),t.on("focus",function(e){i.$search.trigger("focus")}),t.on("results:focus",function(e){i.$search.attr("aria-activedescendant",e.id)}),this.$selection.on("focusin",".select2-search--inline",function(e){i.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){i._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){e.stopPropagation(),i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented();var t=e.which;if(t===n.BACKSPACE&&""===i.$search.val()){var r=i.$searchContainer.prev(".select2-selection__choice");if(r.length>0){var o=r.data("data");i.searchRemoveChoice(o),e.preventDefault()}}});var o=document.documentMode,s=o&&o<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(e){return s?void i.$selection.off("input.search input.searchcheck"):void i.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(e){if(s&&"input"===e.type)return void i.$selection.off("input.search input.searchcheck");var t=e.which;t!=n.SHIFT&&t!=n.CTRL&&t!=n.ALT&&t!=n.TAB&&i.handleSearch(e)})},r.prototype._transferTabIndex=function(e){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},r.prototype.createPlaceholder=function(e,t){this.$search.attr("placeholder",t.text)},r.prototype.update=function(e,t){var n=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),e.call(this,t),this.$selection.find(".select2-selection__rendered").append(this.$searchContainer),this.resizeSearch(),n&&this.$search.focus()},r.prototype.handleSearch=function(){if(this.resizeSearch(),!this._keyUpPrevented){var e=this.$search.val();this.trigger("query",{term:e})}this._keyUpPrevented=!1},r.prototype.searchRemoveChoice=function(e,t){this.trigger("unselect",{data:t}),this.$search.val(t.text),this.handleSearch()},r.prototype.resizeSearch=function(){this.$search.css("width","25px");var e="";if(""!==this.$search.attr("placeholder"))e=this.$selection.find(".select2-selection__rendered").innerWidth();else{var t=this.$search.val().length+1;e=.75*t+"em"}this.$search.css("width",e)},r}),t.define("select2/selection/eventRelay",["jquery"],function(e){function t(){}return t.prototype.bind=function(t,n,r){var i=this,o=["open","opening","close","closing","select","selecting","unselect","unselecting"],s=["opening","closing","selecting","unselecting"];t.call(this,n,r),n.on("*",function(t,n){if(e.inArray(t,o)!==-1){n=n||{};var r=e.Event("select2:"+t,{params:n});i.$element.trigger(r),e.inArray(t,s)!==-1&&(n.prevented=r.isDefaultPrevented())}})},t}),t.define("select2/translation",["jquery","require"],function(e,t){function n(e){this.dict=e||{}}return n.prototype.all=function(){return this.dict},n.prototype.get=function(e){return this.dict[e]},n.prototype.extend=function(t){this.dict=e.extend({},t.all(),this.dict)},n._cache={},n.loadPath=function(e){if(!(e in n._cache)){var r=t(e);n._cache[e]=r}return new n(n._cache[e])},n}),t.define("select2/diacritics",[],function(){var e={"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ω":"ω","ς":"σ"};return e}),t.define("select2/data/base",["../utils"],function(e){function t(e,n){t.__super__.constructor.call(this)}return e.Extend(t,e.Observable),t.prototype.current=function(e){throw new Error("The `current` method must be defined in child classes.")},t.prototype.query=function(e,t){throw new Error("The `query` method must be defined in child classes.")},t.prototype.bind=function(e,t){},t.prototype.destroy=function(){},t.prototype.generateResultId=function(t,n){var r=t.id+"-result-";return r+=e.generateChars(4),r+=null!=n.id?"-"+n.id.toString():"-"+e.generateChars(4)},t}),t.define("select2/data/select",["./base","../utils","jquery"],function(e,t,n){function r(e,t){this.$element=e,this.options=t,r.__super__.constructor.call(this)}return t.Extend(r,e),r.prototype.current=function(e){var t=[],r=this;this.$element.find(":selected").each(function(){var e=n(this),i=r.item(e);t.push(i)}),e(t)},r.prototype.select=function(e){var t=this;if(e.selected=!0,n(e.element).is("option"))return e.element.selected=!0,void this.$element.trigger("change");if(this.$element.prop("multiple"))this.current(function(r){var i=[];e=[e],e.push.apply(e,r);for(var o=0;o=0){var u=o.filter(r(c)),d=this.item(u),p=n.extend(!0,{},d,c),h=this.option(p);u.replaceWith(h)}else{var f=this.option(c);if(c.children){var g=this.convertToOptions(c.children);t.appendMany(f,g)}a.push(f)}}return a},r}),t.define("select2/data/ajax",["./array","../utils","jquery"],function(e,t,n){function r(e,t){this.ajaxOptions=this._applyDefaults(t.get("ajax")),null!=this.ajaxOptions.processResults&&(this.processResults=this.ajaxOptions.processResults),r.__super__.constructor.call(this,e,t)}return t.Extend(r,e),r.prototype._applyDefaults=function(e){var t={data:function(e){return n.extend({},e,{q:e.term})},transport:function(e,t,r){var i=n.ajax(e);return i.then(t),i.fail(r),i}};return n.extend({},t,e,!0)},r.prototype.processResults=function(e){return e},r.prototype.query=function(e,t){function r(){var r=o.transport(o,function(r){var o=i.processResults(r,e);i.options.get("debug")&&window.console&&console.error&&(o&&o.results&&n.isArray(o.results)||console.error("Select2: The AJAX results did not return an array in the `results` key of the response.")),t(o)},function(){});i._request=r}var i=this;null!=this._request&&(n.isFunction(this._request.abort)&&this._request.abort(),this._request=null);var o=n.extend({type:"GET"},this.ajaxOptions);"function"==typeof o.url&&(o.url=o.url.call(this.$element,e)),"function"==typeof o.data&&(o.data=o.data.call(this.$element,e)),this.ajaxOptions.delay&&""!==e.term?(this._queryTimeout&&window.clearTimeout(this._queryTimeout),this._queryTimeout=window.setTimeout(r,this.ajaxOptions.delay)):r()},r}),t.define("select2/data/tags",["jquery"],function(e){function t(t,n,r){var i=r.get("tags"),o=r.get("createTag");if(void 0!==o&&(this.createTag=o),t.call(this,n,r),e.isArray(i))for(var s=0;s0&&t.term.length>this.maximumInputLength?void this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):void e.call(this,t,n)},e}),t.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.query=function(e,t,n){var r=this;this.current(function(i){var o=null!=i?i.length:0;return r.maximumSelectionLength>0&&o>=r.maximumSelectionLength?void r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):void e.call(r,t,n)})},e}),t.define("select2/dropdown",["jquery","./utils"],function(e,t){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return t.Extend(n,t.Observable),n.prototype.render=function(){var t=e('');return t.attr("dir",this.options.get("dir")),this.$dropdown=t,t},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),t.define("select2/dropdown/search",["jquery","../utils"],function(e,t){function n(){}return n.prototype.render=function(t){var n=t.call(this),r=e('');return this.$searchContainer=r,this.$search=r.find("input"),n.prepend(r),n},n.prototype.bind=function(t,n,r){var i=this;t.call(this,n,r),this.$search.on("keydown",function(e){i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(t){e(this).off("keyup")}),this.$search.on("keyup input",function(e){i.handleSearch(e)}),n.on("open",function(){i.$search.attr("tabindex",0),i.$search.focus(),window.setTimeout(function(){i.$search.focus()},0)}),n.on("close",function(){i.$search.attr("tabindex",-1),i.$search.val("")}),n.on("results:all",function(e){if(null==e.query.term||""===e.query.term){var t=i.showSearch(e);t?i.$searchContainer.removeClass("select2-search--hide"):i.$searchContainer.addClass("select2-search--hide")}})},n.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},n.prototype.showSearch=function(e,t){return!0},n}),t.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;r>=0;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),t.define("select2/dropdown/infiniteScroll",["jquery"],function(e){function t(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return t.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&this.$results.append(this.$loadingMore)},t.prototype.bind=function(t,n,r){var i=this;t.call(this,n,r),n.on("query",function(e){i.lastParams=e,i.loading=!0}),n.on("query:append",function(e){i.lastParams=e,i.loading=!0}),this.$results.on("scroll",function(){var t=e.contains(document.documentElement,i.$loadingMore[0]);if(!i.loading&&t){var n=i.$results.offset().top+i.$results.outerHeight(!1),r=i.$loadingMore.offset().top+i.$loadingMore.outerHeight(!1);n+50>=r&&i.loadMore()}})},t.prototype.loadMore=function(){this.loading=!0;var t=e.extend({},{page:1},this.lastParams);t.page++,this.trigger("query:append",t)},t.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},t.prototype.createLoadingMore=function(){var t=e('
    • '),n=this.options.get("translations").get("loadingMore");return t.html(n(this.lastParams)),t},t}),t.define("select2/dropdown/attachBody",["jquery","../utils"],function(e,t){function n(t,n,r){this.$dropdownParent=r.get("dropdownParent")||e(document.body),t.call(this,n,r)}return n.prototype.bind=function(e,t,n){var r=this,i=!1;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),i||(i=!0,t.on("results:all",function(){r._positionDropdown(),r._resizeDropdown()}),t.on("results:append",function(){r._positionDropdown(),r._resizeDropdown()}))}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},n.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},n.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},n.prototype.render=function(t){var n=e(""),r=t.call(this);return n.append(r),this.$dropdownContainer=n,n},n.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},n.prototype._attachPositioningHandler=function(n,r){var i=this,o="scroll.select2."+r.id,s="resize.select2."+r.id,a="orientationchange.select2."+r.id,l=this.$container.parents().filter(t.hasScroll);l.each(function(){e(this).data("select2-scroll-position",{x:e(this).scrollLeft(),y:e(this).scrollTop()})}),l.on(o,function(t){var n=e(this).data("select2-scroll-position");e(this).scrollTop(n.y)}),e(window).on(o+" "+s+" "+a,function(e){i._positionDropdown(),i._resizeDropdown()})},n.prototype._detachPositioningHandler=function(n,r){var i="scroll.select2."+r.id,o="resize.select2."+r.id,s="orientationchange.select2."+r.id,a=this.$container.parents().filter(t.hasScroll);a.off(i),e(window).off(i+" "+o+" "+s)},n.prototype._positionDropdown=function(){var t=e(window),n=this.$dropdown.hasClass("select2-dropdown--above"),r=this.$dropdown.hasClass("select2-dropdown--below"),i=null,o=(this.$container.position(),this.$container.offset());o.bottom=o.top+this.$container.outerHeight(!1);var s={height:this.$container.outerHeight(!1)};s.top=o.top,s.bottom=o.top+s.height;var a={height:this.$dropdown.outerHeight(!1)},l={top:t.scrollTop(),bottom:t.scrollTop()+t.height()},c=l.topo.bottom+a.height,d={left:o.left,top:s.bottom};if("static"!==this.$dropdownParent[0].style.position){var p=this.$dropdownParent.offset();d.top-=p.top,d.left-=p.left}n||r||(i="below"),u||!c||n?!c&&u&&n&&(i="below"):i="above",("above"==i||n&&"below"!==i)&&(d.top=s.top-a.height),null!=i&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+i),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+i)),this.$dropdownContainer.css(d)},n.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.width="auto"),this.$dropdown.css(e)},n.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},n}),t.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(t){for(var n=0,r=0;r0&&(d.dataAdapter=c.Decorate(d.dataAdapter,v)),d.maximumInputLength>0&&(d.dataAdapter=c.Decorate(d.dataAdapter,y)),d.maximumSelectionLength>0&&(d.dataAdapter=c.Decorate(d.dataAdapter,_)),d.tags&&(d.dataAdapter=c.Decorate(d.dataAdapter,g)),null==d.tokenSeparators&&null==d.tokenizer||(d.dataAdapter=c.Decorate(d.dataAdapter,m)),null!=d.query){var S=t(d.amdBase+"compat/query");d.dataAdapter=c.Decorate(d.dataAdapter,S)}if(null!=d.initSelection){var T=t(d.amdBase+"compat/initSelection");d.dataAdapter=c.Decorate(d.dataAdapter,T)}}if(null==d.resultsAdapter&&(d.resultsAdapter=n,null!=d.ajax&&(d.resultsAdapter=c.Decorate(d.resultsAdapter,x)),null!=d.placeholder&&(d.resultsAdapter=c.Decorate(d.resultsAdapter,b)),d.selectOnClose&&(d.resultsAdapter=c.Decorate(d.resultsAdapter,E))),null==d.dropdownAdapter){if(d.multiple)d.dropdownAdapter=$;else{var D=c.Decorate($,w);d.dropdownAdapter=D}if(0!==d.minimumResultsForSearch&&(d.dropdownAdapter=c.Decorate(d.dropdownAdapter,C)),d.closeOnSelect&&(d.dropdownAdapter=c.Decorate(d.dropdownAdapter,O)),null!=d.dropdownCssClass||null!=d.dropdownCss||null!=d.adaptDropdownCssClass){var q=t(d.amdBase+"compat/dropdownCss");d.dropdownAdapter=c.Decorate(d.dropdownAdapter,q)}d.dropdownAdapter=c.Decorate(d.dropdownAdapter,A)}if(null==d.selectionAdapter){if(d.multiple?d.selectionAdapter=i:d.selectionAdapter=r,null!=d.placeholder&&(d.selectionAdapter=c.Decorate(d.selectionAdapter,o)),d.allowClear&&(d.selectionAdapter=c.Decorate(d.selectionAdapter,s)),d.multiple&&(d.selectionAdapter=c.Decorate(d.selectionAdapter,a)),null!=d.containerCssClass||null!=d.containerCss||null!=d.adaptContainerCssClass){var j=t(d.amdBase+"compat/containerCss");d.selectionAdapter=c.Decorate(d.selectionAdapter,j)}d.selectionAdapter=c.Decorate(d.selectionAdapter,l)}if("string"==typeof d.language)if(d.language.indexOf("-")>0){var L=d.language.split("-"),P=L[0];d.language=[d.language,P]}else d.language=[d.language];if(e.isArray(d.language)){var k=new u;d.language.push("en");for(var I=d.language,R=0;R0){for(var o=e.extend(!0,{},i),s=i.children.length-1;s>=0;s--){var a=i.children[s],l=n(r,a);null==l&&o.children.splice(s,1)}return o.children.length>0?o:n(r,o)}var c=t(i.text).toUpperCase(),u=t(r.term).toUpperCase();return c.indexOf(u)>-1?i:null}this.defaults={amdBase:"./",amdLanguageBase:"./i18n/",closeOnSelect:!0,debug:!1,dropdownAutoWidth:!1,escapeMarkup:c.escapeMarkup,language:S,matcher:n,minimumInputLength:0,maximumInputLength:0,maximumSelectionLength:0,minimumResultsForSearch:0,selectOnClose:!1,sorter:function(e){return e},templateResult:function(e){return e.text},templateSelection:function(e){return e.text},theme:"default",width:"resolve"}},T.prototype.set=function(t,n){var r=e.camelCase(t),i={};i[r]=n;var o=c._convertData(i);e.extend(this.defaults,o)};var D=new T;return D}),t.define("select2/options",["require","jquery","./defaults","./utils"],function(e,t,n,r){function i(t,i){if(this.options=t,null!=i&&this.fromElement(i),this.options=n.apply(this.options),i&&i.is("input")){var o=e(this.get("amdBase")+"compat/inputData");this.options.dataAdapter=r.Decorate(this.options.dataAdapter,o)}}return i.prototype.fromElement=function(e){var n=["select2"];null==this.options.multiple&&(this.options.multiple=e.prop("multiple")),null==this.options.disabled&&(this.options.disabled=e.prop("disabled")),null==this.options.language&&(e.prop("lang")?this.options.language=e.prop("lang").toLowerCase():e.closest("[lang]").prop("lang")&&(this.options.language=e.closest("[lang]").prop("lang"))),null==this.options.dir&&(e.prop("dir")?this.options.dir=e.prop("dir"):e.closest("[dir]").prop("dir")?this.options.dir=e.closest("[dir]").prop("dir"):this.options.dir="ltr"),e.prop("disabled",this.options.disabled),e.prop("multiple",this.options.multiple),e.data("select2Tags")&&(this.options.debug&&window.console&&console.warn&&console.warn('Select2: The `data-select2-tags` attribute has been changed to use the `data-data` and `data-tags="true"` attributes and will be removed in future versions of Select2.'),e.data("data",e.data("select2Tags")),e.data("tags",!0)),e.data("ajaxUrl")&&(this.options.debug&&window.console&&console.warn&&console.warn("Select2: The `data-ajax-url` attribute has been changed to `data-ajax--url` and support for the old attribute will be removed in future versions of Select2."),e.attr("ajax--url",e.data("ajaxUrl")),e.data("ajax--url",e.data("ajaxUrl")));var i={};i=t.fn.jquery&&"1."==t.fn.jquery.substr(0,2)&&e[0].dataset?t.extend(!0,{},e[0].dataset,e.data()):e.data();var o=t.extend(!0,{},i);o=r._convertData(o);for(var s in o)t.inArray(s,n)>-1||(t.isPlainObject(this.options[s])?t.extend(this.options[s],o[s]):this.options[s]=o[s]);return this},i.prototype.get=function(e){return this.options[e]},i.prototype.set=function(e,t){this.options[e]=t},i}),t.define("select2/core",["jquery","./options","./utils","./keys"],function(e,t,n,r){var i=function(e,n){null!=e.data("select2")&&e.data("select2").destroy(),this.$element=e,this.id=this._generateId(e),n=n||{},this.options=new t(n,e),i.__super__.constructor.call(this);var r=e.attr("tabindex")||0;e.data("old-tabindex",r),e.attr("tabindex","-1");var o=this.options.get("dataAdapter");this.dataAdapter=new o(e,this.options);var s=this.render();this._placeContainer(s);var a=this.options.get("selectionAdapter");this.selection=new a(e,this.options),this.$selection=this.selection.render(),this.selection.position(this.$selection,s);var l=this.options.get("dropdownAdapter");this.dropdown=new l(e,this.options),this.$dropdown=this.dropdown.render(),this.dropdown.position(this.$dropdown,s);var c=this.options.get("resultsAdapter");this.results=new c(e,this.options,this.dataAdapter),this.$results=this.results.render(),this.results.position(this.$results,this.$dropdown);var u=this;this._bindAdapters(),this._registerDomEvents(),this._registerDataEvents(),this._registerSelectionEvents(),this._registerDropdownEvents(),this._registerResultsEvents(),this._registerEvents(),this.dataAdapter.current(function(e){u.trigger("selection:update",{data:e})}),e.addClass("select2-hidden-accessible"),e.attr("aria-hidden","true"),this._syncAttributes(),e.data("select2",this)};return n.Extend(i,n.Observable),i.prototype._generateId=function(e){var t="";return t=null!=e.attr("id")?e.attr("id"):null!=e.attr("name")?e.attr("name")+"-"+n.generateChars(2):n.generateChars(4),t="select2-"+t},i.prototype._placeContainer=function(e){e.insertAfter(this.$element);var t=this._resolveWidth(this.$element,this.options.get("width"));null!=t&&e.css("width",t)},i.prototype._resolveWidth=function(e,t){var n=/^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i;if("resolve"==t){var r=this._resolveWidth(e,"style");return null!=r?r:this._resolveWidth(e,"element")}if("element"==t){var i=e.outerWidth(!1);return i<=0?"auto":i+"px"}if("style"==t){var o=e.attr("style");if("string"!=typeof o)return null;for(var s=o.split(";"),a=0,l=s.length;a=1)return u[1]}return null}return t},i.prototype._bindAdapters=function(){this.dataAdapter.bind(this,this.$container),this.selection.bind(this,this.$container),this.dropdown.bind(this,this.$container),this.results.bind(this,this.$container)},i.prototype._registerDomEvents=function(){var t=this;this.$element.on("change.select2",function(){t.dataAdapter.current(function(e){t.trigger("selection:update",{data:e})})}),this._sync=n.bind(this._syncAttributes,this),this.$element[0].attachEvent&&this.$element[0].attachEvent("onpropertychange",this._sync);var r=window.MutationObserver||window.WebKitMutationObserver||window.MozMutationObserver;null!=r?(this._observer=new r(function(n){e.each(n,t._sync)}),this._observer.observe(this.$element[0],{attributes:!0,subtree:!1})):this.$element[0].addEventListener&&this.$element[0].addEventListener("DOMAttrModified",t._sync,!1)},i.prototype._registerDataEvents=function(){var e=this;this.dataAdapter.on("*",function(t,n){e.trigger(t,n)})},i.prototype._registerSelectionEvents=function(){var t=this,n=["toggle","focus"];this.selection.on("toggle",function(){t.toggleDropdown()}),this.selection.on("focus",function(e){t.focus(e)}),this.selection.on("*",function(r,i){e.inArray(r,n)===-1&&t.trigger(r,i)})},i.prototype._registerDropdownEvents=function(){var e=this;this.dropdown.on("*",function(t,n){e.trigger(t,n)})},i.prototype._registerResultsEvents=function(){var e=this;this.results.on("*",function(t,n){e.trigger(t,n)})},i.prototype._registerEvents=function(){var e=this;this.on("open",function(){e.$container.addClass("select2-container--open")}),this.on("close",function(){e.$container.removeClass("select2-container--open")}),this.on("enable",function(){e.$container.removeClass("select2-container--disabled")}),this.on("disable",function(){e.$container.addClass("select2-container--disabled")}),this.on("blur",function(){e.$container.removeClass("select2-container--focus")}),this.on("query",function(t){e.isOpen()||e.trigger("open",{}),this.dataAdapter.query(t,function(n){e.trigger("results:all",{data:n,query:t})})}),this.on("query:append",function(t){this.dataAdapter.query(t,function(n){e.trigger("results:append",{data:n,query:t})})}),this.on("keypress",function(t){var n=t.which;e.isOpen()?n===r.ESC||n===r.TAB||n===r.UP&&t.altKey?(e.close(),t.preventDefault()):n===r.ENTER?(e.trigger("results:select",{}),t.preventDefault()):n===r.SPACE&&t.ctrlKey?(e.trigger("results:toggle",{}),t.preventDefault()):n===r.UP?(e.trigger("results:previous",{}),t.preventDefault()):n===r.DOWN&&(e.trigger("results:next",{}),t.preventDefault()):(n===r.ENTER||n===r.SPACE||n===r.DOWN&&t.altKey)&&(e.open(),t.preventDefault())})},i.prototype._syncAttributes=function(){this.options.set("disabled",this.$element.prop("disabled")),this.options.get("disabled")?(this.isOpen()&&this.close(),this.trigger("disable",{})):this.trigger("enable",{})},i.prototype.trigger=function(e,t){var n=i.__super__.trigger,r={open:"opening",close:"closing",select:"selecting",unselect:"unselecting"};if(void 0===t&&(t={}),e in r){var o=r[e],s={prevented:!1,name:e,args:t};if(n.call(this,o,s),s.prevented)return void(t.prevented=!0)}n.call(this,e,t)},i.prototype.toggleDropdown=function(){this.options.get("disabled")||(this.isOpen()?this.close():this.open())},i.prototype.open=function(){this.isOpen()||this.trigger("query",{})},i.prototype.close=function(){this.isOpen()&&this.trigger("close",{})},i.prototype.isOpen=function(){return this.$container.hasClass("select2-container--open")},i.prototype.hasFocus=function(){return this.$container.hasClass("select2-container--focus")},i.prototype.focus=function(e){this.hasFocus()||(this.$container.addClass("select2-container--focus"),this.trigger("focus",{}))},i.prototype.enable=function(e){this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("enable")` method has been deprecated and will be removed in later Select2 versions. Use $element.prop("disabled") instead.'),null!=e&&0!==e.length||(e=[!0]);var t=!e[0];this.$element.prop("disabled",t)},i.prototype.data=function(){this.options.get("debug")&&arguments.length>0&&window.console&&console.warn&&console.warn('Select2: Data can no longer be set using `select2("data")`. You should consider setting the value instead using `$element.val()`.');var e=[];return this.dataAdapter.current(function(t){e=t}),e},i.prototype.val=function(t){if(this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("val")` method has been deprecated and will be removed in later Select2 versions. Use $element.val() instead.'),null==t||0===t.length)return this.$element.val();var n=t[0];e.isArray(n)&&(n=e.map(n,function(e){return e.toString()})),this.$element.val(n).trigger("change")},i.prototype.destroy=function(){this.$container.remove(),this.$element[0].detachEvent&&this.$element[0].detachEvent("onpropertychange",this._sync),null!=this._observer?(this._observer.disconnect(),this._observer=null):this.$element[0].removeEventListener&&this.$element[0].removeEventListener("DOMAttrModified",this._sync,!1),this._sync=null,this.$element.off(".select2"),this.$element.attr("tabindex",this.$element.data("old-tabindex")),this.$element.removeClass("select2-hidden-accessible"),this.$element.attr("aria-hidden","false"),this.$element.removeData("select2"),this.dataAdapter.destroy(),this.selection.destroy(),this.dropdown.destroy(),this.results.destroy(),this.dataAdapter=null,this.selection=null,this.dropdown=null,this.results=null},i.prototype.render=function(){var t=e('');return t.attr("dir",this.options.get("dir")),this.$container=t,this.$container.addClass("select2-container--"+this.options.get("theme")),t.data("element",this.$element),t},i}),t.define("jquery-mousewheel",["jquery"],function(e){return e}),t.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults"],function(e,t,n,r){if(null==e.fn.select2){var i=["open","close","destroy"];e.fn.select2=function(t){if(t=t||{},"object"==typeof t)return this.each(function(){var r=e.extend(!0,{},t);new n(e(this),r)}),this;if("string"==typeof t){var r;return this.each(function(){var n=e(this).data("select2");null==n&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2.");var i=Array.prototype.slice.call(arguments,1);r=n[t].apply(n,i)}),e.inArray(t,i)>-1?this:r}throw new Error("Invalid arguments for Select2: "+t)}}return null==e.fn.select2.defaults&&(e.fn.select2.defaults=r),n}),{define:t.define,require:t.require}}(),n=t.require("jquery.select2");return e.fn.select2.amd=t,n}); +//# sourceMappingURL=jquery.select2.min.js.map diff --git a/pillar/web/static/assets/js/vendor/jquery.ui.widget.min.js b/pillar/web/static/assets/js/vendor/jquery.ui.widget.min.js new file mode 100644 index 00000000..8079abac --- /dev/null +++ b/pillar/web/static/assets/js/vendor/jquery.ui.widget.min.js @@ -0,0 +1 @@ +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports?require("jquery"):jQuery)}(function(a){var b=0,c=Array.prototype.slice;a.cleanData=function(b){return function(c){var d,e,f;for(f=0;null!=(e=c[f]);f++)try{d=a._data(e,"events"),d&&d.remove&&a(e).triggerHandler("remove")}catch(a){}b(c)}}(a.cleanData),a.widget=function(b,c,d){var e,f,g,h,i={},j=b.split(".")[0];return b=b.split(".")[1],e=j+"-"+b,d||(d=c,c=a.Widget),a.expr[":"][e.toLowerCase()]=function(b){return!!a.data(b,e)},a[j]=a[j]||{},f=a[j][b],g=a[j][b]=function(a,b){return this._createWidget?void(arguments.length&&this._createWidget(a,b)):new g(a,b)},a.extend(g,f,{version:d.version,_proto:a.extend({},d),_childConstructors:[]}),h=new c,h.options=a.widget.extend({},h.options),a.each(d,function(b,d){return a.isFunction(d)?void(i[b]=function(){var a=function(){return c.prototype[b].apply(this,arguments)},e=function(a){return c.prototype[b].apply(this,a)};return function(){var f,b=this._super,c=this._superApply;return this._super=a,this._superApply=e,f=d.apply(this,arguments),this._super=b,this._superApply=c,f}}()):void(i[b]=d)}),g.prototype=a.widget.extend(h,{widgetEventPrefix:f?h.widgetEventPrefix||b:b},i,{constructor:g,namespace:j,widgetName:b,widgetFullName:e}),f?(a.each(f._childConstructors,function(b,c){var d=c.prototype;a.widget(d.namespace+"."+d.widgetName,g,c._proto)}),delete f._childConstructors):c._childConstructors.push(g),a.widget.bridge(b,g),g},a.widget.extend=function(b){for(var g,h,d=c.call(arguments,1),e=0,f=d.length;e",options:{disabled:!1,create:null},_createWidget:function(c,d){d=a(d||this.defaultElement||this)[0],this.element=a(d),this.uuid=b++,this.eventNamespace="."+this.widgetName+this.uuid,this.options=a.widget.extend({},this.options,this._getCreateOptions(),c),this.bindings=a(),this.hoverable=a(),this.focusable=a(),d!==this&&(a.data(d,this.widgetFullName,this),this._on(!0,this.element,{remove:function(a){a.target===d&&this.destroy()}}),this.document=a(d.style?d.ownerDocument:d.document||d),this.window=a(this.document[0].defaultView||this.document[0].parentWindow)),this._create(),this._trigger("create",null,this._getCreateEventData()),this._init()},_getCreateOptions:a.noop,_getCreateEventData:a.noop,_create:a.noop,_init:a.noop,destroy:function(){this._destroy(),this.element.unbind(this.eventNamespace).removeData(this.widgetFullName).removeData(a.camelCase(this.widgetFullName)),this.widget().unbind(this.eventNamespace).removeAttr("aria-disabled").removeClass(this.widgetFullName+"-disabled ui-state-disabled"),this.bindings.unbind(this.eventNamespace),this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus")},_destroy:a.noop,widget:function(){return this.element},option:function(b,c){var e,f,g,d=b;if(0===arguments.length)return a.widget.extend({},this.options);if("string"==typeof b)if(d={},e=b.split("."),b=e.shift(),e.length){for(f=d[b]=a.widget.extend({},this.options[b]),g=0;g/edit', methods=['GET', 'POST']) +@login_required +def users_edit(user_id): + if not current_user.has_role('admin'): + return abort(403) + api = system_util.pillar_api() + user = User.find(user_id, api=api) + form = UserEditForm() + if form.validate_on_submit(): + def get_groups(roles): + """Return a set of role ids matching the group names provided""" + groups_set = set() + for system_role in roles: + group = Group.find_one({'where': "name=='%s'" % system_role}, api=api) + groups_set.add(group._id) + return groups_set + + # Remove any of the default roles + system_roles = set([role[0] for role in form.roles.choices]) + system_groups = get_groups(system_roles) + # Current user roles + user_roles_list = user.roles if user.roles else [] + user_roles = set(user_roles_list) + user_groups = get_groups(user_roles_list) + # Remove all form roles from current roles + user_roles = list(user_roles.difference(system_roles)) + user_groups = list(user_groups.difference(system_groups)) + # Get the assigned roles + system_roles_assigned = form.roles.data + system_groups_assigned = get_groups(system_roles_assigned) + # Reassign roles based on form.roles.data by adding them to existing roles + user_roles += system_roles_assigned + user_groups += list(get_groups(user_roles)) + # Fetch the group for the assigned system roles + user.roles = user_roles + user.groups = user_groups + user.update(api=api) + else: + form.roles.data = user.roles + return render_template('users/edit_embed.html', + user=user, + form=form) + + +@blueprint.route('/u') +@login_required +def users_index(): + if not current_user.has_role('admin'): + return abort(403) + return render_template('users/index.html') diff --git a/pillar/web/utils/__init__.py b/pillar/web/utils/__init__.py new file mode 100644 index 00000000..798bae57 --- /dev/null +++ b/pillar/web/utils/__init__.py @@ -0,0 +1,135 @@ +import hashlib +import urllib +import logging +import traceback +import sys + +from flask import current_app +from flask import request +from flask.ext.login import current_user +from pillarsdk import File +from pillarsdk import Project +from pillarsdk.exceptions import ResourceNotFound +import pillarsdk.utils +from pillar.web import system_util +from pillar.web.utils.exceptions import ConfigError + +log = logging.getLogger(__name__) + + +def get_file(file_id, api=None): + # TODO: remove this function and just use the Pillar SDK directly. + if file_id is None: + return None + + if api is None: + api = system_util.pillar_api() + + try: + return File.find(file_id, api=api) + except ResourceNotFound: + f = sys.exc_info()[2].tb_frame.f_back + tb = traceback.format_stack(f=f, limit=2) + log.warning('File %s not found, but requested from %s\n%s', + file_id, request.url, ''.join(tb)) + return None + + +def attach_project_pictures(project, api): + """Utility function that queries for file objects referenced in picture + header and square. In eve we currently can't embed objects in nested + properties, this is the reason why this exists. + This function should be moved in the API, attached to a new Project object. + """ + + project.picture_square = get_file(project.picture_square, api=api) + project.picture_header = get_file(project.picture_header, api=api) + + +def gravatar(email, size=64): + parameters = {'s': str(size), 'd': 'mm'} + return "https://www.gravatar.com/avatar/" + \ + hashlib.md5(str(email)).hexdigest() + \ + "?" + urllib.urlencode(parameters) + + +def pretty_date(time=None, detail=False, now=None): + """Get a datetime object or a int() Epoch timestamp and return a + pretty string like 'an hour ago', 'Yesterday', '3 months ago', + 'just now', etc + """ + + from datetime import datetime + + # Normalize the 'time' parameter so it's always a datetime. + if type(time) is int: + time = datetime.fromtimestamp(time, tz=pillarsdk.utils.utc) + elif time is None: + time = now + + now = now or datetime.now(tz=time.tzinfo) + diff = now - time + + second_diff = diff.seconds + day_diff = diff.days + + if day_diff < 0: + return '' + + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return str(second_diff) + "s ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return str(second_diff / 60 ) + "m ago" + if second_diff < 7200: + return "an hour ago" + if second_diff < 86400: + return str(second_diff / 3600) + "h ago" + + if day_diff == 1: + pretty = "yesterday" + + elif day_diff <= 7: + # "Tuesday" + pretty = time.strftime("%A") + + elif day_diff <= 22: + week_count = day_diff/7 + if week_count == 1: + pretty = "%s week ago" % week_count + else: + pretty = "%s weeks ago" % week_count + + elif time.year == now.year: + # "16 Jul" + pretty = time.strftime("%d %b") + + else: + # "16 Jul 2009" + pretty = time.strftime("%d %b %Y") + + if detail: + # "Tuesday at 14:20" + return '%s at %s' % (pretty, time.strftime('%H:%M')) + + return pretty + + +def current_user_is_authenticated(): + return current_user.is_authenticated + + +def get_main_project(): + api = system_util.pillar_api() + try: + main_project = Project.find( + current_app.config['MAIN_PROJECT_ID'], api=api) + except ResourceNotFound: + raise ConfigError('MAIN_PROJECT_ID was not found. Check config.py.') + except KeyError: + raise ConfigError('MAIN_PROJECT_ID missing from config.py') + return main_project diff --git a/pillar/web/utils/caching.py b/pillar/web/utils/caching.py new file mode 100644 index 00000000..749f3154 --- /dev/null +++ b/pillar/web/utils/caching.py @@ -0,0 +1,25 @@ +import functools +from flask import g + + +def cache_for_request(): + """Decorator; caches the return value for the duration of the current request. + + The caller determines the cache key: *args are used as cache key, **kwargs + are not. + """ + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + if not hasattr(g, 'request_level_cache'): + g.request_level_cache = {} + + try: + return g.request_level_cache[args] + except KeyError: + val = func(*args, **kwargs) + g.request_level_cache[args] = val + return val + return wrapper + return decorator diff --git a/pillar/web/utils/exceptions.py b/pillar/web/utils/exceptions.py new file mode 100644 index 00000000..3edfdef8 --- /dev/null +++ b/pillar/web/utils/exceptions.py @@ -0,0 +1,4 @@ +class ConfigError(Exception): + def __init__(self, message): + # Call the base class constructor with the parameters it needs + super(ConfigError, self).__init__(message) diff --git a/pillar/web/utils/forms.py b/pillar/web/utils/forms.py new file mode 100644 index 00000000..563462db --- /dev/null +++ b/pillar/web/utils/forms.py @@ -0,0 +1,187 @@ +import json + +from markupsafe import Markup + +from pillarsdk import File +from flask import current_app +from flask.ext.login import current_user +from wtforms import Form +from wtforms import StringField +from wtforms import SelectField +from wtforms import BooleanField +from wtforms.compat import text_type +from wtforms.widgets import html_params +from wtforms.widgets import HiddenInput +from wtforms.widgets import HTMLString +from wtforms.fields import FormField +from wtforms import validators +from pillarsdk.exceptions import ResourceNotFound +from pillar.web import system_util + + +class CustomFileSelectWidget(HiddenInput): + def __init__(self, file_format=None, **kwargs): + super(CustomFileSelectWidget, self).__init__(**kwargs) + self.file_format = file_format + + def __call__(self, field, **kwargs): + html = super(CustomFileSelectWidget, self).__call__(field, **kwargs) + + file_format = self.file_format + file_format_regex = '' + if file_format and file_format == 'image': + file_format_regex = '^image\/(gif|jpe?g|png|tif?f|tga)$' + + button = [u'
      '] + + if field.data: + api = system_util.pillar_api() + try: + # Load the existing file attached to the field + file_item = File.find(field.data, api=api) + except ResourceNotFound: + pass + else: + filename = Markup.escape(file_item.filename) + if file_item.content_type.split('/')[0] == 'image': + # If a file of type image is available, display the preview + button.append(u''.format( + file_item.thumbnail('s', api=api))) + else: + button.append(u'

      {}

      '.format(filename)) + + button.append(u'
        ') + # File name + button.append(u'
      • {0}
      • '.format(filename)) + # File size + button.append(u'
      • ({0} MB)
      • '.format( + round((file_item.length / 1024) * 0.001, 2))) + # Image resolution (if image) + button.append(u'
      • {0}x{1}
      • '.format( + file_item.width, file_item.height)) + # Delete button + button.append(u'
      • ' + u' ' + u' Delete
      • '.format( + field_name=field.name, file_id=field.data)) + # Download button for original file + button.append(u'
      • ' + u' ' + u'Original
      • ' + .format(file_item.link)) + button.append(u'
      ') + + upload_url = u'%sstorage/stream/{project_id}' % current_app.config[ + 'PILLAR_SERVER_ENDPOINT'] + + button.append(u'' + u'
      ' + u'
      ' + u'
      ' + u'
      '.format(url=upload_url, + name=field.name, + token=Markup.escape(current_user.id), + file_format=Markup.escape(file_format_regex))) + + button.append(u'
      ') + + return HTMLString(html + u''.join(button)) + + +class FileSelectField(StringField): + def __init__(self, name, file_format=None, **kwargs): + super(FileSelectField, self).__init__(name, **kwargs) + self.widget = CustomFileSelectWidget(file_format=file_format) + + +class ProceduralFileSelectForm(Form): + file = FileSelectField('file') + size = StringField() + slug = StringField() + + +def build_file_select_form(schema): + class FileSelectForm(Form): + pass + + for field_name, field_schema in schema.iteritems(): + if field_schema['type'] == 'boolean': + field = BooleanField() + elif field_schema['type'] == 'string': + if 'allowed' in field_schema: + choices = [(c, c) for c in field_schema['allowed']] + field = SelectField(choices=choices) + else: + field = StringField() + elif field_schema['type'] == 'objectid': + field = FileSelectField('file') + else: + raise ValueError('field type %s not supported' % field_schema['type']) + + setattr(FileSelectForm, field_name, field) + return FileSelectForm + + +class CustomFormFieldWidget(object): + """ + Renders a list of fields as in the way we like. Based the TableWidget. + + Hidden fields will not be displayed with a row, instead the field will be + pushed into a subsequent table row to ensure XHTML validity. Hidden fields + at the end of the field list will appear outside the table. + """ + + def __call__(self, field, **kwargs): + html = [] + kwargs.setdefault('id', field.id) + html.append(u'
      ' % html_params(**kwargs)) + hidden = u'' + for subfield in field: + if subfield.type == 'HiddenField': + hidden += text_type(subfield) + else: + html.append(u'
      %s%s%s
      ' % ( + text_type(subfield.label), hidden, text_type(subfield))) + hidden = u'' + html.append(u'
      ') + if hidden: + html.append(hidden) + return HTMLString(u''.join(html)) + + +class CustomFormField(FormField): + def __init__(self, name, **kwargs): + super(CustomFormField, self).__init__(name, **kwargs) + self.widget = CustomFormFieldWidget() + + +class JSONRequired(validators.DataRequired): + """ + Checks the field's data is valid JSON, otherwise stops the validation chain. + + This validator checks that the ``data`` attribute on the field can be parsed + as JSON string. + + :param message: + Error message to raise in case of a validation error. If not given, + uses the message from the ValueError raised by json.loads(). + """ + + def __call__(self, form, field): + super(JSONRequired, self).__call__(form, field) + + try: + json.loads(field.data) + except ValueError as ex: + message = self.message or ex.message + + field.errors[:] = [] + raise validators.StopValidation(message) diff --git a/pillar/web/utils/jstree.py b/pillar/web/utils/jstree.py new file mode 100644 index 00000000..28d85963 --- /dev/null +++ b/pillar/web/utils/jstree.py @@ -0,0 +1,133 @@ +from pillarsdk import Node +from pillarsdk.exceptions import ForbiddenAccess +from pillarsdk.exceptions import ResourceNotFound +from flask.ext.login import current_user + +from pillar.web import system_util + +GROUP_NODES = {'group', 'storage', 'group_texture', 'group_hdri'} + + +def jstree_parse_node(node, children=None): + """Generate JStree node from node object""" + node_type = node.node_type + # Define better the node type + if node_type == 'asset': + node_type = node.properties.content_type + parsed_node = dict( + id="n_{0}".format(node._id), + text=node.name, + type=node_type, + children=False) + # Append children property only if it is a directory type + if node_type in GROUP_NODES: + parsed_node['children'] = True + + return parsed_node + + +def jstree_get_children(node_id, project_id=None): + api = system_util.pillar_api() + children_list = [] + lookup = { + 'projection': { + 'name': 1, 'parent': 1, 'node_type': 1, 'properties.order': 1, + 'properties.status': 1, 'properties.content_type': 1, 'user': 1, + 'project': 1}, + 'sort': [('properties.order', 1), ('_created', 1)]} + if node_id: + if node_id.startswith('n_'): + node_id = node_id.split('_')[1] + lookup['where'] = {'parent': node_id} + elif project_id: + lookup['where'] = {'project': project_id, 'parent': {'$exists': False}} + + try: + children = Node.all(lookup, api=api) + for child in children['_items']: + # Skip nodes of type comment + if child.node_type not in ['comment', 'post']: + if child.properties.status == 'published': + children_list.append(jstree_parse_node(child)) + elif child.node_type == 'blog': + children_list.append(jstree_parse_node(child)) + elif current_user.is_authenticated and child.user == current_user.objectid: + children_list.append(jstree_parse_node(child)) + except ForbiddenAccess: + pass + return children_list + + +def jstree_build_children(node): + return dict( + id="n_{0}".format(node._id), + text=node.name, + type=node.node_type, + children=jstree_get_children(node._id) + ) + + +def jstree_build_from_node(node): + """Give a node, traverse the tree bottom to top and expand the relevant + branches. + + :param node: the base node, where tree building starts + """ + api = system_util.pillar_api() + # Parse the node and mark it as selected + child_node = jstree_parse_node(node) + child_node['state'] = dict(selected=True) + + # Splice the specified child node between the other project children. + def select_node(x): + if x['id'] == child_node['id']: + return child_node + return x + + # Get the parent node + parent = None + if node.parent: + try: + parent = Node.find(node.parent, { + 'projection': { + 'name': 1, + 'node_type': 1, + 'parent': 1, + 'properties.content_type': 1, + }}, api=api) + # Define the child node of the tree (usually an asset) + except ResourceNotFound: + # If not found, we might be on the top level, in which case we skip the + # while loop and use child_node + pass + except ForbiddenAccess: + pass + + while parent: + # Get the parent's parent + parent_parent = jstree_parse_node(parent) + # Get the parent's children (this will also include child_node) + parent_children = [select_node(x) for x in jstree_get_children(parent_parent['id'])] + parent_parent.pop('children', None) + # Overwrite children_node with the current parent + child_node = parent_parent + # Set the node to open so that jstree actually displays the nodes + child_node['state'] = dict(opened=True) + # Push in the computed children into the parent + child_node['children'] = parent_children + # If we have a parent + if parent.parent: + try: + parent = Node.find(parent.parent, { + 'projection': { + 'name': 1, 'parent': 1, 'project': 1, 'node_type': 1}, + }, api=api) + except ResourceNotFound: + parent = None + else: + parent = None + # Get top level nodes for the project + project_children = jstree_get_children(None, node.project) + + nodes_list = [select_node(x) for x in project_children] + return nodes_list diff --git a/setup.py b/setup.py index b17b4f21..e62a92f1 100644 --- a/setup.py +++ b/setup.py @@ -6,9 +6,28 @@ import setuptools setuptools.setup( name='pillar', - version='1.0', - packages=setuptools.find_packages('pillar', exclude=['manage']), - package_dir={'': 'pillar'}, # tell setuptools packages are under src - tests_require=['pytest'], + version='2.0', + packages=setuptools.find_packages('.', exclude=['test']), + install_requires=[ + 'Flask>0.10,<0.11', # Flask 0.11 is incompatible with Eve 0.6.4 + 'Eve>=0.6.3', + 'Flask-Script>=2.0.5', + 'algoliasearch>=1.8.0,<1.9.0', # 1.9 Gives an issue importing some exception class. + 'gcloud>=0.12.0', + 'google-apitools>=0.4.11', + 'MarkupSafe>=0.23', + 'Pillow>=2.8.1', + 'requests>=2.9.1', + 'rsa>=3.3', + 'zencoder>=0.6.5', + 'bcrypt>=2.0.0', + 'blinker>=1.4', + ], + tests_require=[ + 'pytest>=2.9.1', + 'responses>=0.5.1', + 'pytest-cov>=2.2.1', + 'mock>=2.0.0', + ], zip_safe=False, ) diff --git a/src/scripts/algolia_search.js b/src/scripts/algolia_search.js new file mode 100644 index 00000000..f79e77e9 --- /dev/null +++ b/src/scripts/algolia_search.js @@ -0,0 +1,359 @@ +$(document).ready(function() { + + /******************** + * INITIALIZATION + * *******************/ + + var HITS_PER_PAGE = 25; + var MAX_VALUES_PER_FACET = 30; + + // DOM binding + var $inputField = $('#q'); + var $hits = $('#hits'); + var $stats = $('#stats'); + var $facets = $('#facets'); + var $pagination = $('#pagination'); + + // Templates binding + var hitTemplate = Hogan.compile($('#hit-template').text()); + var statsTemplate = Hogan.compile($('#stats-template').text()); + var facetTemplate = Hogan.compile($('#facet-template').text()); + var sliderTemplate = Hogan.compile($('#slider-template').text()); + var paginationTemplate = Hogan.compile($('#pagination-template').text()); + + // Client initialization + var algolia = algoliasearch(APPLICATION_ID, SEARCH_ONLY_API_KEY); + + // Helper initialization + var params = { + hitsPerPage: HITS_PER_PAGE, + maxValuesPerFacet: MAX_VALUES_PER_FACET, + facets: $.map(FACET_CONFIG, function(facet) { return !facet.disjunctive ? facet.name : null; }), + disjunctiveFacets: $.map(FACET_CONFIG, function(facet) { return facet.disjunctive ? facet.name : null; }) + }; + + // Setup the search helper + var helper = algoliasearchHelper(algolia, INDEX_NAME, params); + + // Check if we passed hidden facets in the FACET_CONFIG + var result = $.grep(FACET_CONFIG, function(e){ return e.hidden && e.hidden == true; }); + for (var i = 0; i < result.length; i++) { + var f = result[i]; + helper.addFacetRefinement(f.name, f.value); + } + + + // Input binding + $inputField.on('keyup change', function() { + var query = $inputField.val(); + toggleIconEmptyInput(!query.trim()); + helper.setQuery(query).search(); + }).focus(); + + // AlgoliaHelper events + helper.on('change', function(state) { + setURLParams(state); + }); + helper.on('error', function(error) { + console.log(error); + }); + helper.on('result', function(content, state) { + renderStats(content); + renderHits(content); + renderFacets(content, state); + renderPagination(content); + bindSearchObjects(); + + renderFirstHit($(hits).children('.search-hit:first')); + }); + + /************ + * SEARCH + * ***********/ + + function renderFirstHit(firstHit) { + + firstHit.addClass('active'); + firstHit.find('#search-loading').addClass('active'); + + var getNode = setTimeout(function(){ + $.get('/nodes/' + firstHit.attr('data-hit-id') + '/view', function(dataHtml){ + $('#search-hit-container').html(dataHtml); + }) + .done(function(){ + $('.search-loading').removeClass('active'); + $('#search-error').hide(); + $('#search-hit-container').show(); + + clearTimeout(getNode); + }) + .fail(function(data){ + $('.search-loading').removeClass('active'); + $('#search-hit-container').hide(); + $('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText); + }); + }, 1000); + }; + + // Initial search + initWithUrlParams(); + helper.search(); + + function convertTimestamp(timestamp) { + var d = new Date(timestamp * 1000), // Convert the passed timestamp to milliseconds + yyyy = d.getFullYear(), + mm = ('0' + (d.getMonth() + 1)).slice(-2), // Months are zero based. Add leading 0. + dd = ('0' + d.getDate()).slice(-2), // Add leading 0. + time; + + time = dd + '/' + mm + '/' + yyyy; + + return time; + } + + + function renderStats(content) { + var stats = { + nbHits: numberWithDelimiter(content.nbHits), + processingTimeMS: content.processingTimeMS, + nbHits_plural: content.nbHits !== 1 + }; + $stats.html(statsTemplate.render(stats)); + } + + function renderHits(content) { + var hitsHtml = ''; + for (var i = 0; i < content.hits.length; ++i) { + // console.log(content.hits[i]); + var created = content.hits[i]['created']; + if (created) { + content.hits[i]['created'] = convertTimestamp(created); + } + var updated = content.hits[i]['updated']; + if (updated) { + content.hits[i]['updated'] = convertTimestamp(updated); + } + hitsHtml += hitTemplate.render(content.hits[i]); + } + if (content.hits.length === 0) hitsHtml = '

      We didn\'t find any items. Try searching something else.

      '; + $hits.html(hitsHtml); + } + + function renderFacets(content, state) { + // If no results + if (content.hits.length === 0) { + $facets.empty(); + return; + } + + // Process facets + var facets = []; + for (var facetIndex = 0; facetIndex < FACET_CONFIG.length; ++facetIndex) { + var facetParams = FACET_CONFIG[facetIndex]; + if (facetParams.hidden) { + continue + } + var facetResult = content.getFacetByName(facetParams.name); + if (facetResult) { + var facetContent = {}; + facetContent.facet = facetParams.name; + facetContent.title = facetParams.title; + facetContent.type = facetParams.type; + + if (facetParams.type === 'slider') { + // if the facet is a slider + facetContent.min = facetResult.stats.min; + facetContent.max = facetResult.stats.max; + var valueMin = state.getNumericRefinement(facetParams.name, '>=') || facetResult.stats.min; + var valueMax = state.getNumericRefinement(facetParams.name, '<=') || facetResult.stats.max; + valueMin = Math.min(facetContent.max, Math.max(facetContent.min, valueMin)); + valueMax = Math.min(facetContent.max, Math.max(facetContent.min, valueMax)); + facetContent.values = [valueMin, valueMax]; + } else { + // format and sort the facet values + var values = []; + for (var v in facetResult.data) { + var label = ''; + if (v === 'true') { label = 'Yes'; } + else if (v === 'false') { label = 'No'; } + // Remove any underscore from the value + else { label = v.replace(/_/g," "); } + values.push({ label: label, value: v, count: facetResult.data[v], refined: helper.isRefined(facetParams.name, v) }); + } + var sortFunction = facetParams.sortFunction || sortByCountDesc; + if (facetParams.topListIfRefined) sortFunction = sortByRefined(sortFunction); + values.sort(sortFunction); + + facetContent.values = values.slice(0, 10); + facetContent.has_other_values = values.length > 10; + facetContent.other_values = values.slice(10); + facetContent.disjunctive = facetParams.disjunctive; + } + facets.push(facetContent); + } + } + // Display facets + var facetsHtml = ''; + for (var indexFacet = 0; indexFacet < facets.length; ++indexFacet) { + var facet = facets[indexFacet]; + if (facet.type && facet.type === 'slider') facetsHtml += sliderTemplate.render(facet); + else facetsHtml += facetTemplate.render(facet); + } + $facets.html(facetsHtml); + } + + function renderPagination(content) { + // If no results + if (content.hits.length === 0) { + $pagination.empty(); + return; + } + + var maxPages = 2; + + // Process pagination + var pages = []; + if (content.page > maxPages) { + pages.push({ current: false, number: 1 }); + // They don't really add much... + // pages.push({ current: false, number: '...', disabled: true }); + } + for (var p = content.page - maxPages; p < content.page + maxPages; ++p) { + if (p < 0 || p >= content.nbPages) { + continue; + } + pages.push({ current: content.page === p, number: (p + 1) }); + } + if (content.page + maxPages < content.nbPages) { + // They don't really add much... + // pages.push({ current: false, number: '...', disabled: true }); + pages.push({ current: false, number: content.nbPages }); + } + var pagination = { + pages: pages, + prev_page: (content.page > 0 ? content.page : false), + next_page: (content.page + 1 < content.nbPages ? content.page + 2 : false) + }; + // Display pagination + $pagination.html(paginationTemplate.render(pagination)); + } + + + // Event bindings + function bindSearchObjects() { + // Slider binding + // $('#customerReviewCount-slider').slider().on('slideStop', function(ev) { + // helper.addNumericRefinement('customerReviewCount', '>=', ev.value[0]).search(); + // helper.addNumericRefinement('customerReviewCount', '<=', ev.value[1]).search(); + // }); + + // Pimp checkboxes + // $('input[type="checkbox"]').checkbox(); + } + + // Click binding + $(document).on('click','.show-more, .show-less',function(e) { + e.preventDefault(); + $(this).closest('ul').find('.show-more').toggle(); + $(this).closest('ul').find('.show-less').toggle(); + return false; + }); + $(document).on('click','.toggleRefine',function() { + helper.toggleRefine($(this).data('facet'), $(this).data('value')).search(); + return false; + }); + $(document).on('click','.gotoPage',function() { + helper.setCurrentPage(+$(this).data('page') - 1).search(); + $("html, body").animate({scrollTop:0}, '500', 'swing'); + return false; + }); + $(document).on('click','.sortBy',function() { + $(this).closest('.btn-group').find('.sort-by').text($(this).text()); + helper.setIndex(INDEX_NAME + $(this).data('index-suffix')).search(); + return false; + }); + $(document).on('click','#input-loop',function() { + $inputField.val('').change(); + }); + + // Dynamic styles + $('#facets').on("mouseenter mouseleave", ".button-checkbox", function(e){ + $(this).parent().find('.facet_link').toggleClass("hover"); + }); + $('#facets').on("mouseenter mouseleave", ".facet_link", function(e){ + $(this).parent().find('.button-checkbox button.btn').toggleClass("hover"); + }); + + + /************ + * HELPERS + * ***********/ + + function toggleIconEmptyInput(isEmpty) { + if(isEmpty) { + $('#input-loop').addClass('glyphicon-loop'); + $('#input-loop').removeClass('glyphicon-remove'); + } + else { + $('#input-loop').removeClass('glyphicon-loop'); + $('#input-loop').addClass('glyphicon-remove'); + } + } + function numberWithDelimiter(number, delimiter) { + number = number + ''; + delimiter = delimiter || ','; + var split = number.split('.'); + split[0] = split[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1' + delimiter); + return split.join('.'); + } + var sortByCountDesc = function sortByCountDesc (a, b) { return b.count - a.count; }; + var sortByName = function sortByName (a, b) { + return a.value.localeCompare(b.value); + }; + var sortByRefined = function sortByRefined (sortFunction) { + return function (a, b) { + if (a.refined !== b.refined) { + if (a.refined) return -1; + if (b.refined) return 1; + } + return sortFunction(a, b); + }; + }; + function initWithUrlParams() { + var sPageURL = location.hash; + if (!sPageURL || sPageURL.length === 0) { return true; } + var sURLVariables = sPageURL.split('&'); + if (!sURLVariables || sURLVariables.length === 0) { return true; } + var query = decodeURIComponent(sURLVariables[0].split('=')[1]); + $inputField.val(query); + helper.setQuery(query); + for (var i = 2; i < sURLVariables.length; i++) { + var sParameterName = sURLVariables[i].split('='); + var facet = decodeURIComponent(sParameterName[0]); + var value = decodeURIComponent(sParameterName[1]); + helper.toggleRefine(facet, value, false); + } + // Page has to be set in the end to avoid being overwritten + var page = decodeURIComponent(sURLVariables[1].split('=')[1])-1; + helper.setCurrentPage(page); + + } + function setURLParams(state) { + var urlParams = '#'; + var currentQuery = state.query; + urlParams += 'q=' + encodeURIComponent(currentQuery); + var currentPage = state.page+1; + urlParams += '&page=' + currentPage; + for (var facetRefine in state.facetsRefinements) { + urlParams += '&' + encodeURIComponent(facetRefine) + '=' + encodeURIComponent(state.facetsRefinements[facetRefine]); + } + for (var disjunctiveFacetrefine in state.disjunctiveFacetsRefinements) { + for (var value in state.disjunctiveFacetsRefinements[disjunctiveFacetrefine]) { + urlParams += '&' + encodeURIComponent(disjunctiveFacetrefine) + '=' + encodeURIComponent(state.disjunctiveFacetsRefinements[disjunctiveFacetrefine][value]); + } + } + location.replace(urlParams); + } + +}); + diff --git a/src/scripts/file_upload.js b/src/scripts/file_upload.js new file mode 100644 index 00000000..a56e3833 --- /dev/null +++ b/src/scripts/file_upload.js @@ -0,0 +1,162 @@ +function deleteFile(fileField, newFileId) { + if (newFileId) { + fileField.val(newFileId); + } else { + fileField.val(''); + } +} + +var current_file_uploads = 0; + +function on_file_upload_activated() { + if (current_file_uploads == 0) { + // Disable the save buttons. + $('.button-save') + .addClass('disabled') + .find('a').html(' Uploading...'); + } + + current_file_uploads++; +} + +function on_file_upload_finished() { + current_file_uploads = Math.max(0, current_file_uploads-1); + + if (current_file_uploads == 0) { + // Restore the save buttons. + $('.button-save') + .removeClass('disabled') + .find('a').html(' Save Changes'); + } +} + + +function setup_file_uploader(index, upload_element) { + var $upload_element = $(upload_element); + var container = $upload_element.parent().parent(); + var progress_bar = container.find('div.form-upload-progress-bar'); + + function set_progress_bar(progress, html_class) { + progress_bar.css({ + 'width': progress + '%', + 'display': progress == 0 ? 'none' : 'block'}); + + progress_bar.removeClass('progress-error progress-uploading progress-processing'); + if (!!html_class) progress_bar.addClass(html_class); + } + + $upload_element.fileupload({ + dataType: 'json', + replaceFileInput: false, + dropZone: container, + formData: {}, + beforeSend: function (xhr, data) { + var token = this.fileInput.attr('data-token'); + xhr.setRequestHeader('Authorization', 'basic ' + btoa(token + ':')); + statusBarSet('info', 'Uploading File...', 'pi-upload-cloud'); + + // console.log('Uploading from', upload_element, upload_element.value); + + // Clear thumbnail & progress bar. + container.find('.preview-thumbnail').hide(); + set_progress_bar(0); + + $('body').trigger('file-upload:activated'); + }, + add: function (e, data) { + var uploadErrors = []; + // Load regex if available (like /^image\/(gif|jpe?g|png)$/i;) + var acceptFileTypes = new RegExp($(this).data('file-format')); + if (data.originalFiles[0]['type'].length && !acceptFileTypes.test(data.originalFiles[0]['type'])) { + uploadErrors.push('Not an accepted file type'); + } + // Limit upload size to 1GB + if (data.originalFiles[0]['size'] && data.originalFiles[0]['size'] > 1262485504) { + uploadErrors.push('Filesize is too big'); + } + if (uploadErrors.length > 0) { + $(this).parent().parent().addClass('error'); + $(this).after(uploadErrors.join("\n")); + } else { + $(this).parent().parent().removeClass('error'); + data.submit(); + } + }, + progressall: function (e, data) { + // Update progressbar during upload + var progress = parseInt(data.loaded / data.total * 100, 10); + // console.log('Uploading', upload_element.value, ': ', progress, '%'); + + set_progress_bar(Math.max(progress, 2), + progress > 99.9 ? 'progress-processing' : 'progress-uploading' + ); + }, + done: function (e, data) { + if (data.result.status !== 'ok') { + if (console) + console.log('FIXME, do error handling for non-ok status', data.result); + return; + } + + // Ensure the form refers to the correct Pillar file ID. + var pillar_file_id = data.result.file_id; + var $file_id_field = $('#' + $(this).attr('data-field-name')); + if ($file_id_field.val()) { + deleteFile($file_id_field, pillar_file_id); + } + $file_id_field.val(pillar_file_id); + + // Ugly workaround: If the asset has the default name, name it as the file + if ($('.form-group.name .form-control').val() == 'New asset') { + var filename = data.files[0].name; + $('.form-group.name .form-control').val(filename); + $('.node-edit-title').html(filename); + } + + statusBarSet('success', 'File Uploaded Successfully', 'pi-check'); + set_progress_bar(100); + + $('body').trigger('file-upload:finished'); + }, + fail: function (jqXHR, textStatus, errorThrown) { + if (console) { + console.log(textStatus, 'Upload error: ' + errorThrown); + } + statusBarSet(textStatus, 'Upload error: ' + errorThrown, 'pi-attention', 8000); + + set_progress_bar(100, 'progress-error'); + + $('body').trigger('file-upload:finished'); + } + }); +} + + +$(function () { + // $('.file_delete').click(function(e){ + $('body').unbind('click') + .on('click', '.file_delete', function(e) { + e.preventDefault(); + var field_name = '#' + $(this).data('field-name'); + var file_field = $(field_name); + deleteFile(file_field); + $(this).parent().parent().hide(); + $(this).parent().parent().prev().hide(); + }) + .on('file-upload:activated', on_file_upload_activated) + .on('file-upload:finished', on_file_upload_finished) + ; + + function inject_project_id_into_url(index, element) { + // console.log('Injecting ', ProjectUtils.projectId(), ' into ', element); + var url = element.getAttribute('data-url'); + url = url.replace('{project_id}', ProjectUtils.projectId()); + element.setAttribute('data-url', url); + // console.log('The new element is', element); + } + + $('.fileupload') + .each(inject_project_id_into_url) + .each(setup_file_uploader) + ; +}); diff --git a/src/scripts/markdown/01_markdown-converter.js b/src/scripts/markdown/01_markdown-converter.js new file mode 100644 index 00000000..8e92a079 --- /dev/null +++ b/src/scripts/markdown/01_markdown-converter.js @@ -0,0 +1,1622 @@ +"use strict"; +var Markdown; + +if (typeof exports === "object" && typeof require === "function") // we're in a CommonJS (e.g. Node.js) module + Markdown = exports; +else + Markdown = {}; + +// The following text is included for historical reasons, but should +// be taken with a pinch of salt; it's not all true anymore. + +// +// Wherever possible, Showdown is a straight, line-by-line port +// of the Perl version of Markdown. +// +// This is not a normal parser design; it's basically just a +// series of string substitutions. It's hard to read and +// maintain this way, but keeping Showdown close to the original +// design makes it easier to port new features. +// +// More importantly, Showdown behaves like markdown.pl in most +// edge cases. So web applications can do client-side preview +// in Javascript, and then build identical HTML on the server. +// +// This port needs the new RegExp functionality of ECMA 262, +// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers +// should do fine. Even with the new regular expression features, +// We do a lot of work to emulate Perl's regex functionality. +// The tricky changes in this file mostly have the "attacklab:" +// label. Major or self-explanatory changes don't. +// +// Smart diff tools like Araxis Merge will be able to match up +// this file with markdown.pl in a useful way. A little tweaking +// helps: in a copy of markdown.pl, replace "#" with "//" and +// replace "$text" with "text". Be sure to ignore whitespace +// and line endings. +// + + +// +// Usage: +// +// var text = "Markdown *rocks*."; +// +// var converter = new Markdown.Converter(); +// var html = converter.makeHtml(text); +// +// alert(html); +// +// Note: move the sample code to the bottom of this +// file before uncommenting it. +// + +(function () { + + function identity(x) { return x; } + function returnFalse(x) { return false; } + + function HookCollection() { } + + HookCollection.prototype = { + + chain: function (hookname, func) { + var original = this[hookname]; + if (!original) + throw new Error("unknown hook " + hookname); + + if (original === identity) + this[hookname] = func; + else + this[hookname] = function (text) { + var args = Array.prototype.slice.call(arguments, 0); + args[0] = original.apply(null, args); + return func.apply(null, args); + }; + }, + set: function (hookname, func) { + if (!this[hookname]) + throw new Error("unknown hook " + hookname); + this[hookname] = func; + }, + addNoop: function (hookname) { + this[hookname] = identity; + }, + addFalse: function (hookname) { + this[hookname] = returnFalse; + } + }; + + Markdown.HookCollection = HookCollection; + + // g_urls and g_titles allow arbitrary user-entered strings as keys. This + // caused an exception (and hence stopped the rendering) when the user entered + // e.g. [push] or [__proto__]. Adding a prefix to the actual key prevents this + // (since no builtin property starts with "s_"). See + // http://meta.stackexchange.com/questions/64655/strange-wmd-bug + // (granted, switching from Array() to Object() alone would have left only __proto__ + // to be a problem) + function SaveHash() { } + SaveHash.prototype = { + set: function (key, value) { + this["s_" + key] = value; + }, + get: function (key) { + return this["s_" + key]; + } + }; + + Markdown.Converter = function (OPTIONS) { + var pluginHooks = this.hooks = new HookCollection(); + + // given a URL that was encountered by itself (without markup), should return the link text that's to be given to this link + pluginHooks.addNoop("plainLinkText"); + + // called with the orignal text as given to makeHtml. The result of this plugin hook is the actual markdown source that will be cooked + pluginHooks.addNoop("preConversion"); + + // called with the text once all normalizations have been completed (tabs to spaces, line endings, etc.), but before any conversions have + pluginHooks.addNoop("postNormalization"); + + // Called with the text before / after creating block elements like code blocks and lists. Note that this is called recursively + // with inner content, e.g. it's called with the full text, and then only with the content of a blockquote. The inner + // call will receive outdented text. + pluginHooks.addNoop("preBlockGamut"); + pluginHooks.addNoop("postBlockGamut"); + + // called with the text of a single block element before / after the span-level conversions (bold, code spans, etc.) have been made + pluginHooks.addNoop("preSpanGamut"); + pluginHooks.addNoop("postSpanGamut"); + + // called with the final cooked HTML code. The result of this plugin hook is the actual output of makeHtml + pluginHooks.addNoop("postConversion"); + + // + // Private state of the converter instance: + // + + // Global hashes, used by various utility routines + var g_urls; + var g_titles; + var g_html_blocks; + + // Used to track when we're inside an ordered or unordered list + // (see _ProcessListItems() for details): + var g_list_level; + + OPTIONS = OPTIONS || {}; + var asciify = identity, deasciify = identity; + if (OPTIONS.nonAsciiLetters) { + + /* In JavaScript regular expressions, \w only denotes [a-zA-Z0-9_]. + * That's why there's inconsistent handling e.g. with intra-word bolding + * of Japanese words. That's why we do the following if OPTIONS.nonAsciiLetters + * is true: + * + * Before doing bold and italics, we find every instance + * of a unicode word character in the Markdown source that is not + * matched by \w, and the letter "Q". We take the character's code point + * and encode it in base 51, using the "digits" + * + * A, B, ..., P, R, ..., Y, Z, a, b, ..., y, z + * + * delimiting it with "Q" on both sides. For example, the source + * + * > In Chinese, the smurfs are called 藍精靈, meaning "blue spirits". + * + * turns into + * + * > In Chinese, the smurfs are called QNIhQQMOIQQOuUQ, meaning "blue spirits". + * + * Since everything that is a letter in Unicode is now a letter (or + * several letters) in ASCII, \w and \b should always do the right thing. + * + * After the bold/italic conversion, we decode again; since "Q" was encoded + * alongside all non-ascii characters (as "QBfQ"), and the conversion + * will not generate "Q", the only instances of that letter should be our + * encoded characters. And since the conversion will not break words, the + * "Q...Q" should all still be in one piece. + * + * We're using "Q" as the delimiter because it's probably one of the + * rarest characters, and also because I can't think of any special behavior + * that would ever be triggered by this letter (to use a silly example, if we + * delimited with "H" on the left and "P" on the right, then "Ψ" would be + * encoded as "HTTP", which may cause special behavior). The latter would not + * actually be a huge issue for bold/italic, but may be if we later use it + * in other places as well. + * */ + (function () { + var lettersThatJavaScriptDoesNotKnowAndQ = /[Q\u00aa\u00b5\u00ba\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02c1\u02c6-\u02d1\u02e0-\u02e4\u02ec\u02ee\u0370-\u0374\u0376-\u0377\u037a-\u037d\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3-\u03f5\u03f7-\u0481\u048a-\u0523\u0531-\u0556\u0559\u0561-\u0587\u05d0-\u05ea\u05f0-\u05f2\u0621-\u064a\u0660-\u0669\u066e-\u066f\u0671-\u06d3\u06d5\u06e5-\u06e6\u06ee-\u06fc\u06ff\u0710\u0712-\u072f\u074d-\u07a5\u07b1\u07c0-\u07ea\u07f4-\u07f5\u07fa\u0904-\u0939\u093d\u0950\u0958-\u0961\u0966-\u096f\u0971-\u0972\u097b-\u097f\u0985-\u098c\u098f-\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd\u09ce\u09dc-\u09dd\u09df-\u09e1\u09e6-\u09f1\u0a05-\u0a0a\u0a0f-\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32-\u0a33\u0a35-\u0a36\u0a38-\u0a39\u0a59-\u0a5c\u0a5e\u0a66-\u0a6f\u0a72-\u0a74\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2-\u0ab3\u0ab5-\u0ab9\u0abd\u0ad0\u0ae0-\u0ae1\u0ae6-\u0aef\u0b05-\u0b0c\u0b0f-\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32-\u0b33\u0b35-\u0b39\u0b3d\u0b5c-\u0b5d\u0b5f-\u0b61\u0b66-\u0b6f\u0b71\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99-\u0b9a\u0b9c\u0b9e-\u0b9f\u0ba3-\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bd0\u0be6-\u0bef\u0c05-\u0c0c\u0c0e-\u0c10\u0c12-\u0c28\u0c2a-\u0c33\u0c35-\u0c39\u0c3d\u0c58-\u0c59\u0c60-\u0c61\u0c66-\u0c6f\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa-\u0cb3\u0cb5-\u0cb9\u0cbd\u0cde\u0ce0-\u0ce1\u0ce6-\u0cef\u0d05-\u0d0c\u0d0e-\u0d10\u0d12-\u0d28\u0d2a-\u0d39\u0d3d\u0d60-\u0d61\u0d66-\u0d6f\u0d7a-\u0d7f\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0e01-\u0e30\u0e32-\u0e33\u0e40-\u0e46\u0e50-\u0e59\u0e81-\u0e82\u0e84\u0e87-\u0e88\u0e8a\u0e8d\u0e94-\u0e97\u0e99-\u0e9f\u0ea1-\u0ea3\u0ea5\u0ea7\u0eaa-\u0eab\u0ead-\u0eb0\u0eb2-\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0ed0-\u0ed9\u0edc-\u0edd\u0f00\u0f20-\u0f29\u0f40-\u0f47\u0f49-\u0f6c\u0f88-\u0f8b\u1000-\u102a\u103f-\u1049\u1050-\u1055\u105a-\u105d\u1061\u1065-\u1066\u106e-\u1070\u1075-\u1081\u108e\u1090-\u1099\u10a0-\u10c5\u10d0-\u10fa\u10fc\u1100-\u1159\u115f-\u11a2\u11a8-\u11f9\u1200-\u1248\u124a-\u124d\u1250-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1380-\u138f\u13a0-\u13f4\u1401-\u166c\u166f-\u1676\u1681-\u169a\u16a0-\u16ea\u1700-\u170c\u170e-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176c\u176e-\u1770\u1780-\u17b3\u17d7\u17dc\u17e0-\u17e9\u1810-\u1819\u1820-\u1877\u1880-\u18a8\u18aa\u1900-\u191c\u1946-\u196d\u1970-\u1974\u1980-\u19a9\u19c1-\u19c7\u19d0-\u19d9\u1a00-\u1a16\u1b05-\u1b33\u1b45-\u1b4b\u1b50-\u1b59\u1b83-\u1ba0\u1bae-\u1bb9\u1c00-\u1c23\u1c40-\u1c49\u1c4d-\u1c7d\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u203f-\u2040\u2054\u2071\u207f\u2090-\u2094\u2102\u2107\u210a-\u2113\u2115\u2119-\u211d\u2124\u2126\u2128\u212a-\u212d\u212f-\u2139\u213c-\u213f\u2145-\u2149\u214e\u2183-\u2184\u2c00-\u2c2e\u2c30-\u2c5e\u2c60-\u2c6f\u2c71-\u2c7d\u2c80-\u2ce4\u2d00-\u2d25\u2d30-\u2d65\u2d6f\u2d80-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8-\u2dde\u2e2f\u3005-\u3006\u3031-\u3035\u303b-\u303c\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fc-\u30ff\u3105-\u312d\u3131-\u318e\u31a0-\u31b7\u31f0-\u31ff\u3400-\u4db5\u4e00-\u9fc3\ua000-\ua48c\ua500-\ua60c\ua610-\ua62b\ua640-\ua65f\ua662-\ua66e\ua67f-\ua697\ua717-\ua71f\ua722-\ua788\ua78b-\ua78c\ua7fb-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua822\ua840-\ua873\ua882-\ua8b3\ua8d0-\ua8d9\ua900-\ua925\ua930-\ua946\uaa00-\uaa28\uaa40-\uaa42\uaa44-\uaa4b\uaa50-\uaa59\uac00-\ud7a3\uf900-\ufa2d\ufa30-\ufa6a\ufa70-\ufad9\ufb00-\ufb06\ufb13-\ufb17\ufb1d\ufb1f-\ufb28\ufb2a-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufbb1\ufbd3-\ufd3d\ufd50-\ufd8f\ufd92-\ufdc7\ufdf0-\ufdfb\ufe33-\ufe34\ufe4d-\ufe4f\ufe70-\ufe74\ufe76-\ufefc\uff10-\uff19\uff21-\uff3a\uff3f\uff41-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]/g; + var cp_Q = "Q".charCodeAt(0); + var cp_A = "A".charCodeAt(0); + var cp_Z = "Z".charCodeAt(0); + var dist_Za = "a".charCodeAt(0) - cp_Z - 1; + + asciify = function(text) { + return text.replace(lettersThatJavaScriptDoesNotKnowAndQ, function (m) { + var c = m.charCodeAt(0); + var s = ""; + var v; + while (c > 0) { + v = (c % 51) + cp_A; + if (v >= cp_Q) + v++; + if (v > cp_Z) + v += dist_Za; + s = String.fromCharCode(v) + s; + c = c / 51 | 0; + } + return "Q" + s + "Q"; + }) + }; + + deasciify = function(text) { + return text.replace(/Q([A-PR-Za-z]{1,3})Q/g, function (m, s) { + var c = 0; + var v; + for (var i = 0; i < s.length; i++) { + v = s.charCodeAt(i); + if (v > cp_Z) + v -= dist_Za; + if (v > cp_Q) + v--; + v -= cp_A; + c = (c * 51) + v; + } + return String.fromCharCode(c); + }) + } + })(); + } + + var _DoItalicsAndBold = OPTIONS.asteriskIntraWordEmphasis ? _DoItalicsAndBold_AllowIntrawordWithAsterisk : _DoItalicsAndBoldStrict; + + this.makeHtml = function (text) { + + // + // Main function. The order in which other subs are called here is + // essential. Link and image substitutions need to happen before + // _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the + // and tags get encoded. + // + + // This will only happen if makeHtml on the same converter instance is called from a plugin hook. + // Don't do that. + if (g_urls) + throw new Error("Recursive call to converter.makeHtml"); + + // Create the private state objects. + g_urls = new SaveHash(); + g_titles = new SaveHash(); + g_html_blocks = []; + g_list_level = 0; + + text = pluginHooks.preConversion(text); + + // attacklab: Replace ~ with ~T + // This lets us use tilde as an escape char to avoid md5 hashes + // The choice of character is arbitray; anything that isn't + // magic in Markdown will work. + text = text.replace(/~/g, "~T"); + + // attacklab: Replace $ with ~D + // RegExp interprets $ as a special character + // when it's in a replacement string + text = text.replace(/\$/g, "~D"); + + // Standardize line endings + text = text.replace(/\r\n/g, "\n"); // DOS to Unix + text = text.replace(/\r/g, "\n"); // Mac to Unix + + // Make sure text begins and ends with a couple of newlines: + text = "\n\n" + text + "\n\n"; + + // Convert all tabs to spaces. + text = _Detab(text); + + // Strip any lines consisting only of spaces and tabs. + // This makes subsequent regexen easier to write, because we can + // match consecutive blank lines with /\n+/ instead of something + // contorted like /[ \t]*\n+/ . + text = text.replace(/^[ \t]+$/mg, ""); + + text = pluginHooks.postNormalization(text); + + // Turn block-level HTML blocks into hash entries + text = _HashHTMLBlocks(text); + + // Strip link definitions, store in hashes. + text = _StripLinkDefinitions(text); + + text = _RunBlockGamut(text); + + text = _UnescapeSpecialChars(text); + + // attacklab: Restore dollar signs + text = text.replace(/~D/g, "$$"); + + // attacklab: Restore tildes + text = text.replace(/~T/g, "~"); + + text = pluginHooks.postConversion(text); + + g_html_blocks = g_titles = g_urls = null; + + return text; + }; + + function _StripLinkDefinitions(text) { + // + // Strips link definitions from text, stores the URLs and titles in + // hash references. + // + + // Link defs are in the form: ^[id]: url "optional title" + + /* + text = text.replace(/ + ^[ ]{0,3}\[([^\[\]]+)\]: // id = $1 attacklab: g_tab_width - 1 + [ \t]* + \n? // maybe *one* newline + [ \t]* + ? // url = $2 + (?=\s|$) // lookahead for whitespace instead of the lookbehind removed below + [ \t]* + \n? // maybe one newline + [ \t]* + ( // (potential) title = $3 + (\n*) // any lines skipped = $4 attacklab: lookbehind removed + [ \t]+ + ["(] + (.+?) // title = $5 + [")] + [ \t]* + )? // title is optional + (\n+) // subsequent newlines = $6, capturing because they must be put back if the potential title isn't an actual title + /gm, function(){...}); + */ + + text = text.replace(/^[ ]{0,3}\[([^\[\]]+)\]:[ \t]*\n?[ \t]*?(?=\s|$)[ \t]*\n?[ \t]*((\n*)["(](.+?)[")][ \t]*)?(\n+)/gm, + function (wholeMatch, m1, m2, m3, m4, m5, m6) { + m1 = m1.toLowerCase(); + g_urls.set(m1, _EncodeAmpsAndAngles(m2)); // Link IDs are case-insensitive + if (m4) { + // Oops, found blank lines, so it's not a title. + // Put back the parenthetical statement we stole. + return m3 + m6; + } else if (m5) { + g_titles.set(m1, m5.replace(/"/g, """)); + } + + // Completely remove the definition from the text + return ""; + } + ); + + return text; + } + + function _HashHTMLBlocks(text) { + + // Hashify HTML blocks: + // We only want to do this for block-level HTML tags, such as headers, + // lists, and tables. That's because we still want to wrap

      s around + // "paragraphs" that are wrapped in non-block-level tags, such as anchors, + // phrase emphasis, and spans. The list of tags we're looking for is + // hard-coded: + var block_tags_a = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|math|ins|del" + var block_tags_b = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|math" + + // First, look for nested blocks, e.g.: + //

      + //
      + // tags for inner block must be indented. + //
      + //
      + // + // The outermost tags must start at the left margin for this to match, and + // the inner nested divs must be indented. + // We need to do this before the next, more liberal match, because the next + // match will start at the first `
      ` and stop at the first `
      `. + + // attacklab: This regex can be expensive when it fails. + + /* + text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_a) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*?\n // any number of lines, minimally matching + // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm, hashMatch); + + // + // Now match more liberally, simply from `\n` to `\n` + // + + /* + text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_b) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*? // any number of lines, minimally matching + .* // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|math)\b[^\r]*?.*<\/\2>[ \t]*(?=\n+)\n)/gm, hashMatch); + + // Special case just for
      . It was easier to make a special case than + // to make the other regex more complicated. + + /* + text = text.replace(/ + \n // Starting after a blank line + [ ]{0,3} + ( // save in $1 + (<(hr) // start tag = $2 + \b // word break + ([^<>])*? + \/?>) // the matching end tag + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashMatch); + */ + text = text.replace(/\n[ ]{0,3}((<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g, hashMatch); + + // Special case for standalone HTML comments: + + /* + text = text.replace(/ + \n\n // Starting after a blank line + [ ]{0,3} // attacklab: g_tab_width - 1 + ( // save in $1 + -]|-[^>])(?:[^-]|-[^-])*)--) // see http://www.w3.org/TR/html-markup/syntax.html#comments and http://meta.stackexchange.com/q/95256 + > + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashMatch); + */ + text = text.replace(/\n\n[ ]{0,3}(-]|-[^>])(?:[^-]|-[^-])*)--)>[ \t]*(?=\n{2,}))/g, hashMatch); + + // PHP and ASP-style processor instructions ( and <%...%>) + + /* + text = text.replace(/ + (?: + \n\n // Starting after a blank line + ) + ( // save in $1 + [ ]{0,3} // attacklab: g_tab_width - 1 + (?: + <([?%]) // $2 + [^\r]*? + \2> + ) + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashMatch); + */ + text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g, hashMatch); + + return text; + } + + function hashBlock(text) { + text = text.replace(/(^\n+|\n+$)/g, ""); + // Replace the element text with a marker ("~KxK" where x is its key) + return "\n\n~K" + (g_html_blocks.push(text) - 1) + "K\n\n"; + } + + function hashMatch(wholeMatch, m1) { + return hashBlock(m1); + } + + var blockGamutHookCallback = function (t) { return _RunBlockGamut(t); } + + function _RunBlockGamut(text, doNotUnhash, doNotCreateParagraphs) { + // + // These are all the transformations that form block-level + // tags like paragraphs, headers, and list items. + // + + text = pluginHooks.preBlockGamut(text, blockGamutHookCallback); + + text = _DoHeaders(text); + + // Do Horizontal Rules: + var replacement = "
      \n"; + text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm, replacement); + text = text.replace(/^[ ]{0,2}([ ]?-[ ]?){3,}[ \t]*$/gm, replacement); + text = text.replace(/^[ ]{0,2}([ ]?_[ ]?){3,}[ \t]*$/gm, replacement); + + text = _DoLists(text); + text = _DoCodeBlocks(text); + text = _DoBlockQuotes(text); + + text = pluginHooks.postBlockGamut(text, blockGamutHookCallback); + + // We already ran _HashHTMLBlocks() before, in Markdown(), but that + // was to escape raw HTML in the original Markdown source. This time, + // we're escaping the markup we've just created, so that we don't wrap + //

      tags around block-level tags. + text = _HashHTMLBlocks(text); + + text = _FormParagraphs(text, doNotUnhash, doNotCreateParagraphs); + + return text; + } + + function _RunSpanGamut(text) { + // + // These are all the transformations that occur *within* block-level + // tags like paragraphs, headers, and list items. + // + + text = pluginHooks.preSpanGamut(text); + + text = _DoCodeSpans(text); + text = _EscapeSpecialCharsWithinTagAttributes(text); + text = _EncodeBackslashEscapes(text); + + // Process anchor and image tags. Images must come first, + // because ![foo][f] looks like an anchor. + text = _DoImages(text); + text = _DoAnchors(text); + + // Make links out of things like `` + // Must come after _DoAnchors(), because you can use < and > + // delimiters in inline links like [this](). + text = _DoAutoLinks(text); + + text = text.replace(/~P/g, "://"); // put in place to prevent autolinking; reset now + + text = _EncodeAmpsAndAngles(text); + text = _DoItalicsAndBold(text); + + // Do hard breaks: + text = text.replace(/ +\n/g, "
      \n"); + + text = pluginHooks.postSpanGamut(text); + + return text; + } + + function _EscapeSpecialCharsWithinTagAttributes(text) { + // + // Within tags -- meaning between < and > -- encode [\ ` * _] so they + // don't conflict with their use in Markdown for code, italics and strong. + // + + // Build a regex to find HTML tags and comments. See Friedl's + // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. + + // SE: changed the comment part of the regex + + var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|-]|-[^>])(?:[^-]|-[^-])*)--)>)/gi; + + text = text.replace(regex, function (wholeMatch) { + var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g, "$1`"); + tag = escapeCharacters(tag, wholeMatch.charAt(1) == "!" ? "\\`*_/" : "\\`*_"); // also escape slashes in comments to prevent autolinking there -- http://meta.stackexchange.com/questions/95987 + return tag; + }); + + return text; + } + + function _DoAnchors(text) { + + if (text.indexOf("[") === -1) + return text; + + // + // Turn Markdown link shortcuts into XHTML
      tags. + // + // + // First, handle reference-style links: [link text] [id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[] // or anything else + )* + ) + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + ) + ()()()() // pad remaining backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeAnchorTag); + + // + // Next, inline-style links: [link text](url "optional title") + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[\]] // or anything else + )* + ) + \] + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // Title = $7 + \6 // matching quote + [ \t]* // ignore any spaces/tabs between closing quote and ) + )? // title is optional + \) + ) + /g, writeAnchorTag); + */ + + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeAnchorTag); + + // + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link test][1] + // or [link test](/foo) + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ([^\[\]]+) // link text = $2; can't contain '[' or ']' + \] + ) + ()()()()() // pad rest of backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); + + return text; + } + + function writeAnchorTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) { + if (m7 == undefined) m7 = ""; + var whole_match = m1; + var link_text = m2.replace(/:\/\//g, "~P"); // to prevent auto-linking withing the link. will be converted back after the auto-linker runs + var link_id = m3.toLowerCase(); + var url = m4; + var title = m7; + + if (url == "") { + if (link_id == "") { + // lower-case and turn embedded newlines into spaces + link_id = link_text.toLowerCase().replace(/ ?\n/g, " "); + } + url = "#" + link_id; + + if (g_urls.get(link_id) != undefined) { + url = g_urls.get(link_id); + if (g_titles.get(link_id) != undefined) { + title = g_titles.get(link_id); + } + } + else { + if (whole_match.search(/\(\s*\)$/m) > -1) { + // Special case for explicit empty url + url = ""; + } else { + return whole_match; + } + } + } + url = attributeSafeUrl(url); + + var result = ""; + + return result; + } + + function _DoImages(text) { + + if (text.indexOf("![") === -1) + return text; + + // + // Turn Markdown image shortcuts into tags. + // + + // + // First, handle reference-style labeled images: ![alt text][id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + ) + ()()()() // pad rest of backreferences + /g, writeImageTag); + */ + text = text.replace(/(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeImageTag); + + // + // Next, handle inline images: ![alt text](url "optional title") + // Don't forget: encode * and _ + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + \s? // One optional whitespace character + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? // src url = $4 + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // title = $7 + \6 // matching quote + [ \t]* + )? // title is optional + \) + ) + /g, writeImageTag); + */ + text = text.replace(/(!\[(.*?)\]\s?\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeImageTag); + + return text; + } + + function attributeEncode(text) { + // unconditionally replace angle brackets here -- what ends up in an attribute (e.g. alt or title) + // never makes sense to have verbatim HTML in it (and the sanitizer would totally break it) + return text.replace(/>/g, ">").replace(/" + _RunSpanGamut(m1) + "\n\n"; } + ); + + text = text.replace(/^(.+)[ \t]*\n-+[ \t]*\n+/gm, + function (matchFound, m1) { return "

      " + _RunSpanGamut(m1) + "

      \n\n"; } + ); + + // atx-style headers: + // # Header 1 + // ## Header 2 + // ## Header 2 with closing hashes ## + // ... + // ###### Header 6 + // + + /* + text = text.replace(/ + ^(\#{1,6}) // $1 = string of #'s + [ \t]* + (.+?) // $2 = Header text + [ \t]* + \#* // optional closing #'s (not counted) + \n+ + /gm, function() {...}); + */ + + text = text.replace(/^(\#{1,6})[ \t]*(.+?)[ \t]*\#*\n+/gm, + function (wholeMatch, m1, m2) { + var h_level = m1.length; + return "" + _RunSpanGamut(m2) + "\n\n"; + } + ); + + return text; + } + + function _DoLists(text, isInsideParagraphlessListItem) { + // + // Form HTML ordered (numbered) and unordered (bulleted) lists. + // + + // attacklab: add sentinel to hack around khtml/safari bug: + // http://bugs.webkit.org/show_bug.cgi?id=11231 + text += "~0"; + + // Re-usable pattern to match any entirel ul or ol list: + + /* + var whole_list = / + ( // $1 = whole list + ( // $2 + [ ]{0,3} // attacklab: g_tab_width - 1 + ([*+-]|\d+[.]) // $3 = first list item marker + [ \t]+ + ) + [^\r]+? + ( // $4 + ~0 // sentinel for workaround; should be $ + | + \n{2,} + (?=\S) + (?! // Negative lookahead for another list item marker + [ \t]* + (?:[*+-]|\d+[.])[ \t]+ + ) + ) + ) + /g + */ + var whole_list = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; + if (g_list_level) { + text = text.replace(whole_list, function (wholeMatch, m1, m2) { + var list = m1; + var list_type = (m2.search(/[*+-]/g) > -1) ? "ul" : "ol"; + var first_number; + if (list_type === "ol") + first_number = parseInt(m2, 10) + + var result = _ProcessListItems(list, list_type, isInsideParagraphlessListItem); + + // Trim any trailing whitespace, to put the closing `` + // up on the preceding line, to get it past the current stupid + // HTML block parser. This is a hack to work around the terrible + // hack that is the HTML block parser. + result = result.replace(/\s+$/, ""); + var opening = "<" + list_type; + if (first_number && first_number !== 1) + opening += " start=\"" + first_number + "\""; + result = opening + ">" + result + "\n"; + return result; + }); + } else { + whole_list = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g; + text = text.replace(whole_list, function (wholeMatch, m1, m2, m3) { + var runup = m1; + var list = m2; + + var list_type = (m3.search(/[*+-]/g) > -1) ? "ul" : "ol"; + + var first_number; + if (list_type === "ol") + first_number = parseInt(m3, 10) + + var result = _ProcessListItems(list, list_type); + var opening = "<" + list_type; + if (first_number && first_number !== 1) + opening += " start=\"" + first_number + "\""; + + result = runup + opening + ">\n" + result + "\n"; + return result; + }); + } + + // attacklab: strip sentinel + text = text.replace(/~0/, ""); + + return text; + } + + var _listItemMarkers = { ol: "\\d+[.]", ul: "[*+-]" }; + + function _ProcessListItems(list_str, list_type, isInsideParagraphlessListItem) { + // + // Process the contents of a single ordered or unordered list, splitting it + // into individual list items. + // + // list_type is either "ul" or "ol". + + // The $g_list_level global keeps track of when we're inside a list. + // Each time we enter a list, we increment it; when we leave a list, + // we decrement. If it's zero, we're not in a list anymore. + // + // We do this because when we're not inside a list, we want to treat + // something like this: + // + // I recommend upgrading to version + // 8. Oops, now this line is treated + // as a sub-list. + // + // As a single paragraph, despite the fact that the second line starts + // with a digit-period-space sequence. + // + // Whereas when we're inside a list (or sub-list), that line will be + // treated as the start of a sub-list. What a kludge, huh? This is + // an aspect of Markdown's syntax that's hard to parse perfectly + // without resorting to mind-reading. Perhaps the solution is to + // change the syntax rules such that sub-lists must start with a + // starting cardinal number; e.g. "1." or "a.". + + g_list_level++; + + // trim trailing blank lines: + list_str = list_str.replace(/\n{2,}$/, "\n"); + + // attacklab: add sentinel to emulate \z + list_str += "~0"; + + // In the original attacklab showdown, list_type was not given to this function, and anything + // that matched /[*+-]|\d+[.]/ would just create the next
    • , causing this mismatch: + // + // Markdown rendered by WMD rendered by MarkdownSharp + // ------------------------------------------------------------------ + // 1. first 1. first 1. first + // 2. second 2. second 2. second + // - third 3. third * third + // + // We changed this to behave identical to MarkdownSharp. This is the constructed RegEx, + // with {MARKER} being one of \d+[.] or [*+-], depending on list_type: + + /* + list_str = list_str.replace(/ + (^[ \t]*) // leading whitespace = $1 + ({MARKER}) [ \t]+ // list marker = $2 + ([^\r]+? // list item text = $3 + (\n+) + ) + (?= + (~0 | \2 ({MARKER}) [ \t]+) + ) + /gm, function(){...}); + */ + + var marker = _listItemMarkers[list_type]; + var re = new RegExp("(^[ \\t]*)(" + marker + ")[ \\t]+([^\\r]+?(\\n+))(?=(~0|\\1(" + marker + ")[ \\t]+))", "gm"); + var last_item_had_a_double_newline = false; + list_str = list_str.replace(re, + function (wholeMatch, m1, m2, m3) { + var item = m3; + var leading_space = m1; + var ends_with_double_newline = /\n\n$/.test(item); + var contains_double_newline = ends_with_double_newline || item.search(/\n{2,}/) > -1; + + var loose = contains_double_newline || last_item_had_a_double_newline; + item = _RunBlockGamut(_Outdent(item), /* doNotUnhash = */true, /* doNotCreateParagraphs = */ !loose); + + last_item_had_a_double_newline = ends_with_double_newline; + return "
    • " + item + "
    • \n"; + } + ); + + // attacklab: strip sentinel + list_str = list_str.replace(/~0/g, ""); + + g_list_level--; + return list_str; + } + + function _DoCodeBlocks(text) { + // + // Process Markdown `
      ` blocks.
      +            //
      +
      +            /*
      +            text = text.replace(/
      +                (?:\n\n|^)
      +                (                               // $1 = the code block -- one or more lines, starting with a space/tab
      +                    (?:
      +                        (?:[ ]{4}|\t)           // Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
      +                        .*\n+
      +                    )+
      +                )
      +                (\n*[ ]{0,3}[^ \t\n]|(?=~0))    // attacklab: g_tab_width
      +            /g ,function(){...});
      +            */
      +
      +            // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
      +            text += "~0";
      +
      +            text = text.replace(/(?:\n\n|^\n?)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g,
      +                function (wholeMatch, m1, m2) {
      +                    var codeblock = m1;
      +                    var nextChar = m2;
      +
      +                    codeblock = _EncodeCode(_Outdent(codeblock));
      +                    codeblock = _Detab(codeblock);
      +                    codeblock = codeblock.replace(/^\n+/g, ""); // trim leading newlines
      +                    codeblock = codeblock.replace(/\n+$/g, ""); // trim trailing whitespace
      +
      +                    codeblock = "
      " + codeblock + "\n
      "; + + return "\n\n" + codeblock + "\n\n" + nextChar; + } + ); + + // attacklab: strip sentinel + text = text.replace(/~0/, ""); + + return text; + } + + function _DoCodeSpans(text) { + // + // * Backtick quotes are used for spans. + // + // * You can use multiple backticks as the delimiters if you want to + // include literal backticks in the code span. So, this input: + // + // Just type ``foo `bar` baz`` at the prompt. + // + // Will translate to: + // + //

      Just type foo `bar` baz at the prompt.

      + // + // There's no arbitrary limit to the number of backticks you + // can use as delimters. If you need three consecutive backticks + // in your code, use four for delimiters, etc. + // + // * You can use spaces to get literal backticks at the edges: + // + // ... type `` `bar` `` ... + // + // Turns to: + // + // ... type `bar` ... + // + + /* + text = text.replace(/ + (^|[^\\`]) // Character before opening ` can't be a backslash or backtick + (`+) // $2 = Opening run of ` + (?!`) // and no more backticks -- match the full run + ( // $3 = The code block + [^\r]*? + [^`] // attacklab: work around lack of lookbehind + ) + \2 // Matching closer + (?!`) + /gm, function(){...}); + */ + + text = text.replace(/(^|[^\\`])(`+)(?!`)([^\r]*?[^`])\2(?!`)/gm, + function (wholeMatch, m1, m2, m3, m4) { + var c = m3; + c = c.replace(/^([ \t]*)/g, ""); // leading whitespace + c = c.replace(/[ \t]*$/g, ""); // trailing whitespace + c = _EncodeCode(c); + c = c.replace(/:\/\//g, "~P"); // to prevent auto-linking. Not necessary in code *blocks*, but in code spans. Will be converted back after the auto-linker runs. + return m1 + "" + c + ""; + } + ); + + return text; + } + + function _EncodeCode(text) { + // + // Encode/escape certain characters inside Markdown code runs. + // The point is that in code, these characters are literals, + // and lose their special Markdown meanings. + // + // Encode all ampersands; HTML entities are not + // entities within a Markdown code span. + text = text.replace(/&/g, "&"); + + // Do the angle bracket song and dance: + text = text.replace(//g, ">"); + + // Now, escape characters that are magic in Markdown: + text = escapeCharacters(text, "\*_{}[]\\", false); + + // jj the line above breaks this: + //--- + + //* Item + + // 1. Subitem + + // special char: * + //--- + + return text; + } + + function _DoItalicsAndBoldStrict(text) { + + if (text.indexOf("*") === -1 && text.indexOf("_") === - 1) + return text; + + text = asciify(text); + + // must go first: + + // (^|[\W_]) Start with a non-letter or beginning of string. Store in \1. + // (?:(?!\1)|(?=^)) Either the next character is *not* the same as the previous, + // or we started at the end of the string (in which case the previous + // group had zero width, so we're still there). Because the next + // character is the marker, this means that if there are e.g. multiple + // underscores in a row, we can only match the left-most ones (which + // prevents foo___bar__ from getting bolded) + // (\*|_) The marker character itself, asterisk or underscore. Store in \2. + // \2 The marker again, since bold needs two. + // (?=\S) The first bolded character cannot be a space. + // ([^\r]*?\S) The actual bolded string. At least one character, and it cannot *end* + // with a space either. Note that like in many other places, [^\r] is + // just a workaround for JS' lack of single-line regexes; it's equivalent + // to a . in an /s regex, because the string cannot contain any \r (they + // are removed in the normalizing step). + // \2\2 The marker character, twice -- end of bold. + // (?!\2) Not followed by another marker character (ensuring that we match the + // rightmost two in a longer row)... + // (?=[\W_]|$) ...but by any other non-word character or the end of string. + text = text.replace(/(^|[\W_])(?:(?!\1)|(?=^))(\*|_)\2(?=\S)([^\r]*?\S)\2\2(?!\2)(?=[\W_]|$)/g, + "$1$3"); + + // This is almost identical to the regex, except 1) there's obviously just one marker + // character, and 2) the italicized string cannot contain the marker character. + text = text.replace(/(^|[\W_])(?:(?!\1)|(?=^))(\*|_)(?=\S)((?:(?!\2)[^\r])*?\S)\2(?!\2)(?=[\W_]|$)/g, + "$1$3"); + + return deasciify(text); + } + + function _DoItalicsAndBold_AllowIntrawordWithAsterisk(text) { + + if (text.indexOf("*") === -1 && text.indexOf("_") === - 1) + return text; + + text = asciify(text); + + // must go first: + // (?=[^\r][*_]|[*_]) Optimization only, to find potentially relevant text portions faster. Minimally slower in Chrome, but much faster in IE. + // ( Store in \1. This is the last character before the delimiter + // ^ Either we're at the start of the string (i.e. there is no last character)... + // | ... or we allow one of the following: + // (?= (lookahead; we're not capturing this, just listing legal possibilities) + // \W__ If the delimiter is __, then this last character must be non-word non-underscore (extra-word emphasis only) + // | + // (?!\*)[\W_]\*\* If the delimiter is **, then this last character can be non-word non-asterisk (extra-word emphasis)... + // | + // \w\*\*\w ...or it can be word/underscore, but only if the first bolded character is such a character as well (intra-word emphasis) + // ) + // [^\r] actually capture the character (can't use `.` since it could be \n) + // ) + // (\*\*|__) Store in \2: the actual delimiter + // (?!\2) not followed by the delimiter again (at most one more asterisk/underscore is allowed) + // (?=\S) the first bolded character can't be a space + // ( Store in \3: the bolded string + // + // (?:| Look at all bolded characters except for the last one. Either that's empty, meaning only a single character was bolded... + // [^\r]*? ... otherwise take arbitrary characters, minimally matching; that's all bolded characters except for the last *two* + // (?!\2) the last two characters cannot be the delimiter itself (because that would mean four underscores/asterisks in a row) + // [^\r] capture the next-to-last bolded character + // ) + // (?= lookahead at the very last bolded char and what comes after + // \S_ for underscore-bolding, it can be any non-space + // | + // \w for asterisk-bolding (otherwise the previous alternative would've matched, since \w implies \S), either the last char is word/underscore... + // | + // \S\*\*(?:[\W_]|$) ... or it's any other non-space, but in that case the character *after* the delimiter may not be a word character + // ) + // . actually capture the last character (can use `.` this time because the lookahead ensures \S in all cases) + // ) + // (?= lookahead; list the legal possibilities for the closing delimiter and its following character + // __(?:\W|$) for underscore-bolding, the following character (if any) must be non-word non-underscore + // | + // \*\*(?:[^*]|$) for asterisk-bolding, any non-asterisk is allowed (note we already ensured above that it's not a word character if the last bolded character wasn't one) + // ) + // \2 actually capture the closing delimiter (and make sure that it matches the opening one) + + text = text.replace(/(?=[^\r][*_]|[*_])(^|(?=\W__|(?!\*)[\W_]\*\*|\w\*\*\w)[^\r])(\*\*|__)(?!\2)(?=\S)((?:|[^\r]*?(?!\2)[^\r])(?=\S_|\w|\S\*\*(?:[\W_]|$)).)(?=__(?:\W|$)|\*\*(?:[^*]|$))\2/g, + "$1$3"); + + // now : + // (?=[^\r][*_]|[*_]) Optimization, see above. + // ( Store in \1. This is the last character before the delimiter + // ^ Either we're at the start of the string (i.e. there is no last character)... + // | ... or we allow one of the following: + // (?= (lookahead; we're not capturing this, just listing legal possibilities) + // \W_ If the delimiter is _, then this last character must be non-word non-underscore (extra-word emphasis only) + // | + // (?!\*) otherwise, we list two possiblities for * as the delimiter; in either case, the last characters cannot be an asterisk itself + // (?: + // [\W_]\* this last character can be non-word (extra-word emphasis)... + // | + // \D\*(?=\w)\D ...or it can be word (otherwise the first alternative would've matched), but only if + // a) the first italicized character is such a character as well (intra-word emphasis), and + // b) neither character on either side of the asterisk is a digit + // ) + // ) + // [^\r] actually capture the character (can't use `.` since it could be \n) + // ) + // (\*|_) Store in \2: the actual delimiter + // (?!\2\2\2) not followed by more than two more instances of the delimiter + // (?=\S) the first italicized character can't be a space + // ( Store in \3: the italicized string + // (?:(?!\2)[^\r])*? arbitrary characters except for the delimiter itself, minimally matching + // (?= lookahead at the very last italicized char and what comes after + // [^\s_]_ for underscore-italicizing, it can be any non-space non-underscore + // | + // (?=\w)\D\*\D for asterisk-italicizing, either the last char is word/underscore *and* neither character on either side of the asterisk is a digit... + // | + // [^\s*]\*(?:[\W_]|$) ... or that last char is any other non-space non-asterisk, but then the character after the delimiter (if any) must be non-word + // ) + // . actually capture the last character (can use `.` this time because the lookahead ensures \S in all cases) + // ) + // (?= lookahead; list the legal possibilities for the closing delimiter and its following character + // _(?:\W|$) for underscore-italicizing, the following character (if any) must be non-word non-underscore + // | + // \*(?:[^*]|$) for asterisk-italicizing, any non-asterisk is allowed; all other restrictions have already been ensured in the previous lookahead + // ) + // \2 actually capture the closing delimiter (and make sure that it matches the opening one) + + text = text.replace(/(?=[^\r][*_]|[*_])(^|(?=\W_|(?!\*)(?:[\W_]\*|\D\*(?=\w)\D))[^\r])(\*|_)(?!\2\2\2)(?=\S)((?:(?!\2)[^\r])*?(?=[^\s_]_|(?=\w)\D\*\D|[^\s*]\*(?:[\W_]|$)).)(?=_(?:\W|$)|\*(?:[^*]|$))\2/g, + "$1$3"); + + return deasciify(text); + } + + + function _DoBlockQuotes(text) { + + /* + text = text.replace(/ + ( // Wrap whole match in $1 + ( + ^[ \t]*>[ \t]? // '>' at the start of a line + .+\n // rest of the first line + (.+\n)* // subsequent consecutive lines + \n* // blanks + )+ + ) + /gm, function(){...}); + */ + + text = text.replace(/((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm, + function (wholeMatch, m1) { + var bq = m1; + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + bq = bq.replace(/^[ \t]*>[ \t]?/gm, "~0"); // trim one level of quoting + + // attacklab: clean up hack + bq = bq.replace(/~0/g, ""); + + bq = bq.replace(/^[ \t]+$/gm, ""); // trim whitespace-only lines + bq = _RunBlockGamut(bq); // recurse + + bq = bq.replace(/(^|\n)/g, "$1 "); + // These leading spaces screw with
       content, so we need to fix that:
      +                    bq = bq.replace(
      +                            /(\s*
      [^\r]+?<\/pre>)/gm,
      +                        function (wholeMatch, m1) {
      +                            var pre = m1;
      +                            // attacklab: hack around Konqueror 3.5.4 bug:
      +                            pre = pre.replace(/^  /mg, "~0");
      +                            pre = pre.replace(/~0/g, "");
      +                            return pre;
      +                        });
      +
      +                    return hashBlock("
      \n" + bq + "\n
      "); + } + ); + return text; + } + + function _FormParagraphs(text, doNotUnhash, doNotCreateParagraphs) { + // + // Params: + // $text - string to process with html

      tags + // + + // Strip leading and trailing lines: + text = text.replace(/^\n+/g, ""); + text = text.replace(/\n+$/g, ""); + + var grafs = text.split(/\n{2,}/g); + var grafsOut = []; + + var markerRe = /~K(\d+)K/; + + // + // Wrap

      tags. + // + var end = grafs.length; + for (var i = 0; i < end; i++) { + var str = grafs[i]; + + // if this is an HTML marker, copy it + if (markerRe.test(str)) { + grafsOut.push(str); + } + else if (/\S/.test(str)) { + str = _RunSpanGamut(str); + str = str.replace(/^([ \t]*)/g, doNotCreateParagraphs ? "" : "

      "); + if (!doNotCreateParagraphs) + str += "

      " + grafsOut.push(str); + } + + } + // + // Unhashify HTML blocks + // + if (!doNotUnhash) { + end = grafsOut.length; + for (var i = 0; i < end; i++) { + var foundAny = true; + while (foundAny) { // we may need several runs, since the data may be nested + foundAny = false; + grafsOut[i] = grafsOut[i].replace(/~K(\d+)K/g, function (wholeMatch, id) { + foundAny = true; + return g_html_blocks[id]; + }); + } + } + } + return grafsOut.join("\n\n"); + } + + function _EncodeAmpsAndAngles(text) { + // Smart processing for ampersands and angle brackets that need to be encoded. + + // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: + // http://bumppo.net/projects/amputator/ + text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g, "&"); + + // Encode naked <'s + text = text.replace(/<(?![a-z\/?!]|~D)/gi, "<"); + + return text; + } + + function _EncodeBackslashEscapes(text) { + // + // Parameter: String. + // Returns: The string, with after processing the following backslash + // escape sequences. + // + + // attacklab: The polite way to do this is with the new + // escapeCharacters() function: + // + // text = escapeCharacters(text,"\\",true); + // text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); + // + // ...but we're sidestepping its use of the (slow) RegExp constructor + // as an optimization for Firefox. This function gets called a LOT. + + text = text.replace(/\\(\\)/g, escapeCharacters_callback); + text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g, escapeCharacters_callback); + return text; + } + + var charInsideUrl = "[-A-Z0-9+&@#/%?=~_|[\\]()!:,.;]", + charEndingUrl = "[-A-Z0-9+&@#/%=~_|[\\])]", + autoLinkRegex = new RegExp("(=\"|<)?\\b(https?|ftp)(://" + charInsideUrl + "*" + charEndingUrl + ")(?=$|\\W)", "gi"), + endCharRegex = new RegExp(charEndingUrl, "i"); + + function handleTrailingParens(wholeMatch, lookbehind, protocol, link) { + if (lookbehind) + return wholeMatch; + if (link.charAt(link.length - 1) !== ")") + return "<" + protocol + link + ">"; + var parens = link.match(/[()]/g); + var level = 0; + for (var i = 0; i < parens.length; i++) { + if (parens[i] === "(") { + if (level <= 0) + level = 1; + else + level++; + } + else { + level--; + } + } + var tail = ""; + if (level < 0) { + var re = new RegExp("\\){1," + (-level) + "}$"); + link = link.replace(re, function (trailingParens) { + tail = trailingParens; + return ""; + }); + } + if (tail) { + var lastChar = link.charAt(link.length - 1); + if (!endCharRegex.test(lastChar)) { + tail = lastChar + tail; + link = link.substr(0, link.length - 1); + } + } + return "<" + protocol + link + ">" + tail; + } + + function _DoAutoLinks(text) { + + // note that at this point, all other URL in the text are already hyperlinked as
      + // *except* for the case + + // automatically add < and > around unadorned raw hyperlinks + // must be preceded by a non-word character (and not by =" or <) and followed by non-word/EOF character + // simulating the lookbehind in a consuming way is okay here, since a URL can neither and with a " nor + // with a <, so there is no risk of overlapping matches. + text = text.replace(autoLinkRegex, handleTrailingParens); + + // autolink anything like + + + var replacer = function (wholematch, m1) { + var url = attributeSafeUrl(m1); + + return "" + pluginHooks.plainLinkText(m1) + ""; + }; + text = text.replace(/<((https?|ftp):[^'">\s]+)>/gi, replacer); + + // Email addresses: + /* + text = text.replace(/ + < + (?:mailto:)? + ( + [-.\w]+ + \@ + [-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+ + ) + > + /gi, _DoAutoLinks_callback()); + */ + + /* disabling email autolinking, since we don't do that on the server, either + text = text.replace(/<(?:mailto:)?([-.\w]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi, + function(wholeMatch,m1) { + return _EncodeEmailAddress( _UnescapeSpecialChars(m1) ); + } + ); + */ + return text; + } + + function _UnescapeSpecialChars(text) { + // + // Swap back in all the special characters we've hidden. + // + text = text.replace(/~E(\d+)E/g, + function (wholeMatch, m1) { + var charCodeToReplace = parseInt(m1); + return String.fromCharCode(charCodeToReplace); + } + ); + return text; + } + + function _Outdent(text) { + // + // Remove one level of line-leading tabs or spaces + // + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + text = text.replace(/^(\t|[ ]{1,4})/gm, "~0"); // attacklab: g_tab_width + + // attacklab: clean up hack + text = text.replace(/~0/g, "") + + return text; + } + + function _Detab(text) { + if (!/\t/.test(text)) + return text; + + var spaces = [" ", " ", " ", " "], + skew = 0, + v; + + return text.replace(/[\n\t]/g, function (match, offset) { + if (match === "\n") { + skew = offset + 1; + return match; + } + v = (offset - skew) % 4; + skew = offset + 1; + return spaces[v]; + }); + } + + // + // attacklab: Utility functions + // + + function attributeSafeUrl(url) { + url = attributeEncode(url); + url = escapeCharacters(url, "*_:()[]") + return url; + } + + function escapeCharacters(text, charsToEscape, afterBackslash) { + // First we have to escape the escape characters so that + // we can build a character class out of them + var regexString = "([" + charsToEscape.replace(/([\[\]\\])/g, "\\$1") + "])"; + + if (afterBackslash) { + regexString = "\\\\" + regexString; + } + + var regex = new RegExp(regexString, "g"); + text = text.replace(regex, escapeCharacters_callback); + + return text; + } + + + function escapeCharacters_callback(wholeMatch, m1) { + var charCodeToEscape = m1.charCodeAt(0); + return "~E" + charCodeToEscape + "E"; + } + + }; // end of the Markdown.Converter constructor + +})(); diff --git a/src/scripts/markdown/02_markdown-sanitizer.js b/src/scripts/markdown/02_markdown-sanitizer.js new file mode 100644 index 00000000..03aca2e6 --- /dev/null +++ b/src/scripts/markdown/02_markdown-sanitizer.js @@ -0,0 +1,114 @@ +(function () { + var output, Converter; + if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module + output = exports; + Converter = require("./Markdown.Converter").Converter; + } else { + output = window.Markdown; + Converter = output.Converter; + } + + output.getSanitizingConverter = function () { + var converter = new Converter(); + converter.hooks.chain("postConversion", sanitizeHtml); + converter.hooks.chain("postConversion", balanceTags); + return converter; + } + + function sanitizeHtml(html) { + return html.replace(/<[^>]*>?/gi, sanitizeTag); + } + + // (tags that can be opened/closed) | (tags that stand alone) + var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|iframe|kbd|li|ol(?: start="\d+")?|p|pre|s|sup|sub|strong|strike|ul)>|<(br|hr)\s?\/?>)$/i; + // | + var a_white = /^(]+")?(\sclass="[^"<>]+")?\s?>|<\/a>)$/i; + + // Cloud custom: Allow iframe embed from YouTube, Vimeo and SoundCloud + var iframe_youtube = /^(|<\/iframe>)$/i; + var iframe_vimeo = /^(|<\/iframe>)$/i; + var iframe_soundcloud = /^(|<\/iframe>)$/i; + + // ]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i; + + function sanitizeTag(tag) { + if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white) || tag.match(iframe_youtube) || tag.match(iframe_vimeo) || tag.match(iframe_soundcloud)) { + return tag; + } else { + return ""; + } + } + + /// + /// attempt to balance HTML tags in the html string + /// by removing any unmatched opening or closing tags + /// IMPORTANT: we *assume* HTML has *already* been + /// sanitized and is safe/sane before balancing! + /// + /// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593 + /// + function balanceTags(html) { + + if (html == "") + return ""; + + var re = /<\/?\w+[^>]*(\s|$|>)/g; + // convert everything to lower case; this makes + // our case insensitive comparisons easier + var tags = html.toLowerCase().match(re); + + // no HTML tags present? nothing to do; exit now + var tagcount = (tags || []).length; + if (tagcount == 0) + return html; + + var tagname, tag; + var ignoredtags = "



    • "; + var match; + var tagpaired = []; + var tagremove = []; + var needsRemoval = false; + + // loop through matched tags in forward order + for (var ctag = 0; ctag < tagcount; ctag++) { + tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1"); + // skip any already paired tags + // and skip tags in our ignore list; assume they're self-closed + if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1) + continue; + + tag = tags[ctag]; + match = -1; + + if (!/^<\//.test(tag)) { + // this is an opening tag + // search forwards (next tags), look for closing tags + for (var ntag = ctag + 1; ntag < tagcount; ntag++) { + if (!tagpaired[ntag] && tags[ntag] == "") { + match = ntag; + break; + } + } + } + + if (match == -1) + needsRemoval = tagremove[ctag] = true; // mark for removal + else + tagpaired[match] = true; // mark paired + } + + if (!needsRemoval) + return html; + + // delete all orphaned tags from the string + + var ctag = 0; + html = html.replace(re, function (match) { + var res = tagremove[ctag] ? "" : match; + ctag++; + return res; + }); + return html; + } +})(); diff --git a/src/scripts/markdown/03_showdown.js b/src/scripts/markdown/03_showdown.js new file mode 100644 index 00000000..5de0896a --- /dev/null +++ b/src/scripts/markdown/03_showdown.js @@ -0,0 +1,2295 @@ +;/*! showdown 27-08-2015 */ +(function(){ +/** + * Created by Tivie on 13-07-2015. + */ + +function getDefaultOpts(simple) { + 'use strict'; + + var defaultOptions = { + omitExtraWLInCodeBlocks: { + default: false, + describe: 'Omit the default extra whiteline added to code blocks', + type: 'boolean' + }, + noHeaderId: { + default: false, + describe: 'Turn on/off generated header id', + type: 'boolean' + }, + prefixHeaderId: { + default: false, + describe: 'Specify a prefix to generated header ids', + type: 'string' + }, + headerLevelStart: { + default: false, + describe: 'The header blocks level start', + type: 'integer' + }, + parseImgDimensions: { + default: false, + describe: 'Turn on/off image dimension parsing', + type: 'boolean' + }, + simplifiedAutoLink: { + default: false, + describe: 'Turn on/off GFM autolink style', + type: 'boolean' + }, + literalMidWordUnderscores: { + default: false, + describe: 'Parse midword underscores as literal underscores', + type: 'boolean' + }, + strikethrough: { + default: false, + describe: 'Turn on/off strikethrough support', + type: 'boolean' + }, + tables: { + default: false, + describe: 'Turn on/off tables support', + type: 'boolean' + }, + tablesHeaderId: { + default: false, + describe: 'Add an id to table headers', + type: 'boolean' + }, + ghCodeBlocks: { + default: true, + describe: 'Turn on/off GFM fenced code blocks support', + type: 'boolean' + }, + tasklists: { + default: false, + describe: 'Turn on/off GFM tasklist support', + type: 'boolean' + }, + smoothLivePreview: { + default: false, + describe: 'Prevents weird effects in live previews due to incomplete input', + type: 'boolean' + } + }; + if (simple === false) { + return JSON.parse(JSON.stringify(defaultOptions)); + } + var ret = {}; + for (var opt in defaultOptions) { + if (defaultOptions.hasOwnProperty(opt)) { + ret[opt] = defaultOptions[opt].default; + } + } + return ret; +} + +/** + * Created by Tivie on 06-01-2015. + */ + +// Private properties +var showdown = {}, + parsers = {}, + extensions = {}, + globalOptions = getDefaultOpts(true), + flavor = { + github: { + omitExtraWLInCodeBlocks: true, + prefixHeaderId: 'user-content-', + simplifiedAutoLink: true, + literalMidWordUnderscores: true, + strikethrough: true, + tables: true, + tablesHeaderId: true, + ghCodeBlocks: true, + tasklists: true + }, + vanilla: getDefaultOpts(true) + }; + +/** + * helper namespace + * @type {{}} + */ +showdown.helper = {}; + +/** + * TODO LEGACY SUPPORT CODE + * @type {{}} + */ +showdown.extensions = {}; + +/** + * Set a global option + * @static + * @param {string} key + * @param {*} value + * @returns {showdown} + */ +showdown.setOption = function (key, value) { + 'use strict'; + globalOptions[key] = value; + return this; +}; + +/** + * Get a global option + * @static + * @param {string} key + * @returns {*} + */ +showdown.getOption = function (key) { + 'use strict'; + return globalOptions[key]; +}; + +/** + * Get the global options + * @static + * @returns {{}} + */ +showdown.getOptions = function () { + 'use strict'; + return globalOptions; +}; + +/** + * Reset global options to the default values + * @static + */ +showdown.resetOptions = function () { + 'use strict'; + globalOptions = getDefaultOpts(true); +}; + +/** + * Set the flavor showdown should use as default + * @param {string} name + */ +showdown.setFlavor = function (name) { + 'use strict'; + if (flavor.hasOwnProperty(name)) { + var preset = flavor[name]; + for (var option in preset) { + if (preset.hasOwnProperty(option)) { + globalOptions[option] = preset[option]; + } + } + } +}; + +/** + * Get the default options + * @static + * @param {boolean} [simple=true] + * @returns {{}} + */ +showdown.getDefaultOptions = function (simple) { + 'use strict'; + return getDefaultOpts(simple); +}; + +/** + * Get or set a subParser + * + * subParser(name) - Get a registered subParser + * subParser(name, func) - Register a subParser + * @static + * @param {string} name + * @param {function} [func] + * @returns {*} + */ +showdown.subParser = function (name, func) { + 'use strict'; + if (showdown.helper.isString(name)) { + if (typeof func !== 'undefined') { + parsers[name] = func; + } else { + if (parsers.hasOwnProperty(name)) { + return parsers[name]; + } else { + throw Error('SubParser named ' + name + ' not registered!'); + } + } + } +}; + +/** + * Gets or registers an extension + * @static + * @param {string} name + * @param {object|function=} ext + * @returns {*} + */ +showdown.extension = function (name, ext) { + 'use strict'; + + if (!showdown.helper.isString(name)) { + throw Error('Extension \'name\' must be a string'); + } + + name = showdown.helper.stdExtName(name); + + // Getter + if (showdown.helper.isUndefined(ext)) { + if (!extensions.hasOwnProperty(name)) { + throw Error('Extension named ' + name + ' is not registered!'); + } + return extensions[name]; + + // Setter + } else { + // Expand extension if it's wrapped in a function + if (typeof ext === 'function') { + ext = ext(); + } + + // Ensure extension is an array + if (!showdown.helper.isArray(ext)) { + ext = [ext]; + } + + var validExtension = validate(ext, name); + + if (validExtension.valid) { + extensions[name] = ext; + } else { + throw Error(validExtension.error); + } + } +}; + +/** + * Gets all extensions registered + * @returns {{}} + */ +showdown.getAllExtensions = function () { + 'use strict'; + return extensions; +}; + +/** + * Remove an extension + * @param {string} name + */ +showdown.removeExtension = function (name) { + 'use strict'; + delete extensions[name]; +}; + +/** + * Removes all extensions + */ +showdown.resetExtensions = function () { + 'use strict'; + extensions = {}; +}; + +/** + * Validate extension + * @param {array} extension + * @param {string} name + * @returns {{valid: boolean, error: string}} + */ +function validate(extension, name) { + 'use strict'; + + var errMsg = (name) ? 'Error in ' + name + ' extension->' : 'Error in unnamed extension', + ret = { + valid: true, + error: '' + }; + + if (!showdown.helper.isArray(extension)) { + extension = [extension]; + } + + for (var i = 0; i < extension.length; ++i) { + var baseMsg = errMsg + ' sub-extension ' + i + ': ', + ext = extension[i]; + if (typeof ext !== 'object') { + ret.valid = false; + ret.error = baseMsg + 'must be an object, but ' + typeof ext + ' given'; + return ret; + } + + if (!showdown.helper.isString(ext.type)) { + ret.valid = false; + ret.error = baseMsg + 'property "type" must be a string, but ' + typeof ext.type + ' given'; + return ret; + } + + var type = ext.type = ext.type.toLowerCase(); + + // normalize extension type + if (type === 'language') { + type = ext.type = 'lang'; + } + + if (type === 'html') { + type = ext.type = 'output'; + } + + if (type !== 'lang' && type !== 'output') { + ret.valid = false; + ret.error = baseMsg + 'type ' + type + ' is not recognized. Valid values: "lang" or "output"'; + return ret; + } + + if (ext.filter) { + if (typeof ext.filter !== 'function') { + ret.valid = false; + ret.error = baseMsg + '"filter" must be a function, but ' + typeof ext.filter + ' given'; + return ret; + } + + } else if (ext.regex) { + if (showdown.helper.isString(ext.regex)) { + ext.regex = new RegExp(ext.regex, 'g'); + } + if (!ext.regex instanceof RegExp) { + ret.valid = false; + ret.error = baseMsg + '"regex" property must either be a string or a RegExp object, but ' + + typeof ext.regex + ' given'; + return ret; + } + if (showdown.helper.isUndefined(ext.replace)) { + ret.valid = false; + ret.error = baseMsg + '"regex" extensions must implement a replace string or function'; + return ret; + } + + } else { + ret.valid = false; + ret.error = baseMsg + 'extensions must define either a "regex" property or a "filter" method'; + return ret; + } + + if (showdown.helper.isUndefined(ext.filter) && showdown.helper.isUndefined(ext.regex)) { + ret.valid = false; + ret.error = baseMsg + 'output extensions must define a filter property'; + return ret; + } + } + return ret; +} + +/** + * Validate extension + * @param {object} ext + * @returns {boolean} + */ +showdown.validateExtension = function (ext) { + 'use strict'; + + var validateExtension = validate(ext, null); + if (!validateExtension.valid) { + console.warn(validateExtension.error); + return false; + } + return true; +}; + +/** + * showdownjs helper functions + */ + +if (!showdown.hasOwnProperty('helper')) { + showdown.helper = {}; +} + +/** + * Check if var is string + * @static + * @param {string} a + * @returns {boolean} + */ +showdown.helper.isString = function isString(a) { + 'use strict'; + return (typeof a === 'string' || a instanceof String); +}; + +/** + * ForEach helper function + * @static + * @param {*} obj + * @param {function} callback + */ +showdown.helper.forEach = function forEach(obj, callback) { + 'use strict'; + if (typeof obj.forEach === 'function') { + obj.forEach(callback); + } else { + for (var i = 0; i < obj.length; i++) { + callback(obj[i], i, obj); + } + } +}; + +/** + * isArray helper function + * @static + * @param {*} a + * @returns {boolean} + */ +showdown.helper.isArray = function isArray(a) { + 'use strict'; + return a.constructor === Array; +}; + +/** + * Check if value is undefined + * @static + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is `undefined`, else `false`. + */ +showdown.helper.isUndefined = function isUndefined(value) { + 'use strict'; + return typeof value === 'undefined'; +}; + +/** + * Standardidize extension name + * @static + * @param {string} s extension name + * @returns {string} + */ +showdown.helper.stdExtName = function (s) { + 'use strict'; + return s.replace(/[_-]||\s/g, '').toLowerCase(); +}; + +function escapeCharactersCallback(wholeMatch, m1) { + 'use strict'; + var charCodeToEscape = m1.charCodeAt(0); + return '~E' + charCodeToEscape + 'E'; +} + +/** + * Callback used to escape characters when passing through String.replace + * @static + * @param {string} wholeMatch + * @param {string} m1 + * @returns {string} + */ +showdown.helper.escapeCharactersCallback = escapeCharactersCallback; + +/** + * Escape characters in a string + * @static + * @param {string} text + * @param {string} charsToEscape + * @param {boolean} afterBackslash + * @returns {XML|string|void|*} + */ +showdown.helper.escapeCharacters = function escapeCharacters(text, charsToEscape, afterBackslash) { + 'use strict'; + // First we have to escape the escape characters so that + // we can build a character class out of them + var regexString = '([' + charsToEscape.replace(/([\[\]\\])/g, '\\$1') + '])'; + + if (afterBackslash) { + regexString = '\\\\' + regexString; + } + + var regex = new RegExp(regexString, 'g'); + text = text.replace(regex, escapeCharactersCallback); + + return text; +}; + +/** + * POLYFILLS + */ +if (showdown.helper.isUndefined(console)) { + console = { + warn: function (msg) { + 'use strict'; + alert(msg); + }, + log: function (msg) { + 'use strict'; + alert(msg); + } + }; +} + +/** + * Created by Estevao on 31-05-2015. + */ + +/** + * Showdown Converter class + * @class + * @param {object} [converterOptions] + * @returns {Converter} + */ +showdown.Converter = function (converterOptions) { + 'use strict'; + + var + /** + * Options used by this converter + * @private + * @type {{}} + */ + options = {}, + + /** + * Language extensions used by this converter + * @private + * @type {Array} + */ + langExtensions = [], + + /** + * Output modifiers extensions used by this converter + * @private + * @type {Array} + */ + outputModifiers = [], + + /** + * The parser Order + * @private + * @type {string[]} + */ + parserOrder = [ + 'githubCodeBlocks', + 'hashHTMLBlocks', + 'stripLinkDefinitions', + 'blockGamut', + 'unescapeSpecialChars' + ]; + + _constructor(); + + /** + * Converter constructor + * @private + */ + function _constructor() { + converterOptions = converterOptions || {}; + + for (var gOpt in globalOptions) { + if (globalOptions.hasOwnProperty(gOpt)) { + options[gOpt] = globalOptions[gOpt]; + } + } + + // Merge options + if (typeof converterOptions === 'object') { + for (var opt in converterOptions) { + if (converterOptions.hasOwnProperty(opt)) { + options[opt] = converterOptions[opt]; + } + } + } else { + throw Error('Converter expects the passed parameter to be an object, but ' + typeof converterOptions + + ' was passed instead.'); + } + + if (options.extensions) { + showdown.helper.forEach(options.extensions, _parseExtension); + } + } + + /** + * Parse extension + * @param {*} ext + * @param {string} [name=''] + * @private + */ + function _parseExtension(ext, name) { + + name = name || null; + // If it's a string, the extension was previously loaded + if (showdown.helper.isString(ext)) { + ext = showdown.helper.stdExtName(ext); + name = ext; + + // LEGACY_SUPPORT CODE + if (showdown.extensions[ext]) { + console.warn('DEPRECATION WARNING: ' + ext + ' is an old extension that uses a deprecated loading method.' + + 'Please inform the developer that the extension should be updated!'); + legacyExtensionLoading(showdown.extensions[ext], ext); + return; + // END LEGACY SUPPORT CODE + + } else if (!showdown.helper.isUndefined(extensions[ext])) { + ext = extensions[ext]; + + } else { + throw Error('Extension "' + ext + '" could not be loaded. It was either not found or is not a valid extension.'); + } + } + + if (typeof ext === 'function') { + ext = ext(); + } + + if (!showdown.helper.isArray(ext)) { + ext = [ext]; + } + + var validExt = validate(ext, name); + if (!validExt.valid) { + throw Error(validExt.error); + } + + for (var i = 0; i < ext.length; ++i) { + switch (ext[i].type) { + case 'lang': + langExtensions.push(ext[i]); + break; + + case 'output': + outputModifiers.push(ext[i]); + break; + + default: + // should never reach here + throw Error('Extension loader error: Type unrecognized!!!'); + } + } + } + + /** + * LEGACY_SUPPORT + * @param {*} ext + * @param {string} name + */ + function legacyExtensionLoading(ext, name) { + if (typeof ext === 'function') { + ext = ext(new showdown.Converter()); + } + if (!showdown.helper.isArray(ext)) { + ext = [ext]; + } + var valid = validate(ext, name); + + if (!valid.valid) { + throw Error(valid.error); + } + + for (var i = 0; i < ext.length; ++i) { + switch (ext[i].type) { + case 'lang': + langExtensions.push(ext[i]); + break; + case 'output': + outputModifiers.push(ext[i]); + break; + default:// should never reach here + throw Error('Extension loader error: Type unrecognized!!!'); + } + } + } + + /** + * Converts a markdown string into HTML + * @param {string} text + * @returns {*} + */ + this.makeHtml = function (text) { + //check if text is not falsy + if (!text) { + return text; + } + + var globals = { + gHtmlBlocks: [], + gUrls: {}, + gTitles: {}, + gDimensions: {}, + gListLevel: 0, + hashLinkCounts: {}, + langExtensions: langExtensions, + outputModifiers: outputModifiers, + converter: this + }; + + // attacklab: Replace ~ with ~T + // This lets us use tilde as an escape char to avoid md5 hashes + // The choice of character is arbitrary; anything that isn't + // magic in Markdown will work. + text = text.replace(/~/g, '~T'); + + // attacklab: Replace $ with ~D + // RegExp interprets $ as a special character + // when it's in a replacement string + text = text.replace(/\$/g, '~D'); + + // Standardize line endings + text = text.replace(/\r\n/g, '\n'); // DOS to Unix + text = text.replace(/\r/g, '\n'); // Mac to Unix + + // Make sure text begins and ends with a couple of newlines: + text = '\n\n' + text + '\n\n'; + + // detab + text = showdown.subParser('detab')(text, options, globals); + + // stripBlankLines + text = showdown.subParser('stripBlankLines')(text, options, globals); + + //run languageExtensions + showdown.helper.forEach(langExtensions, function (ext) { + text = showdown.subParser('runExtension')(ext, text, options, globals); + }); + + // Run all registered parsers + for (var i = 0; i < parserOrder.length; ++i) { + var name = parserOrder[i]; + text = parsers[name](text, options, globals); + } + + // attacklab: Restore dollar signs + text = text.replace(/~D/g, '$$'); + + // attacklab: Restore tildes + text = text.replace(/~T/g, '~'); + + // Run output modifiers + showdown.helper.forEach(outputModifiers, function (ext) { + text = showdown.subParser('runExtension')(ext, text, options, globals); + }); + + return text; + }; + + /** + * Set an option of this Converter instance + * @param {string} key + * @param {*} value + */ + this.setOption = function (key, value) { + options[key] = value; + }; + + /** + * Get the option of this Converter instance + * @param {string} key + * @returns {*} + */ + this.getOption = function (key) { + return options[key]; + }; + + /** + * Get the options of this Converter instance + * @returns {{}} + */ + this.getOptions = function () { + return options; + }; + + /** + * Add extension to THIS converter + * @param {{}} extension + * @param {string} [name=null] + */ + this.addExtension = function (extension, name) { + name = name || null; + _parseExtension(extension, name); + }; + + /** + * Use a global registered extension with THIS converter + * @param {string} extensionName Name of the previously registered extension + */ + this.useExtension = function (extensionName) { + _parseExtension(extensionName); + }; + + /** + * Set the flavor THIS converter should use + * @param {string} name + */ + this.setFlavor = function (name) { + if (flavor.hasOwnProperty(name)) { + var preset = flavor[name]; + for (var option in preset) { + if (preset.hasOwnProperty(option)) { + options[option] = preset[option]; + } + } + } + }; + + /** + * Remove an extension from THIS converter. + * Note: This is a costly operation. It's better to initialize a new converter + * and specify the extensions you wish to use + * @param {Array} extension + */ + this.removeExtension = function (extension) { + if (!showdown.helper.isArray(extension)) { + extension = [extension]; + } + for (var a = 0; a < extension.length; ++a) { + var ext = extension[a]; + for (var i = 0; i < langExtensions.length; ++i) { + if (langExtensions[i] === ext) { + langExtensions[i].splice(i, 1); + } + } + for (var ii = 0; ii < outputModifiers.length; ++i) { + if (outputModifiers[ii] === ext) { + outputModifiers[ii].splice(i, 1); + } + } + } + }; + + /** + * Get all extension of THIS converter + * @returns {{language: Array, output: Array}} + */ + this.getAllExtensions = function () { + return { + language: langExtensions, + output: outputModifiers + }; + }; +}; + +/** + * Turn Markdown link shortcuts into XHTML tags. + */ +showdown.subParser('anchors', function (text, config, globals) { + 'use strict'; + + var writeAnchorTag = function (wholeMatch, m1, m2, m3, m4, m5, m6, m7) { + if (showdown.helper.isUndefined(m7)) { + m7 = ''; + } + wholeMatch = m1; + var linkText = m2, + linkId = m3.toLowerCase(), + url = m4, + title = m7; + + if (!url) { + if (!linkId) { + // lower-case and turn embedded newlines into spaces + linkId = linkText.toLowerCase().replace(/ ?\n/g, ' '); + } + url = '#' + linkId; + + if (!showdown.helper.isUndefined(globals.gUrls[linkId])) { + url = globals.gUrls[linkId]; + if (!showdown.helper.isUndefined(globals.gTitles[linkId])) { + title = globals.gTitles[linkId]; + } + } else { + if (wholeMatch.search(/\(\s*\)$/m) > -1) { + // Special case for explicit empty url + url = ''; + } else { + return wholeMatch; + } + } + } + + url = showdown.helper.escapeCharacters(url, '*_', false); + var result = ''; + + return result; + }; + + // First, handle reference-style links: [link text] [id] + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[] // or anything else + )* + ) + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + )()()()() // pad remaining backreferences + /g,_DoAnchors_callback); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeAnchorTag); + + // + // Next, inline-style links: [link text](url "optional title") + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[\]] // or anything else + ) + ) + \] + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? // href = $4 + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // Title = $7 + \6 // matching quote + [ \t]* // ignore any spaces/tabs between closing quote and ) + )? // title is optional + \) + ) + /g,writeAnchorTag); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, + writeAnchorTag); + + // + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link test][1] + // or [link test](/foo) + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ([^\[\]]+) // link text = $2; can't contain '[' or ']' + \] + )()()()()() // pad rest of backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); + + return text; + +}); + +showdown.subParser('autoLinks', function (text, options) { + 'use strict'; + + //simpleURLRegex = /\b(((https?|ftp|dict):\/\/|www\.)[-.+~:?#@!$&'()*,;=[\]\w]+)\b/gi, + + var simpleURLRegex = /\b(((https?|ftp|dict):\/\/|www\.)[^'">\s]+\.[^'">\s]+)(?=\s|$)(?!["<>])/gi, + delimUrlRegex = /<(((https?|ftp|dict):\/\/|www\.)[^'">\s]+)>/gi, + simpleMailRegex = /\b(?:mailto:)?([-.\w]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)\b/gi, + delimMailRegex = /<(?:mailto:)?([-.\w]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi; + + text = text.replace(delimUrlRegex, '$1'); + text = text.replace(delimMailRegex, replaceMail); + //simpleURLRegex = /\b(((https?|ftp|dict):\/\/|www\.)[-.+~:?#@!$&'()*,;=[\]\w]+)\b/gi, + // Email addresses: + + if (options.simplifiedAutoLink) { + text = text.replace(simpleURLRegex, '$1'); + text = text.replace(simpleMailRegex, replaceMail); + } + + function replaceMail(wholeMatch, m1) { + var unescapedStr = showdown.subParser('unescapeSpecialChars')(m1); + return showdown.subParser('encodeEmailAddress')(unescapedStr); + } + + return text; +}); + +/** + * These are all the transformations that form block-level + * tags like paragraphs, headers, and list items. + */ +showdown.subParser('blockGamut', function (text, options, globals) { + 'use strict'; + + // we parse blockquotes first so that we can have headings and hrs + // inside blockquotes + text = showdown.subParser('blockQuotes')(text, options, globals); + text = showdown.subParser('headers')(text, options, globals); + + // Do Horizontal Rules: + var key = showdown.subParser('hashBlock')('
      ', options, globals); + text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm, key); + text = text.replace(/^[ ]{0,2}([ ]?\-[ ]?){3,}[ \t]*$/gm, key); + text = text.replace(/^[ ]{0,2}([ ]?_[ ]?){3,}[ \t]*$/gm, key); + + text = showdown.subParser('lists')(text, options, globals); + text = showdown.subParser('codeBlocks')(text, options, globals); + text = showdown.subParser('tables')(text, options, globals); + + // We already ran _HashHTMLBlocks() before, in Markdown(), but that + // was to escape raw HTML in the original Markdown source. This time, + // we're escaping the markup we've just created, so that we don't wrap + //

      tags around block-level tags. + text = showdown.subParser('hashHTMLBlocks')(text, options, globals); + text = showdown.subParser('paragraphs')(text, options, globals); + + return text; + +}); + +showdown.subParser('blockQuotes', function (text, options, globals) { + 'use strict'; + + /* + text = text.replace(/ + ( // Wrap whole match in $1 + ( + ^[ \t]*>[ \t]? // '>' at the start of a line + .+\n // rest of the first line + (.+\n)* // subsequent consecutive lines + \n* // blanks + )+ + ) + /gm, function(){...}); + */ + + text = text.replace(/((^[ \t]{0,3}>[ \t]?.+\n(.+\n)*\n*)+)/gm, function (wholeMatch, m1) { + var bq = m1; + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + bq = bq.replace(/^[ \t]*>[ \t]?/gm, '~0'); // trim one level of quoting + + // attacklab: clean up hack + bq = bq.replace(/~0/g, ''); + + bq = bq.replace(/^[ \t]+$/gm, ''); // trim whitespace-only lines + bq = showdown.subParser('githubCodeBlocks')(bq, options, globals); + bq = showdown.subParser('blockGamut')(bq, options, globals); // recurse + + bq = bq.replace(/(^|\n)/g, '$1 '); + // These leading spaces screw with

       content, so we need to fix that:
      +    bq = bq.replace(/(\s*
      [^\r]+?<\/pre>)/gm, function (wholeMatch, m1) {
      +      var pre = m1;
      +      // attacklab: hack around Konqueror 3.5.4 bug:
      +      pre = pre.replace(/^  /mg, '~0');
      +      pre = pre.replace(/~0/g, '');
      +      return pre;
      +    });
      +
      +    return showdown.subParser('hashBlock')('
      \n' + bq + '\n
      ', options, globals); + }); + return text; +}); + +/** + * Process Markdown `
      ` blocks.
      + */
      +showdown.subParser('codeBlocks', function (text, options, globals) {
      +  'use strict';
      +
      +  /*
      +   text = text.replace(text,
      +   /(?:\n\n|^)
      +   (								// $1 = the code block -- one or more lines, starting with a space/tab
      +   (?:
      +   (?:[ ]{4}|\t)			// Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
      +   .*\n+
      +   )+
      +   )
      +   (\n*[ ]{0,3}[^ \t\n]|(?=~0))	// attacklab: g_tab_width
      +   /g,function(){...});
      +   */
      +
      +  // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
      +  text += '~0';
      +
      +  var pattern = /(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g;
      +  text = text.replace(pattern, function (wholeMatch, m1, m2) {
      +    var codeblock = m1,
      +        nextChar = m2,
      +        end = '\n';
      +
      +    codeblock = showdown.subParser('outdent')(codeblock);
      +    codeblock = showdown.subParser('encodeCode')(codeblock);
      +    codeblock = showdown.subParser('detab')(codeblock);
      +    codeblock = codeblock.replace(/^\n+/g, ''); // trim leading newlines
      +    codeblock = codeblock.replace(/\n+$/g, ''); // trim trailing newlines
      +
      +    if (options.omitExtraWLInCodeBlocks) {
      +      end = '';
      +    }
      +
      +    codeblock = '
      ' + codeblock + end + '
      '; + + return showdown.subParser('hashBlock')(codeblock, options, globals) + nextChar; + }); + + // attacklab: strip sentinel + text = text.replace(/~0/, ''); + + return text; +}); + +/** + * + * * Backtick quotes are used for spans. + * + * * You can use multiple backticks as the delimiters if you want to + * include literal backticks in the code span. So, this input: + * + * Just type ``foo `bar` baz`` at the prompt. + * + * Will translate to: + * + *

      Just type foo `bar` baz at the prompt.

      + * + * There's no arbitrary limit to the number of backticks you + * can use as delimters. If you need three consecutive backticks + * in your code, use four for delimiters, etc. + * + * * You can use spaces to get literal backticks at the edges: + * + * ... type `` `bar` `` ... + * + * Turns to: + * + * ... type `bar` ... + */ +showdown.subParser('codeSpans', function (text) { + 'use strict'; + + //special case -> literal html code tag + text = text.replace(/(<]*?>)([^]*?)<\/code>/g, function (wholeMatch, tag, c) { + c = c.replace(/^([ \t]*)/g, ''); // leading whitespace + c = c.replace(/[ \t]*$/g, ''); // trailing whitespace + c = showdown.subParser('encodeCode')(c); + return tag + c + '
      '; + }); + + /* + text = text.replace(/ + (^|[^\\]) // Character before opening ` can't be a backslash + (`+) // $2 = Opening run of ` + ( // $3 = The code block + [^\r]*? + [^`] // attacklab: work around lack of lookbehind + ) + \2 // Matching closer + (?!`) + /gm, function(){...}); + */ + text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm, + function (wholeMatch, m1, m2, m3) { + var c = m3; + c = c.replace(/^([ \t]*)/g, ''); // leading whitespace + c = c.replace(/[ \t]*$/g, ''); // trailing whitespace + c = showdown.subParser('encodeCode')(c); + return m1 + '' + c + ''; + } + ); + + return text; +}); + +/** + * Convert all tabs to spaces + */ +showdown.subParser('detab', function (text) { + 'use strict'; + + // expand first n-1 tabs + text = text.replace(/\t(?=\t)/g, ' '); // g_tab_width + + // replace the nth with two sentinels + text = text.replace(/\t/g, '~A~B'); + + // use the sentinel to anchor our regex so it doesn't explode + text = text.replace(/~B(.+?)~A/g, function (wholeMatch, m1) { + var leadingText = m1, + numSpaces = 4 - leadingText.length % 4; // g_tab_width + + // there *must* be a better way to do this: + for (var i = 0; i < numSpaces; i++) { + leadingText += ' '; + } + + return leadingText; + }); + + // clean up sentinels + text = text.replace(/~A/g, ' '); // g_tab_width + text = text.replace(/~B/g, ''); + + return text; + +}); + +/** + * Smart processing for ampersands and angle brackets that need to be encoded. + */ +showdown.subParser('encodeAmpsAndAngles', function (text) { + 'use strict'; + // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: + // http://bumppo.net/projects/amputator/ + text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g, '&'); + + // Encode naked <'s + text = text.replace(/<(?![a-z\/?\$!])/gi, '<'); + + return text; +}); + +/** + * Returns the string, with after processing the following backslash escape sequences. + * + * attacklab: The polite way to do this is with the new escapeCharacters() function: + * + * text = escapeCharacters(text,"\\",true); + * text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); + * + * ...but we're sidestepping its use of the (slow) RegExp constructor + * as an optimization for Firefox. This function gets called a LOT. + */ +showdown.subParser('encodeBackslashEscapes', function (text) { + 'use strict'; + text = text.replace(/\\(\\)/g, showdown.helper.escapeCharactersCallback); + text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g, showdown.helper.escapeCharactersCallback); + return text; +}); + +/** + * Encode/escape certain characters inside Markdown code runs. + * The point is that in code, these characters are literals, + * and lose their special Markdown meanings. + */ +showdown.subParser('encodeCode', function (text) { + 'use strict'; + + // Encode all ampersands; HTML entities are not + // entities within a Markdown code span. + text = text.replace(/&/g, '&'); + + // Do the angle bracket song and dance: + text = text.replace(//g, '>'); + + // Now, escape characters that are magic in Markdown: + text = showdown.helper.escapeCharacters(text, '*_{}[]\\', false); + + // jj the line above breaks this: + //--- + //* Item + // 1. Subitem + // special char: * + // --- + + return text; +}); + +/** + * Input: an email address, e.g. "foo@example.com" + * + * Output: the email address as a mailto link, with each character + * of the address encoded as either a decimal or hex entity, in + * the hopes of foiling most address harvesting spam bots. E.g.: + * + * foo + * @example.com + * + * Based on a filter by Matthew Wickline, posted to the BBEdit-Talk + * mailing list: + * + */ +showdown.subParser('encodeEmailAddress', function (addr) { + 'use strict'; + + var encode = [ + function (ch) { + return '&#' + ch.charCodeAt(0) + ';'; + }, + function (ch) { + return '&#x' + ch.charCodeAt(0).toString(16) + ';'; + }, + function (ch) { + return ch; + } + ]; + + addr = 'mailto:' + addr; + + addr = addr.replace(/./g, function (ch) { + if (ch === '@') { + // this *must* be encoded. I insist. + ch = encode[Math.floor(Math.random() * 2)](ch); + } else if (ch !== ':') { + // leave ':' alone (to spot mailto: later) + var r = Math.random(); + // roughly 10% raw, 45% hex, 45% dec + ch = ( + r > 0.9 ? encode[2](ch) : r > 0.45 ? encode[1](ch) : encode[0](ch) + ); + } + return ch; + }); + + addr = '' + addr + ''; + addr = addr.replace(/">.+:/g, '">'); // strip the mailto: from the visible part + + return addr; +}); + +/** + * Within tags -- meaning between < and > -- encode [\ ` * _] so they + * don't conflict with their use in Markdown for code, italics and strong. + */ +showdown.subParser('escapeSpecialCharsWithinTagAttributes', function (text) { + 'use strict'; + + // Build a regex to find HTML tags and comments. See Friedl's + // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. + var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|)/gi; + + text = text.replace(regex, function (wholeMatch) { + var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g, '$1`'); + tag = showdown.helper.escapeCharacters(tag, '\\`*_', false); + return tag; + }); + + return text; +}); + +/** + * Handle github codeblocks prior to running HashHTML so that + * HTML contained within the codeblock gets escaped properly + * Example: + * ```ruby + * def hello_world(x) + * puts "Hello, #{x}" + * end + * ``` + */ +showdown.subParser('githubCodeBlocks', function (text, options, globals) { + 'use strict'; + + // early exit if option is not enabled + if (!options.ghCodeBlocks) { + return text; + } + + text += '~0'; + + text = text.replace(/(?:^|\n)```(.*)\n([\s\S]*?)\n```/g, function (wholeMatch, language, codeblock) { + var end = (options.omitExtraWLInCodeBlocks) ? '' : '\n'; + + codeblock = showdown.subParser('encodeCode')(codeblock); + codeblock = showdown.subParser('detab')(codeblock); + codeblock = codeblock.replace(/^\n+/g, ''); // trim leading newlines + codeblock = codeblock.replace(/\n+$/g, ''); // trim trailing whitespace + + codeblock = '
      ' + codeblock + end + '
      '; + + return showdown.subParser('hashBlock')(codeblock, options, globals); + }); + + // attacklab: strip sentinel + text = text.replace(/~0/, ''); + + return text; + +}); + +showdown.subParser('hashBlock', function (text, options, globals) { + 'use strict'; + text = text.replace(/(^\n+|\n+$)/g, ''); + return '\n\n~K' + (globals.gHtmlBlocks.push(text) - 1) + 'K\n\n'; +}); + +showdown.subParser('hashElement', function (text, options, globals) { + 'use strict'; + + return function (wholeMatch, m1) { + var blockText = m1; + + // Undo double lines + blockText = blockText.replace(/\n\n/g, '\n'); + blockText = blockText.replace(/^\n/, ''); + + // strip trailing blank lines + blockText = blockText.replace(/\n+$/g, ''); + + // Replace the element text with a marker ("~KxK" where x is its key) + blockText = '\n\n~K' + (globals.gHtmlBlocks.push(blockText) - 1) + 'K\n\n'; + + return blockText; + }; +}); + +showdown.subParser('hashHTMLBlocks', function (text, options, globals) { + 'use strict'; + + // attacklab: Double up blank lines to reduce lookaround + text = text.replace(/\n/g, '\n\n'); + + // Hashify HTML blocks: + // We only want to do this for block-level HTML tags, such as headers, + // lists, and tables. That's because we still want to wrap

      s around + // "paragraphs" that are wrapped in non-block-level tags, such as anchors, + // phrase emphasis, and spans. The list of tags we're looking for is + // hard-coded: + //var block_tags_a = + // 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del|style|section|header|footer|nav|article|aside'; + // var block_tags_b = + // 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|style|section|header|footer|nav|article|aside'; + + // First, look for nested blocks, e.g.: + //

      + //
      + // tags for inner block must be indented. + //
      + //
      + // + // The outermost tags must start at the left margin for this to match, and + // the inner nested divs must be indented. + // We need to do this before the next, more liberal match, because the next + // match will start at the first `
      ` and stop at the first `
      `. + + // attacklab: This regex can be expensive when it fails. + /* + var text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_a) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*?\n // any number of lines, minimally matching + // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm, + showdown.subParser('hashElement')(text, options, globals)); + + // + // Now match more liberally, simply from `\n` to `\n` + // + + /* + var text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_b) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*? // any number of lines, minimally matching + // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|math|style|section|header|footer|nav|article|aside|address|audio|canvas|figure|hgroup|output|video)\b[^\r]*?<\/\2>[ \t]*(?=\n+)\n)/gm, + showdown.subParser('hashElement')(text, options, globals)); + + // Special case just for
      . It was easier to make a special case than + // to make the other regex more complicated. + + /* + text = text.replace(/ + ( // save in $1 + \n\n // Starting after a blank line + [ ]{0,3} + (<(hr) // start tag = $2 + \b // word break + ([^<>])*? // + \/?>) // the matching end tag + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,showdown.subParser('hashElement')(text, options, globals)); + */ + text = text.replace(/(\n[ ]{0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g, + showdown.subParser('hashElement')(text, options, globals)); + + // Special case for standalone HTML comments: + + /* + text = text.replace(/ + ( // save in $1 + \n\n // Starting after a blank line + [ ]{0,3} // attacklab: g_tab_width - 1 + + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,showdown.subParser('hashElement')(text, options, globals)); + */ + text = text.replace(/(\n\n[ ]{0,3}[ \t]*(?=\n{2,}))/g, + showdown.subParser('hashElement')(text, options, globals)); + + // PHP and ASP-style processor instructions ( and <%...%>) + + /* + text = text.replace(/ + (?: + \n\n // Starting after a blank line + ) + ( // save in $1 + [ ]{0,3} // attacklab: g_tab_width - 1 + (?: + <([?%]) // $2 + [^\r]*? + \2> + ) + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,showdown.subParser('hashElement')(text, options, globals)); + */ + text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g, + showdown.subParser('hashElement')(text, options, globals)); + + // attacklab: Undo double lines (see comment at top of this function) + text = text.replace(/\n\n/g, '\n'); + return text; + +}); + +showdown.subParser('headers', function (text, options, globals) { + 'use strict'; + + var prefixHeader = options.prefixHeaderId, + headerLevelStart = (isNaN(parseInt(options.headerLevelStart))) ? 1 : parseInt(options.headerLevelStart), + + // Set text-style headers: + // Header 1 + // ======== + // + // Header 2 + // -------- + // + setextRegexH1 = (options.smoothLivePreview) ? /^(.+)[ \t]*\n={2,}[ \t]*\n+/gm : /^(.+)[ \t]*\n=+[ \t]*\n+/gm, + setextRegexH2 = (options.smoothLivePreview) ? /^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm : /^(.+)[ \t]*\n-+[ \t]*\n+/gm; + + text = text.replace(setextRegexH1, function (wholeMatch, m1) { + + var spanGamut = showdown.subParser('spanGamut')(m1, options, globals), + hID = (options.noHeaderId) ? '' : ' id="' + headerId(m1) + '"', + hLevel = headerLevelStart, + hashBlock = '' + spanGamut + ''; + return showdown.subParser('hashBlock')(hashBlock, options, globals); + }); + + text = text.replace(setextRegexH2, function (matchFound, m1) { + var spanGamut = showdown.subParser('spanGamut')(m1, options, globals), + hID = (options.noHeaderId) ? '' : ' id="' + headerId(m1) + '"', + hLevel = headerLevelStart + 1, + hashBlock = '' + spanGamut + ''; + return showdown.subParser('hashBlock')(hashBlock, options, globals); + }); + + // atx-style headers: + // # Header 1 + // ## Header 2 + // ## Header 2 with closing hashes ## + // ... + // ###### Header 6 + // + text = text.replace(/^(#{1,6})[ \t]*(.+?)[ \t]*#*\n+/gm, function (wholeMatch, m1, m2) { + var span = showdown.subParser('spanGamut')(m2, options, globals), + hID = (options.noHeaderId) ? '' : ' id="' + headerId(m2) + '"', + hLevel = headerLevelStart - 1 + m1.length, + header = '' + span + ''; + + return showdown.subParser('hashBlock')(header, options, globals); + }); + + function headerId(m) { + var title, escapedId = m.replace(/[^\w]/g, '').toLowerCase(); + + if (globals.hashLinkCounts[escapedId]) { + title = escapedId + '-' + (globals.hashLinkCounts[escapedId]++); + } else { + title = escapedId; + globals.hashLinkCounts[escapedId] = 1; + } + + // Prefix id to prevent causing inadvertent pre-existing style matches. + if (prefixHeader === true) { + prefixHeader = 'section'; + } + + if (showdown.helper.isString(prefixHeader)) { + return prefixHeader + title; + } + return title; + } + + return text; +}); + +/** + * Turn Markdown image shortcuts into tags. + */ +showdown.subParser('images', function (text, options, globals) { + 'use strict'; + + var inlineRegExp = /!\[(.*?)]\s?\([ \t]*()?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(['"])(.*?)\6[ \t]*)?\)/g, + referenceRegExp = /!\[(.*?)][ ]?(?:\n[ ]*)?\[(.*?)]()()()()()/g; + + function writeImageTag (wholeMatch, altText, linkId, url, width, height, m5, title) { + + var gUrls = globals.gUrls, + gTitles = globals.gTitles, + gDims = globals.gDimensions; + + linkId = linkId.toLowerCase(); + + if (!title) { + title = ''; + } + + if (url === '' || url === null) { + if (linkId === '' || linkId === null) { + // lower-case and turn embedded newlines into spaces + linkId = altText.toLowerCase().replace(/ ?\n/g, ' '); + } + url = '#' + linkId; + + if (!showdown.helper.isUndefined(gUrls[linkId])) { + url = gUrls[linkId]; + if (!showdown.helper.isUndefined(gTitles[linkId])) { + title = gTitles[linkId]; + } + if (!showdown.helper.isUndefined(gDims[linkId])) { + width = gDims[linkId].width; + height = gDims[linkId].height; + } + } else { + return wholeMatch; + } + } + + altText = altText.replace(/"/g, '"'); + altText = showdown.helper.escapeCharacters(altText, '*_', false); + url = showdown.helper.escapeCharacters(url, '*_', false); + var result = '' + altText + 'x "optional title") + text = text.replace(inlineRegExp, writeImageTag); + + return text; +}); + +showdown.subParser('italicsAndBold', function (text, options) { + 'use strict'; + + if (options.literalMidWordUnderscores) { + //underscores + // Since we are consuming a \s character, we need to add it + text = text.replace(/(^|\s|>|\b)__(?=\S)([^]+?)__(?=\b|<|\s|$)/gm, '$1$2'); + text = text.replace(/(^|\s|>|\b)_(?=\S)([^]+?)_(?=\b|<|\s|$)/gm, '$1$2'); + //asterisks + text = text.replace(/\*\*(?=\S)([^]+?)\*\*/g, '$1'); + text = text.replace(/\*(?=\S)([^]+?)\*/g, '$1'); + + } else { + // must go first: + text = text.replace(/(\*\*|__)(?=\S)([^\r]*?\S[*_]*)\1/g, '$2'); + text = text.replace(/(\*|_)(?=\S)([^\r]*?\S)\1/g, '$2'); + } + return text; +}); + +/** + * Form HTML ordered (numbered) and unordered (bulleted) lists. + */ +showdown.subParser('lists', function (text, options, globals) { + 'use strict'; + + /** + * Process the contents of a single ordered or unordered list, splitting it + * into individual list items. + * @param {string} listStr + * @param {boolean} trimTrailing + * @returns {string} + */ + function processListItems (listStr, trimTrailing) { + // The $g_list_level global keeps track of when we're inside a list. + // Each time we enter a list, we increment it; when we leave a list, + // we decrement. If it's zero, we're not in a list anymore. + // + // We do this because when we're not inside a list, we want to treat + // something like this: + // + // I recommend upgrading to version + // 8. Oops, now this line is treated + // as a sub-list. + // + // As a single paragraph, despite the fact that the second line starts + // with a digit-period-space sequence. + // + // Whereas when we're inside a list (or sub-list), that line will be + // treated as the start of a sub-list. What a kludge, huh? This is + // an aspect of Markdown's syntax that's hard to parse perfectly + // without resorting to mind-reading. Perhaps the solution is to + // change the syntax rules such that sub-lists must start with a + // starting cardinal number; e.g. "1." or "a.". + globals.gListLevel++; + + // trim trailing blank lines: + listStr = listStr.replace(/\n{2,}$/, '\n'); + + // attacklab: add sentinel to emulate \z + listStr += '~0'; + + var rgx = /(\n)?(^[ \t]*)([*+-]|\d+[.])[ \t]+((\[(x| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(~0|\2([*+-]|\d+[.])[ \t]+))/gm, + isParagraphed = (/\n[ \t]*\n(?!~0)/.test(listStr)); + + listStr = listStr.replace(rgx, function (wholeMatch, m1, m2, m3, m4, taskbtn, checked) { + checked = (checked && checked.trim() !== ''); + var item = showdown.subParser('outdent')(m4, options, globals), + bulletStyle = ''; + + // Support for github tasklists + if (taskbtn && options.tasklists) { + bulletStyle = ' class="task-list-item" style="list-style-type: none;"'; + item = item.replace(/^[ \t]*\[(x| )?]/m, function () { + var otp = ' -1)) { + item = showdown.subParser('githubCodeBlocks')(item, options, globals); + item = showdown.subParser('blockGamut')(item, options, globals); + } else { + // Recursion for sub-lists: + item = showdown.subParser('lists')(item, options, globals); + item = item.replace(/\n$/, ''); // chomp(item) + if (isParagraphed) { + item = showdown.subParser('paragraphs')(item, options, globals); + } else { + item = showdown.subParser('spanGamut')(item, options, globals); + } + } + item = '\n' + item + '
    • \n'; + return item; + }); + + // attacklab: strip sentinel + listStr = listStr.replace(/~0/g, ''); + + globals.gListLevel--; + + if (trimTrailing) { + listStr = listStr.replace(/\s+$/, ''); + } + + return listStr; + } + + /** + * Check and parse consecutive lists (better fix for issue #142) + * @param {string} list + * @param {string} listType + * @param {boolean} trimTrailing + * @returns {string} + */ + function parseConsecutiveLists(list, listType, trimTrailing) { + // check if we caught 2 or more consecutive lists by mistake + // we use the counterRgx, meaning if listType is UL we look for UL and vice versa + var counterRxg = (listType === 'ul') ? /^ {0,2}\d+\.[ \t]/gm : /^ {0,2}[*+-][ \t]/gm, + subLists = [], + result = ''; + + if (list.search(counterRxg) !== -1) { + (function parseCL(txt) { + var pos = txt.search(counterRxg); + if (pos !== -1) { + // slice + result += '\n\n<' + listType + '>' + processListItems(txt.slice(0, pos), !!trimTrailing) + '\n\n'; + + // invert counterType and listType + listType = (listType === 'ul') ? 'ol' : 'ul'; + counterRxg = (listType === 'ul') ? /^ {0,2}\d+\.[ \t]/gm : /^ {0,2}[*+-][ \t]/gm; + + //recurse + parseCL(txt.slice(pos)); + } else { + result += '\n\n<' + listType + '>' + processListItems(txt, !!trimTrailing) + '\n\n'; + } + })(list); + for (var i = 0; i < subLists.length; ++i) { + + } + } else { + result = '\n\n<' + listType + '>' + processListItems(list, !!trimTrailing) + '\n\n'; + } + + return result; + } + + // attacklab: add sentinel to hack around khtml/safari bug: + // http://bugs.webkit.org/show_bug.cgi?id=11231 + text += '~0'; + + // Re-usable pattern to match any entire ul or ol list: + var wholeList = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; + + if (globals.gListLevel) { + text = text.replace(wholeList, function (wholeMatch, list, m2) { + var listType = (m2.search(/[*+-]/g) > -1) ? 'ul' : 'ol'; + return parseConsecutiveLists(list, listType, true); + }); + } else { + wholeList = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; + //wholeList = /(\n\n|^\n?)( {0,3}([*+-]|\d+\.)[ \t]+[\s\S]+?)(?=(~0)|(\n\n(?!\t| {2,}| {0,3}([*+-]|\d+\.)[ \t])))/g; + text = text.replace(wholeList, function (wholeMatch, m1, list, m3) { + + var listType = (m3.search(/[*+-]/g) > -1) ? 'ul' : 'ol'; + return parseConsecutiveLists(list, listType); + }); + } + + // attacklab: strip sentinel + text = text.replace(/~0/, ''); + + return text; +}); + +/** + * Remove one level of line-leading tabs or spaces + */ +showdown.subParser('outdent', function (text) { + 'use strict'; + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + text = text.replace(/^(\t|[ ]{1,4})/gm, '~0'); // attacklab: g_tab_width + + // attacklab: clean up hack + text = text.replace(/~0/g, ''); + + return text; +}); + +/** + * + */ +showdown.subParser('paragraphs', function (text, options, globals) { + 'use strict'; + + // Strip leading and trailing lines: + text = text.replace(/^\n+/g, ''); + text = text.replace(/\n+$/g, ''); + + var grafs = text.split(/\n{2,}/g), + grafsOut = [], + end = grafs.length; // Wrap

      tags + + for (var i = 0; i < end; i++) { + var str = grafs[i]; + + // if this is an HTML marker, copy it + if (str.search(/~K(\d+)K/g) >= 0) { + grafsOut.push(str); + } else if (str.search(/\S/) >= 0) { + str = showdown.subParser('spanGamut')(str, options, globals); + str = str.replace(/^([ \t]*)/g, '

      '); + str += '

      '; + grafsOut.push(str); + } + } + + /** Unhashify HTML blocks */ + end = grafsOut.length; + for (i = 0; i < end; i++) { + // if this is a marker for an html block... + while (grafsOut[i].search(/~K(\d+)K/) >= 0) { + var blockText = globals.gHtmlBlocks[RegExp.$1]; + blockText = blockText.replace(/\$/g, '$$$$'); // Escape any dollar signs + grafsOut[i] = grafsOut[i].replace(/~K\d+K/, blockText); + } + } + + return grafsOut.join('\n\n'); +}); + +/** + * Run extension + */ +showdown.subParser('runExtension', function (ext, text, options, globals) { + 'use strict'; + + if (ext.filter) { + text = ext.filter(text, globals.converter, options); + + } else if (ext.regex) { + // TODO remove this when old extension loading mechanism is deprecated + var re = ext.regex; + if (!re instanceof RegExp) { + re = new RegExp(re, 'g'); + } + text = text.replace(re, ext.replace); + } + + return text; +}); + +/** + * These are all the transformations that occur *within* block-level + * tags like paragraphs, headers, and list items. + */ +showdown.subParser('spanGamut', function (text, options, globals) { + 'use strict'; + + text = showdown.subParser('codeSpans')(text, options, globals); + text = showdown.subParser('escapeSpecialCharsWithinTagAttributes')(text, options, globals); + text = showdown.subParser('encodeBackslashEscapes')(text, options, globals); + + // Process anchor and image tags. Images must come first, + // because ![foo][f] looks like an anchor. + text = showdown.subParser('images')(text, options, globals); + text = showdown.subParser('anchors')(text, options, globals); + + // Make links out of things like `` + // Must come after _DoAnchors(), because you can use < and > + // delimiters in inline links like [this](). + text = showdown.subParser('autoLinks')(text, options, globals); + text = showdown.subParser('encodeAmpsAndAngles')(text, options, globals); + text = showdown.subParser('italicsAndBold')(text, options, globals); + text = showdown.subParser('strikethrough')(text, options, globals); + + // Do hard breaks: + text = text.replace(/ +\n/g, '
      \n'); + + return text; + +}); + +showdown.subParser('strikethrough', function (text, options) { + 'use strict'; + + if (options.strikethrough) { + text = text.replace(/(?:~T){2}([^~]+)(?:~T){2}/g, '$1'); + } + + return text; +}); + +/** + * Strip any lines consisting only of spaces and tabs. + * This makes subsequent regexs easier to write, because we can + * match consecutive blank lines with /\n+/ instead of something + * contorted like /[ \t]*\n+/ + */ +showdown.subParser('stripBlankLines', function (text) { + 'use strict'; + return text.replace(/^[ \t]+$/mg, ''); +}); + +/** + * Strips link definitions from text, stores the URLs and titles in + * hash references. + * Link defs are in the form: ^[id]: url "optional title" + * + * ^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1 + * [ \t]* + * \n? // maybe *one* newline + * [ \t]* + * ? // url = $2 + * [ \t]* + * \n? // maybe one newline + * [ \t]* + * (?: + * (\n*) // any lines skipped = $3 attacklab: lookbehind removed + * ["(] + * (.+?) // title = $4 + * [")] + * [ \t]* + * )? // title is optional + * (?:\n+|$) + * /gm, + * function(){...}); + * + */ +showdown.subParser('stripLinkDefinitions', function (text, options, globals) { + 'use strict'; + + var regex = /^ {0,3}\[(.+)]:[ \t]*\n?[ \t]*?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n+|(?=~0))/gm; + + // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug + text += '~0'; + + text = text.replace(regex, function (wholeMatch, linkId, url, width, height, blankLines, title) { + linkId = linkId.toLowerCase(); + globals.gUrls[linkId] = showdown.subParser('encodeAmpsAndAngles')(url); // Link IDs are case-insensitive + + if (blankLines) { + // Oops, found blank lines, so it's not a title. + // Put back the parenthetical statement we stole. + return blankLines + title; + + } else { + if (title) { + globals.gTitles[linkId] = title.replace(/"|'/g, '"'); + } + if (options.parseImgDimensions && width && height) { + globals.gDimensions[linkId] = { + width: width, + height: height + }; + } + } + // Completely remove the definition from the text + return ''; + }); + + // attacklab: strip sentinel + text = text.replace(/~0/, ''); + + return text; +}); + +showdown.subParser('tables', function (text, options, globals) { + 'use strict'; + + var table = function () { + + var tables = {}, + filter; + + tables.th = function (header, style) { + var id = ''; + header = header.trim(); + if (header === '') { + return ''; + } + if (options.tableHeaderId) { + id = ' id="' + header.replace(/ /g, '_').toLowerCase() + '"'; + } + header = showdown.subParser('spanGamut')(header, options, globals); + if (!style || style.trim() === '') { + style = ''; + } else { + style = ' style="' + style + '"'; + } + return '' + header + ''; + }; + + tables.td = function (cell, style) { + var subText = showdown.subParser('spanGamut')(cell.trim(), options, globals); + if (!style || style.trim() === '') { + style = ''; + } else { + style = ' style="' + style + '"'; + } + return '' + subText + ''; + }; + + tables.ths = function () { + var out = '', + i = 0, + hs = [].slice.apply(arguments[0]), + style = [].slice.apply(arguments[1]); + + for (i; i < hs.length; i += 1) { + out += tables.th(hs[i], style[i]) + '\n'; + } + + return out; + }; + + tables.tds = function () { + var out = '', + i = 0, + ds = [].slice.apply(arguments[0]), + style = [].slice.apply(arguments[1]); + + for (i; i < ds.length; i += 1) { + out += tables.td(ds[i], style[i]) + '\n'; + } + return out; + }; + + tables.thead = function () { + var out, + hs = [].slice.apply(arguments[0]), + style = [].slice.apply(arguments[1]); + + out = '\n'; + out += '\n'; + out += tables.ths.apply(this, [hs, style]); + out += '\n'; + out += '\n'; + return out; + }; + + tables.tr = function () { + var out, + cs = [].slice.apply(arguments[0]), + style = [].slice.apply(arguments[1]); + + out = '\n'; + out += tables.tds.apply(this, [cs, style]); + out += '\n'; + return out; + }; + + filter = function (text) { + var i = 0, + lines = text.split('\n'), + line, + hs, + out = []; + + for (i; i < lines.length; i += 1) { + line = lines[i]; + // looks like a table heading + if (line.trim().match(/^[|].*[|]$/)) { + line = line.trim(); + + var tbl = [], + align = lines[i + 1].trim(), + styles = [], + j = 0; + + if (align.match(/^[|][-=|: ]+[|]$/)) { + styles = align.substring(1, align.length - 1).split('|'); + for (j = 0; j < styles.length; ++j) { + styles[j] = styles[j].trim(); + if (styles[j].match(/^[:][-=| ]+[:]$/)) { + styles[j] = 'text-align:center;'; + + } else if (styles[j].match(/^[-=| ]+[:]$/)) { + styles[j] = 'text-align:right;'; + + } else if (styles[j].match(/^[:][-=| ]+$/)) { + styles[j] = 'text-align:left;'; + } else { + styles[j] = ''; + } + } + } + tbl.push(''); + hs = line.substring(1, line.length - 1).split('|'); + + if (styles.length === 0) { + for (j = 0; j < hs.length; ++j) { + styles.push('text-align:left'); + } + } + tbl.push(tables.thead.apply(this, [hs, styles])); + line = lines[++i]; + if (!line.trim().match(/^[|][-=|: ]+[|]$/)) { + // not a table rolling back + line = lines[--i]; + } else { + line = lines[++i]; + tbl.push(''); + while (line.trim().match(/^[|].*[|]$/)) { + line = line.trim(); + tbl.push(tables.tr.apply(this, [line.substring(1, line.length - 1).split('|'), styles])); + line = lines[++i]; + } + tbl.push(''); + tbl.push('
      '); + // we are done with this table and we move along + out.push(tbl.join('\n')); + continue; + } + } + out.push(line); + } + return out.join('\n'); + }; + return {parse: filter}; + }; + + if (options.tables) { + var tableParser = table(); + return tableParser.parse(text); + } else { + return text; + } +}); + +/** + * Swap back in all the special characters we've hidden. + */ +showdown.subParser('unescapeSpecialChars', function (text) { + 'use strict'; + + text = text.replace(/~E(\d+)E/g, function (wholeMatch, m1) { + var charCodeToReplace = parseInt(m1); + return String.fromCharCode(charCodeToReplace); + }); + return text; +}); + +var root = this; + +// CommonJS/nodeJS Loader +if (typeof module !== 'undefined' && module.exports) { + module.exports = showdown; + +// AMD Loader +} else if (typeof define === 'function' && define.amd) { + define('showdown', function () { + 'use strict'; + return showdown; + }); + +// Regular Browser loader +} else { + root.showdown = showdown; +} +}).call(this); +//# sourceMappingURL=showdown.js.map diff --git a/src/scripts/markdown/04_pagedown-extra.js b/src/scripts/markdown/04_pagedown-extra.js new file mode 100644 index 00000000..2a5e35f5 --- /dev/null +++ b/src/scripts/markdown/04_pagedown-extra.js @@ -0,0 +1,874 @@ +(function () { + // A quick way to make sure we're only keeping span-level tags when we need to. + // This isn't supposed to be foolproof. It's just a quick way to make sure we + // keep all span-level tags returned by a pagedown converter. It should allow + // all span-level tags through, with or without attributes. + var inlineTags = new RegExp(['^(<\\/?(a|abbr|acronym|applet|area|b|basefont|', + 'bdo|big|button|cite|code|del|dfn|em|figcaption|', + 'font|i|iframe|img|input|ins|kbd|label|map|', + 'mark|meter|object|param|progress|q|ruby|rp|rt|s|', + 'samp|script|select|small|span|strike|strong|', + 'sub|sup|textarea|time|tt|u|var|wbr)[^>]*>|', + '<(br)\\s?\\/?>)$'].join(''), 'i'); + + /****************************************************************** + * Utility Functions * + *****************************************************************/ + + // patch for ie7 + if (!Array.indexOf) { + Array.prototype.indexOf = function(obj) { + for (var i = 0; i < this.length; i++) { + if (this[i] == obj) { + return i; + } + } + return -1; + }; + } + + function trim(str) { + return str.replace(/^\s+|\s+$/g, ''); + } + + function rtrim(str) { + return str.replace(/\s+$/g, ''); + } + + // Remove one level of indentation from text. Indent is 4 spaces. + function outdent(text) { + return text.replace(new RegExp('^(\\t|[ ]{1,4})', 'gm'), ''); + } + + function contains(str, substr) { + return str.indexOf(substr) != -1; + } + + // Sanitize html, removing tags that aren't in the whitelist + function sanitizeHtml(html, whitelist) { + return html.replace(/<[^>]*>?/gi, function(tag) { + return tag.match(whitelist) ? tag : ''; + }); + } + + // Merge two arrays, keeping only unique elements. + function union(x, y) { + var obj = {}; + for (var i = 0; i < x.length; i++) + obj[x[i]] = x[i]; + for (i = 0; i < y.length; i++) + obj[y[i]] = y[i]; + var res = []; + for (var k in obj) { + if (obj.hasOwnProperty(k)) + res.push(obj[k]); + } + return res; + } + + // JS regexes don't support \A or \Z, so we add sentinels, as Pagedown + // does. In this case, we add the ascii codes for start of text (STX) and + // end of text (ETX), an idea borrowed from: + // https://github.com/tanakahisateru/js-markdown-extra + function addAnchors(text) { + if(text.charAt(0) != '\x02') + text = '\x02' + text; + if(text.charAt(text.length - 1) != '\x03') + text = text + '\x03'; + return text; + } + + // Remove STX and ETX sentinels. + function removeAnchors(text) { + if(text.charAt(0) == '\x02') + text = text.substr(1); + if(text.charAt(text.length - 1) == '\x03') + text = text.substr(0, text.length - 1); + return text; + } + + // Convert markdown within an element, retaining only span-level tags + function convertSpans(text, extra) { + return sanitizeHtml(convertAll(text, extra), inlineTags); + } + + // Convert internal markdown using the stock pagedown converter + function convertAll(text, extra) { + var result = extra.blockGamutHookCallback(text); + // We need to perform these operations since we skip the steps in the converter + result = unescapeSpecialChars(result); + result = result.replace(/~D/g, "$$").replace(/~T/g, "~"); + result = extra.previousPostConversion(result); + return result; + } + + // Convert escaped special characters + function processEscapesStep1(text) { + // Markdown extra adds two escapable characters, `:` and `|` + return text.replace(/\\\|/g, '~I').replace(/\\:/g, '~i'); + } + function processEscapesStep2(text) { + return text.replace(/~I/g, '|').replace(/~i/g, ':'); + } + + // Duplicated from PageDown converter + function unescapeSpecialChars(text) { + // Swap back in all the special characters we've hidden. + text = text.replace(/~E(\d+)E/g, function(wholeMatch, m1) { + var charCodeToReplace = parseInt(m1); + return String.fromCharCode(charCodeToReplace); + }); + return text; + } + + function slugify(text) { + return text.toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w\-]+/g, '') // Remove all non-word chars + .replace(/\-\-+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text + } + + /***************************************************************************** + * Markdown.Extra * + ****************************************************************************/ + + Markdown.Extra = function() { + // For converting internal markdown (in tables for instance). + // This is necessary since these methods are meant to be called as + // preConversion hooks, and the Markdown converter passed to init() + // won't convert any markdown contained in the html tags we return. + this.converter = null; + + // Stores html blocks we generate in hooks so that + // they're not destroyed if the user is using a sanitizing converter + this.hashBlocks = []; + + // Stores footnotes + this.footnotes = {}; + this.usedFootnotes = []; + + // Special attribute blocks for fenced code blocks and headers enabled. + this.attributeBlocks = false; + + // Fenced code block options + this.googleCodePrettify = false; + this.highlightJs = false; + + // Table options + this.tableClass = ''; + + this.tabWidth = 4; + }; + + Markdown.Extra.init = function(converter, options) { + // Each call to init creates a new instance of Markdown.Extra so it's + // safe to have multiple converters, with different options, on a single page + var extra = new Markdown.Extra(); + var postNormalizationTransformations = []; + var preBlockGamutTransformations = []; + var postSpanGamutTransformations = []; + var postConversionTransformations = ["unHashExtraBlocks"]; + + options = options || {}; + options.extensions = options.extensions || ["all"]; + if (contains(options.extensions, "all")) { + options.extensions = ["tables", "fenced_code_gfm", "def_list", "attr_list", "footnotes", "smartypants", "strikethrough", "newlines"]; + } + preBlockGamutTransformations.push("wrapHeaders"); + if (contains(options.extensions, "attr_list")) { + postNormalizationTransformations.push("hashFcbAttributeBlocks"); + preBlockGamutTransformations.push("hashHeaderAttributeBlocks"); + postConversionTransformations.push("applyAttributeBlocks"); + extra.attributeBlocks = true; + } + if (contains(options.extensions, "fenced_code_gfm")) { + // This step will convert fcb inside list items and blockquotes + preBlockGamutTransformations.push("fencedCodeBlocks"); + // This extra step is to prevent html blocks hashing and link definition/footnotes stripping inside fcb + postNormalizationTransformations.push("fencedCodeBlocks"); + } + if (contains(options.extensions, "tables")) { + preBlockGamutTransformations.push("tables"); + } + if (contains(options.extensions, "def_list")) { + preBlockGamutTransformations.push("definitionLists"); + } + if (contains(options.extensions, "footnotes")) { + postNormalizationTransformations.push("stripFootnoteDefinitions"); + preBlockGamutTransformations.push("doFootnotes"); + postConversionTransformations.push("printFootnotes"); + } + if (contains(options.extensions, "smartypants")) { + postConversionTransformations.push("runSmartyPants"); + } + if (contains(options.extensions, "strikethrough")) { + postSpanGamutTransformations.push("strikethrough"); + } + if (contains(options.extensions, "newlines")) { + postSpanGamutTransformations.push("newlines"); + } + + converter.hooks.chain("postNormalization", function(text) { + return extra.doTransform(postNormalizationTransformations, text) + '\n'; + }); + + converter.hooks.chain("preBlockGamut", function(text, blockGamutHookCallback) { + // Keep a reference to the block gamut callback to run recursively + extra.blockGamutHookCallback = blockGamutHookCallback; + text = processEscapesStep1(text); + text = extra.doTransform(preBlockGamutTransformations, text) + '\n'; + text = processEscapesStep2(text); + return text; + }); + + converter.hooks.chain("postSpanGamut", function(text) { + return extra.doTransform(postSpanGamutTransformations, text); + }); + + // Keep a reference to the hook chain running before doPostConversion to apply on hashed extra blocks + extra.previousPostConversion = converter.hooks.postConversion; + converter.hooks.chain("postConversion", function(text) { + text = extra.doTransform(postConversionTransformations, text); + // Clear state vars that may use unnecessary memory + extra.hashBlocks = []; + extra.footnotes = {}; + extra.usedFootnotes = []; + return text; + }); + + if ("highlighter" in options) { + extra.googleCodePrettify = options.highlighter === 'prettify'; + extra.highlightJs = options.highlighter === 'highlight'; + } + + if ("table_class" in options) { + extra.tableClass = options.table_class; + } + + extra.converter = converter; + + // Caller usually won't need this, but it's handy for testing. + return extra; + }; + + // Do transformations + Markdown.Extra.prototype.doTransform = function(transformations, text) { + for(var i = 0; i < transformations.length; i++) + text = this[transformations[i]](text); + return text; + }; + + // Return a placeholder containing a key, which is the block's index in the + // hashBlocks array. We wrap our output in a

      tag here so Pagedown won't. + Markdown.Extra.prototype.hashExtraBlock = function(block) { + return '\n

      ~X' + (this.hashBlocks.push(block) - 1) + 'X

      \n'; + }; + Markdown.Extra.prototype.hashExtraInline = function(block) { + return '~X' + (this.hashBlocks.push(block) - 1) + 'X'; + }; + + // Replace placeholder blocks in `text` with their corresponding + // html blocks in the hashBlocks array. + Markdown.Extra.prototype.unHashExtraBlocks = function(text) { + var self = this; + function recursiveUnHash() { + var hasHash = false; + text = text.replace(/(?:

      )?~X(\d+)X(?:<\/p>)?/g, function(wholeMatch, m1) { + hasHash = true; + var key = parseInt(m1, 10); + return self.hashBlocks[key]; + }); + if(hasHash === true) { + recursiveUnHash(); + } + } + recursiveUnHash(); + return text; + }; + + // Wrap headers to make sure they won't be in def lists + Markdown.Extra.prototype.wrapHeaders = function(text) { + function wrap(text) { + return '\n' + text + '\n'; + } + text = text.replace(/^.+[ \t]*\n=+[ \t]*\n+/gm, wrap); + text = text.replace(/^.+[ \t]*\n-+[ \t]*\n+/gm, wrap); + text = text.replace(/^\#{1,6}[ \t]*.+?[ \t]*\#*\n+/gm, wrap); + return text; + }; + + + /****************************************************************** + * Attribute Blocks * + *****************************************************************/ + + // TODO: use sentinels. Should we just add/remove them in doConversion? + // TODO: better matches for id / class attributes + var attrBlock = "\\{[ \\t]*((?:[#.][-_:a-zA-Z0-9]+[ \\t]*)+)\\}"; + var hdrAttributesA = new RegExp("^(#{1,6}.*#{0,6})[ \\t]+" + attrBlock + "[ \\t]*(?:\\n|0x03)", "gm"); + var hdrAttributesB = new RegExp("^(.*)[ \\t]+" + attrBlock + "[ \\t]*\\n" + + "(?=[\\-|=]+\\s*(?:\\n|0x03))", "gm"); // underline lookahead + var fcbAttributes = new RegExp("^(```[^`\\n]*)[ \\t]+" + attrBlock + "[ \\t]*\\n" + + "(?=([\\s\\S]*?)\\n```[ \\t]*(\\n|0x03))", "gm"); + + // Extract headers attribute blocks, move them above the element they will be + // applied to, and hash them for later. + Markdown.Extra.prototype.hashHeaderAttributeBlocks = function(text) { + + var self = this; + function attributeCallback(wholeMatch, pre, attr) { + return '

      ~XX' + (self.hashBlocks.push(attr) - 1) + 'XX

      \n' + pre + "\n"; + } + + text = text.replace(hdrAttributesA, attributeCallback); // ## headers + text = text.replace(hdrAttributesB, attributeCallback); // underline headers + return text; + }; + + // Extract FCB attribute blocks, move them above the element they will be + // applied to, and hash them for later. + Markdown.Extra.prototype.hashFcbAttributeBlocks = function(text) { + // TODO: use sentinels. Should we just add/remove them in doConversion? + // TODO: better matches for id / class attributes + + var self = this; + function attributeCallback(wholeMatch, pre, attr) { + return '

      ~XX' + (self.hashBlocks.push(attr) - 1) + 'XX

      \n' + pre + "\n"; + } + + return text.replace(fcbAttributes, attributeCallback); + }; + + Markdown.Extra.prototype.applyAttributeBlocks = function(text) { + var self = this; + var blockRe = new RegExp('

      ~XX(\\d+)XX

      [\\s]*' + + '(?:<(h[1-6]|pre)(?: +class="(\\S+)")?(>[\\s\\S]*?))', "gm"); + text = text.replace(blockRe, function(wholeMatch, k, tag, cls, rest) { + if (!tag) // no following header or fenced code block. + return ''; + + // get attributes list from hash + var key = parseInt(k, 10); + var attributes = self.hashBlocks[key]; + + // get id + var id = attributes.match(/#[^\s#.]+/g) || []; + var idStr = id[0] ? ' id="' + id[0].substr(1, id[0].length - 1) + '"' : ''; + + // get classes and merge with existing classes + var classes = attributes.match(/\.[^\s#.]+/g) || []; + for (var i = 0; i < classes.length; i++) // Remove leading dot + classes[i] = classes[i].substr(1, classes[i].length - 1); + + var classStr = ''; + if (cls) + classes = union(classes, [cls]); + + if (classes.length > 0) + classStr = ' class="' + classes.join(' ') + '"'; + + return "<" + tag + idStr + classStr + rest; + }); + + return text; + }; + + /****************************************************************** + * Tables * + *****************************************************************/ + + // Find and convert Markdown Extra tables into html. + Markdown.Extra.prototype.tables = function(text) { + var self = this; + + var leadingPipe = new RegExp( + ['^' , + '[ ]{0,3}' , // Allowed whitespace + '[|]' , // Initial pipe + '(.+)\\n' , // $1: Header Row + + '[ ]{0,3}' , // Allowed whitespace + '[|]([ ]*[-:]+[-| :]*)\\n' , // $2: Separator + + '(' , // $3: Table Body + '(?:[ ]*[|].*\\n?)*' , // Table rows + ')', + '(?:\\n|$)' // Stop at final newline + ].join(''), + 'gm' + ); + + var noLeadingPipe = new RegExp( + ['^' , + '[ ]{0,3}' , // Allowed whitespace + '(\\S.*[|].*)\\n' , // $1: Header Row + + '[ ]{0,3}' , // Allowed whitespace + '([-:]+[ ]*[|][-| :]*)\\n' , // $2: Separator + + '(' , // $3: Table Body + '(?:.*[|].*\\n?)*' , // Table rows + ')' , + '(?:\\n|$)' // Stop at final newline + ].join(''), + 'gm' + ); + + text = text.replace(leadingPipe, doTable); + text = text.replace(noLeadingPipe, doTable); + + // $1 = header, $2 = separator, $3 = body + function doTable(match, header, separator, body, offset, string) { + // remove any leading pipes and whitespace + header = header.replace(/^ *[|]/m, ''); + separator = separator.replace(/^ *[|]/m, ''); + body = body.replace(/^ *[|]/gm, ''); + + // remove trailing pipes and whitespace + header = header.replace(/[|] *$/m, ''); + separator = separator.replace(/[|] *$/m, ''); + body = body.replace(/[|] *$/gm, ''); + + // determine column alignments + var alignspecs = separator.split(/ *[|] */); + var align = []; + for (var i = 0; i < alignspecs.length; i++) { + var spec = alignspecs[i]; + if (spec.match(/^ *-+: *$/m)) + align[i] = ' align="right"'; + else if (spec.match(/^ *:-+: *$/m)) + align[i] = ' align="center"'; + else if (spec.match(/^ *:-+ *$/m)) + align[i] = ' align="left"'; + else align[i] = ''; + } + + // TODO: parse spans in header and rows before splitting, so that pipes + // inside of tags are not interpreted as separators + var headers = header.split(/ *[|] */); + var colCount = headers.length; + + // build html + var cls = self.tableClass ? ' class="' + self.tableClass + '"' : ''; + var html = ['\n', '\n', '\n'].join(''); + + // build column headers. + for (i = 0; i < colCount; i++) { + var headerHtml = convertSpans(trim(headers[i]), self); + html += [" ", headerHtml, "\n"].join(''); + } + html += "\n\n"; + + // build rows + var rows = body.split('\n'); + for (i = 0; i < rows.length; i++) { + if (rows[i].match(/^\s*$/)) // can apply to final row + continue; + + // ensure number of rowCells matches colCount + var rowCells = rows[i].split(/ *[|] */); + var lenDiff = colCount - rowCells.length; + for (var j = 0; j < lenDiff; j++) + rowCells.push(''); + + html += "\n"; + for (j = 0; j < colCount; j++) { + var colHtml = convertSpans(trim(rowCells[j]), self); + html += [" ", colHtml, "\n"].join(''); + } + html += "\n"; + } + + html += "\n"; + + // replace html with placeholder until postConversion step + return self.hashExtraBlock(html); + } + + return text; + }; + + + /****************************************************************** + * Footnotes * + *****************************************************************/ + + // Strip footnote, store in hashes. + Markdown.Extra.prototype.stripFootnoteDefinitions = function(text) { + var self = this; + + text = text.replace( + /\n[ ]{0,3}\[\^(.+?)\]\:[ \t]*\n?([\s\S]*?)\n{1,2}((?=\n[ ]{0,3}\S)|$)/g, + function(wholeMatch, m1, m2) { + m1 = slugify(m1); + m2 += "\n"; + m2 = m2.replace(/^[ ]{0,3}/g, ""); + self.footnotes[m1] = m2; + return "\n"; + }); + + return text; + }; + + + // Find and convert footnotes references. + Markdown.Extra.prototype.doFootnotes = function(text) { + var self = this; + if(self.isConvertingFootnote === true) { + return text; + } + + var footnoteCounter = 0; + text = text.replace(/\[\^(.+?)\]/g, function(wholeMatch, m1) { + var id = slugify(m1); + var footnote = self.footnotes[id]; + if (footnote === undefined) { + return wholeMatch; + } + footnoteCounter++; + self.usedFootnotes.push(id); + var html = '' + footnoteCounter + + ''; + return self.hashExtraInline(html); + }); + + return text; + }; + + // Print footnotes at the end of the document + Markdown.Extra.prototype.printFootnotes = function(text) { + var self = this; + + if (self.usedFootnotes.length === 0) { + return text; + } + + text += '\n\n
      \n
      \n
        \n\n'; + for(var i=0; i' + + formattedfootnote + + ' \n\n'; + } + text += '
      \n
      '; + return text; + }; + + + /****************************************************************** + * Fenced Code Blocks (gfm) * + ******************************************************************/ + + // Find and convert gfm-inspired fenced code blocks into html. + Markdown.Extra.prototype.fencedCodeBlocks = function(text) { + function encodeCode(code) { + code = code.replace(/&/g, "&"); + code = code.replace(//g, ">"); + // These were escaped by PageDown before postNormalization + code = code.replace(/~D/g, "$$"); + code = code.replace(/~T/g, "~"); + return code; + } + + var self = this; + text = text.replace(/(?:^|\n)```([^`\n]*)\n([\s\S]*?)\n```[ \t]*(?=\n)/g, function(match, m1, m2) { + var language = trim(m1), codeblock = m2; + + // adhere to specified options + var preclass = self.googleCodePrettify ? ' class="prettyprint"' : ''; + var codeclass = ''; + if (language) { + if (self.googleCodePrettify || self.highlightJs) { + // use html5 language- class names. supported by both prettify and highlight.js + codeclass = ' class="language-' + language + '"'; + } else { + codeclass = ' class="' + language + '"'; + } + } + + var html = ['', + encodeCode(codeblock), '
      '].join(''); + + // replace codeblock with placeholder until postConversion step + return self.hashExtraBlock(html); + }); + + return text; + }; + + + /****************************************************************** + * SmartyPants * + ******************************************************************/ + + Markdown.Extra.prototype.educatePants = function(text) { + var self = this; + var result = ''; + var blockOffset = 0; + // Here we parse HTML in a very bad manner + text.replace(/(?:)|(<)([a-zA-Z1-6]+)([^\n]*?>)([\s\S]*?)(<\/\2>)/g, function(wholeMatch, m1, m2, m3, m4, m5, offset) { + var token = text.substring(blockOffset, offset); + result += self.applyPants(token); + self.smartyPantsLastChar = result.substring(result.length - 1); + blockOffset = offset + wholeMatch.length; + if(!m1) { + // Skip commentary + result += wholeMatch; + return; + } + // Skip special tags + if(!/code|kbd|pre|script|noscript|iframe|math|ins|del|pre/i.test(m2)) { + m4 = self.educatePants(m4); + } + else { + self.smartyPantsLastChar = m4.substring(m4.length - 1); + } + result += m1 + m2 + m3 + m4 + m5; + }); + var lastToken = text.substring(blockOffset); + result += self.applyPants(lastToken); + self.smartyPantsLastChar = result.substring(result.length - 1); + return result; + }; + + function revertPants(wholeMatch, m1) { + var blockText = m1; + blockText = blockText.replace(/&\#8220;/g, "\""); + blockText = blockText.replace(/&\#8221;/g, "\""); + blockText = blockText.replace(/&\#8216;/g, "'"); + blockText = blockText.replace(/&\#8217;/g, "'"); + blockText = blockText.replace(/&\#8212;/g, "---"); + blockText = blockText.replace(/&\#8211;/g, "--"); + blockText = blockText.replace(/&\#8230;/g, "..."); + return blockText; + } + + Markdown.Extra.prototype.applyPants = function(text) { + // Dashes + text = text.replace(/---/g, "—").replace(/--/g, "–"); + // Ellipses + text = text.replace(/\.\.\./g, "…").replace(/\.\s\.\s\./g, "…"); + // Backticks + text = text.replace(/``/g, "“").replace (/''/g, "”"); + + if(/^'$/.test(text)) { + // Special case: single-character ' token + if(/\S/.test(this.smartyPantsLastChar)) { + return "’"; + } + return "‘"; + } + if(/^"$/.test(text)) { + // Special case: single-character " token + if(/\S/.test(this.smartyPantsLastChar)) { + return "”"; + } + return "“"; + } + + // Special case if the very first character is a quote + // followed by punctuation at a non-word-break. Close the quotes by brute force: + text = text.replace (/^'(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "’"); + text = text.replace (/^"(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "”"); + + // Special case for double sets of quotes, e.g.: + //

      He said, "'Quoted' words in a larger quote."

      + text = text.replace(/"'(?=\w)/g, "“‘"); + text = text.replace(/'"(?=\w)/g, "‘“"); + + // Special case for decade abbreviations (the '80s): + text = text.replace(/'(?=\d{2}s)/g, "’"); + + // Get most opening single quotes: + text = text.replace(/(\s| |--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)'(?=\w)/g, "$1‘"); + + // Single closing quotes: + text = text.replace(/([^\s\[\{\(\-])'/g, "$1’"); + text = text.replace(/'(?=\s|s\b)/g, "’"); + + // Any remaining single quotes should be opening ones: + text = text.replace(/'/g, "‘"); + + // Get most opening double quotes: + text = text.replace(/(\s| |--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)"(?=\w)/g, "$1“"); + + // Double closing quotes: + text = text.replace(/([^\s\[\{\(\-])"/g, "$1”"); + text = text.replace(/"(?=\s)/g, "”"); + + // Any remaining quotes should be opening ones. + text = text.replace(/"/ig, "“"); + return text; + }; + + // Find and convert markdown extra definition lists into html. + Markdown.Extra.prototype.runSmartyPants = function(text) { + this.smartyPantsLastChar = ''; + text = this.educatePants(text); + // Clean everything inside html tags (some of them may have been converted due to our rough html parsing) + text = text.replace(/(<([a-zA-Z1-6]+)\b([^\n>]*?)(\/)?>)/g, revertPants); + return text; + }; + + /****************************************************************** + * Definition Lists * + ******************************************************************/ + + // Find and convert markdown extra definition lists into html. + Markdown.Extra.prototype.definitionLists = function(text) { + var wholeList = new RegExp( + ['(\\x02\\n?|\\n\\n)' , + '(?:' , + '(' , // $1 = whole list + '(' , // $2 + '[ ]{0,3}' , + '((?:[ \\t]*\\S.*\\n)+)', // $3 = defined term + '\\n?' , + '[ ]{0,3}:[ ]+' , // colon starting definition + ')' , + '([\\s\\S]+?)' , + '(' , // $4 + '(?=\\0x03)' , // \z + '|' , + '(?=' , + '\\n{2,}' , + '(?=\\S)' , + '(?!' , // Negative lookahead for another term + '[ ]{0,3}' , + '(?:\\S.*\\n)+?' , // defined term + '\\n?' , + '[ ]{0,3}:[ ]+' , // colon starting definition + ')' , + '(?!' , // Negative lookahead for another definition + '[ ]{0,3}:[ ]+' , // colon starting definition + ')' , + ')' , + ')' , + ')' , + ')' + ].join(''), + 'gm' + ); + + var self = this; + text = addAnchors(text); + + text = text.replace(wholeList, function(match, pre, list) { + var result = trim(self.processDefListItems(list)); + result = "
      \n" + result + "\n
      "; + return pre + self.hashExtraBlock(result) + "\n\n"; + }); + + return removeAnchors(text); + }; + + // Process the contents of a single definition list, splitting it + // into individual term and definition list items. + Markdown.Extra.prototype.processDefListItems = function(listStr) { + var self = this; + + var dt = new RegExp( + ['(\\x02\\n?|\\n\\n+)' , // leading line + '(' , // definition terms = $1 + '[ ]{0,3}' , // leading whitespace + '(?![:][ ]|[ ])' , // negative lookahead for a definition + // mark (colon) or more whitespace + '(?:\\S.*\\n)+?' , // actual term (not whitespace) + ')' , + '(?=\\n?[ ]{0,3}:[ ])' // lookahead for following line feed + ].join(''), // with a definition mark + 'gm' + ); + + var dd = new RegExp( + ['\\n(\\n+)?' , // leading line = $1 + '(' , // marker space = $2 + '[ ]{0,3}' , // whitespace before colon + '[:][ ]+' , // definition mark (colon) + ')' , + '([\\s\\S]+?)' , // definition text = $3 + '(?=\\n*' , // stop at next definition mark, + '(?:' , // next term or end of text + '\\n[ ]{0,3}[:][ ]|' , + '
      |\\x03' , // \z + ')' , + ')' + ].join(''), + 'gm' + ); + + listStr = addAnchors(listStr); + // trim trailing blank lines: + listStr = listStr.replace(/\n{2,}(?=\\x03)/, "\n"); + + // Process definition terms. + listStr = listStr.replace(dt, function(match, pre, termsStr) { + var terms = trim(termsStr).split("\n"); + var text = ''; + for (var i = 0; i < terms.length; i++) { + var term = terms[i]; + // process spans inside dt + term = convertSpans(trim(term), self); + text += "\n
      " + term + "
      "; + } + return text + "\n"; + }); + + // Process actual definitions. + listStr = listStr.replace(dd, function(match, leadingLine, markerSpace, def) { + if (leadingLine || def.match(/\n{2,}/)) { + // replace marker with the appropriate whitespace indentation + def = Array(markerSpace.length + 1).join(' ') + def; + // process markdown inside definition + // TODO?: currently doesn't apply extensions + def = outdent(def) + "\n\n"; + def = "\n" + convertAll(def, self) + "\n"; + } else { + // convert span-level markdown inside definition + def = rtrim(def); + def = convertSpans(outdent(def), self); + } + + return "\n
      " + def + "
      \n"; + }); + + return removeAnchors(listStr); + }; + + + /*********************************************************** + * Strikethrough * + ************************************************************/ + + Markdown.Extra.prototype.strikethrough = function(text) { + // Pretty much duplicated from _DoItalicsAndBold + return text.replace(/([\W_]|^)~T~T(?=\S)([^\r]*?\S[\*_]*)~T~T([\W_]|$)/g, + "$1$2$3"); + }; + + + /*********************************************************** + * New lines * + ************************************************************/ + + Markdown.Extra.prototype.newlines = function(text) { + // We have to ignore already converted newlines and line breaks in sub-list items + return text.replace(/(<(?:br|\/li)>)?\n/g, function(wholeMatch, previousTag) { + return previousTag ? wholeMatch : "
      \n"; + }); + }; + +})(); + diff --git a/src/scripts/project-edit.js b/src/scripts/project-edit.js new file mode 100644 index 00000000..40b4a9d2 --- /dev/null +++ b/src/scripts/project-edit.js @@ -0,0 +1,238 @@ +/* Edit Node */ + + +/* Move Node */ +var movingMode = Cookies.getJSON('bcloud_moving_node'); + + +function editNode(nodeId) { + + // Remove the 'n_' suffix from the id + if (nodeId.substring(0, 2) == 'n_') { + nodeId = nodeId.substr(2); + } + + var url = '/nodes/' + nodeId + '/edit?embed=1'; + $.get(url, function(dataHtml) { + // Update the DOM injecting the generate HTML into the page + $('#project_context').html(dataHtml); + updateUi(nodeId, 'edit'); + }) + .fail(function(dataResponse) { + $('#project_context').html($('') + }); + +| {% endblock %} diff --git a/src/templates/layout.jade b/src/templates/layout.jade new file mode 100644 index 00000000..4c30efa2 --- /dev/null +++ b/src/templates/layout.jade @@ -0,0 +1,436 @@ +doctype +html(lang="en") + head + meta(charset="utf-8") + title {% if self.page_title() %}{% block page_title %}{% endblock %} — {% endif %}Blender Cloud + meta(name="viewport", content="width=device-width, initial-scale=1.0") + meta(name="description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.") + meta(name="author", content="Blender Institute") + meta(name="theme-color", content="#3e92aa") + + meta(property="og:site_name", content="Blender Cloud") + | {% block og %} + meta(property="og:title", content="Blender Cloud") + meta(property="og:url", content="https://cloud.blender.org") + meta(property="og:type", content="website") + meta(property="og:image", content="{{ url_for('static', filename='assets/img/backgrounds/background_gleb_locomotive.jpg')}}") + | {% endblock %} + + meta(name="twitter:card", content="summary_large_image") + meta(name="twitter:site", content="@Blender_Cloud") + | {% block tw %} + meta(name="twitter:title", content="Blender Cloud") + meta(name="twitter:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.") + meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/backgrounds/background_gleb_locomotive.jpg')}}") + | {% endblock %} + + script(src="//code.jquery.com/jquery-2.2.1.min.js") + script(src="//cdn.jsdelivr.net/typeahead.js/0.11.1/typeahead.jquery.min.js") + script(src="//cdn.jsdelivr.net/algoliasearch/3/algoliasearch.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/js-cookie/2.0.3/js.cookie.min.js",) + + script. + var algolia = algoliasearch("{{config['ALGOLIA_USER']}}", "{{config['ALGOLIA_PUBLIC_KEY']}}"); + var index = algolia.initIndex("{{config['ALGOLIA_INDEX_NODES']}}"); + + !function(e){"use strict";e.loadCSS=function(t,n,o){var r,i=e.document,l=i.createElement("link");if(n)r=n;else{var d=(i.body||i.getElementsByTagName("head")[0]).childNodes;r=d[d.length-1]}var a=i.styleSheets;l.rel="stylesheet",l.href=t,l.media="only x",r.parentNode.insertBefore(l,n?r:r.nextSibling);var f=function(e){for(var t=l.href,n=a.length;n--;)if(a[n].href===t)return e();setTimeout(function(){f(e)})};return l.onloadcssdefined=f,f(function(){l.media=o||"all"}),l},"undefined"!=typeof module&&(module.exports=e.loadCSS)}(this); + + loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400,500" ); + loadCSS( "//fonts.googleapis.com/css?family=Lato:300,400" ); + + script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js', v=040820161) }}") + script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js', v=040820161) }}") + + link(href="{{ url_for('static_pillar', filename='assets/ico/favicon.png') }}", rel="shortcut icon") + link(href="{{ url_for('static_pillar', filename='assets/ico/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192") + + link(href="{{ url_for('static_pillar', filename='assets/css/vendor/bootstrap.min.css') }}", rel="stylesheet") + + | {% block head %}{% endblock %} + + | {% block css %} + | {% if title == 'blog' %} + link(href="{{ url_for('static_pillar', filename='assets/css/blog.css', v=040820161) }}", rel="stylesheet") + | {% else %} + link(href="{{ url_for('static_pillar', filename='assets/css/main.css', v=040820161) }}", rel="stylesheet") + | {% endif %} + | {% endblock %} + + + | {% if not title %}{% set title="default" %}{% endif %} + + body(class="{{ title }}") + .container-page + header.navbar-backdrop-container + | {% block header_backdrop %} + img(src="{{ url_for('static', filename='assets/img/backgrounds/pattern_02_blur.jpg')}}") + | {% endblock %} + + | {% with messages = get_flashed_messages(with_categories=True) %} + | {% if messages %} + + | {% for (category, message) in messages %} + .alert(role="alert", class="alert-{{ category }}") + i.alert-icon(class="{{ category }}") + span {{ message }} + button.close(type="button", data-dismiss="alert") + i.pi-cancel + | {% endfor %} + + | {% endif %} + | {% endwith %} + + nav.navbar.navbar-transparent.navbar-fixed-top + .navbar-overlay + + .navbar-container + header.navbar-header + button.navbar-toggle(data-target=".navbar-collapse", data-toggle="collapse", type="button") + span.sr-only Toggle navigation + i.pi-menu + a.navbar-brand( + href="/", + title="Blender Cloud") + span.app-logo + i.pi-blender-cloud + + | {% block navigation_search %} + .search-input + input#cloud-search( + type="text", + placeholder="Search assets, tutorials...") + i.search-icon.pi-search + | {% endblock %} + + nav.collapse.navbar-collapse + ul.nav.navbar-nav.navbar-right + | {% if node and node.properties and node.properties.category %} + | {% set category = node.properties.category %} + | {% else %} + | {% set category = title %} + | {% endif %} + + | {% block navigation_sections %} + li + a.navbar-item( + href="{{ url_for('main.main_blog') }}", + title="Blender Cloud Blog", + data-toggle="tooltip", + data-placement="bottom", + class="{% if category == 'blog' %}active{% endif %}") + span Blog + + li(class="dropdown libraries") + a.navbar-item.dropdown-toggle( + href="#", + data-toggle="dropdown", + title="Libraries") + span Libraries + i.pi-angle-down + + ul.dropdown-menu + li + a.navbar-item( + href="{{ url_for('main.redir_hdri') }}", + title="HDRI Library", + data-toggle="tooltip", + data-placement="left") + i.pi-globe + | HDRI + li + a.navbar-item( + href="{{ url_for('main.redir_textures') }}", + title="Textures Library", + data-toggle="tooltip", + data-placement="left") + i.pi-folder-texture + | Textures + li + a.navbar-item( + href="{{ url_for('main.redir_characters') }}", + title="Character Library", + data-toggle="tooltip", + data-placement="left") + i.pi-character + | Characters + li + a.navbar-item( + href="{{ url_for('main.gallery') }}", + title="Curated artwork collection", + data-toggle="tooltip", + data-placement="left") + i.pi-image + | Art Gallery + + li + a.navbar-item( + href="{{ url_for('main.training') }}", + title="Training & Tutorials", + data-toggle="tooltip", + data-placement="bottom", + class="{% if category == 'training' %}active{% endif %}") + span Training + li + a.navbar-item( + href="{{ url_for('main.open_projects') }}", + title="Browse all the Open Projects", + data-toggle="tooltip", + data-placement="bottom", + class="{% if category in ['open-projects', 'film'] %}active{% endif %}") + span Open Projects + li + a.navbar-item( + href="{{ url_for('main.services') }}", + title="Blender Cloud Services", + data-toggle="tooltip", + data-placement="bottom", + class="{% if category == 'services' %}active{% endif %}") + span Services + | {% endblock %} + + | {% if current_user.is_anonymous %} + li + a.navbar-item( + href="https://store.blender.org/product/membership/", + title="Sign up") Sign up + | {% endif %} + + | {% if current_user.is_authenticated %} + + | {% if current_user.has_role('demo') %} + | {% set subscription = 'demo' %} + | {% elif current_user.has_role('subscriber') %} + | {% set subscription = 'subscriber' %} + | {% else %} + | {% set subscription = 'none' %} + | {% endif %} + + li.nav-notifications + a.navbar-item#notifications-toggle( + title="Notifications", + data-toggle="tooltip", + data-placement="bottom") + i.pi-notifications-none.nav-notifications-icon + span#notifications-count + span + .flyout-hat + + #notifications.flyout.notifications + .flyout-content + span.flyout-title Notifications + a#notifications-markallread( + title="Mark All as Read", + href="/notifications/read-all") + | Mark All as Read + + | {% include '_notifications.html' %} + + + li(class="dropdown{% if title in ['profile', 'billing-address', 'pledges', 'manage-collection']: %} active{% endif %}") + a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}") + img.gravatar( + src="{{ current_user.gravatar }}", + class="{{ subscription }}", + alt="Avatar") + .special(class="{{ subscription }}") + | {% if subscription == 'subscriber' %} + i.pi-check + | {% elif subscription == 'demo' %} + i.pi-heart-filled + | {% else %} + i.pi-attention + | {% endif %} + + ul.dropdown-menu + | {% if not current_user.has_role('protected') %} + li.subscription-status(class="{{ subscription }}") + | {% if subscription == 'subscriber' %} + a.navbar-item( + href="{{url_for('users.settings_billing')}}" + title="View subscription info") + i.pi-grin + span Your subscription is active! + | {% elif subscription == 'demo' %} + a.navbar-item( + href="{{url_for('users.settings_billing')}}" + title="View subscription info") + i.pi-heart-filled + span You have a free account. + | {% else %} + a.navbar-item( + href="https://store.blender.org/product/membership/" + title="Renew subscription") + i.pi-unhappy + span.info Your subscription is not active. + span.renew Click here to renew. + | {% endif %} + + li + a.navbar-item( + href="{{ url_for('projects.home_project') }}" + title="Home") + i.pi-home + | Home + + li + home_project + a.navbar-item( + href="{{ url_for('projects.index') }}" + title="My Projects") + i.pi-star + | My Projects + + li + a.navbar-item( + href="{{ url_for('users.settings_profile') }}" + title="Settings") + i.pi-cog + | Settings + li + a.navbar-item( + href="{{ url_for('users.settings_billing') }}" + title="Billing") + i.pi-credit-card + | Subscription + li.divider(role="separator") + | {% endif %} + li + a.navbar-item( + href="{{ url_for('users.logout') }}") + i.pi-log-out(title="Log Out") + | Log out + + | {% else %} + + li.nav-item-sign-in + a.navbar-item(href="{{ url_for('users.login') }}") + | Log in + | {% endif %} + + .page-content + #search-overlay + | {% block page_overlay %} + #page-overlay + | {% endblock %} + .page-body + | {% block body %}{% endblock %} + + | {% block footer_container %} + #footer-container + | {% block footer_navigation %} + #footer-navigation + .container + .row + .col-md-4.col-xs-6 + .footer-support + h4 Support & Feedback + p. + Let us know what you think or if you have any issues + just write to cloudsupport at blender dot org + + .col-md-2.col-xs-6 + ul.footer-social + li + a(href="https://twitter.com/Blender_Cloud", + title="Follow us on Twitter") + i.pi-social-twitter + li + a(href="mailto:cloudsupport@blender.org" + title="Support Email") + i.pi-email + + .col-md-2.col-xs-6 + h4 + a(href="{{ url_for('main.homepage') }}") + | Blender Cloud + ul.footer-links + li + a(href="{{ url_for('main.main_blog') }}", + title="Blender Cloud Blog") + | Blog + + li + a(href="{{ url_for('main.services') }}", + title="Blender Cloud Services") + | Services + + li + a(href="https://cloud.blender.org/blog/blender-cloud-v3", + title="About Blender Cloud") + | About + + .col-md-2.col-xs-6 + h4 + a(href="https://www.blender.org", + title="Blender official Website") + | Blender + ul.footer-links + li + a(href="https://www.blender.org", + title="Blender official Website") + | Blender.org + li + a(href="https://www.blender.org/store", + title="The official Blender Store") + | Blender Store + + .col-md-2.col-xs-6.special + | With the support of the
      MEDIA Programme of the European Union

      + img(alt="MEDIA Programme of the European Union", + src="https://gooseberry.blender.org/wp-content/uploads/2014/01/media_programme.png") + | {% endblock %} + + | {% block footer %} + footer.container + ul.links + li + a(href="{{ url_for('main.homepage') }}") + | Blender Cloud + + #hop(title="Be awesome in space") + i.pi-angle-up + + | {% endblock %} + | {% endblock %} + + #notification-pop(data-url="", data-read-toggle="") + .nc-progress + a#pop-close(href="#", title="Dismiss") + i.pi-cancel + .nc-item + .nc-avatar + .nc-text + span.nc-date + a(href="") + + noscript + link(href='//fonts.googleapis.com/css?family=Roboto:300,400,500', rel='stylesheet', type='text/css') + link(href='//fonts.googleapis.com/css?family=Lato:300,400', rel='stylesheet', type='text/css') + + script(type="text/javascript", src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js") + script(type="text/javascript", src="//cdnjs.cloudflare.com/ajax/libs/jquery.perfect-scrollbar/0.6.10/js/min/perfect-scrollbar.min.js") + script(type="text/javascript", src="//cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.5/handlebars.min.js") + + script. + $(document).ready(function() { + {% if current_user.is_authenticated %} + getNotificationsLoop(); // Check for new notifications in the background + + // Resize #notifications and change overflow for scrollbars + $(window).on("resize", function() { notificationsResize(); }); + + // Load perfectScrollbar + Ps.initialize(document.getElementById('notifications'), {suppressScrollX: true}); + + {% endif %} + }); + + | {% block footer_scripts %}{% endblock %} + + script. + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + ga('create', '{{ config.GOOGLE_ANALYTICS_TRACKING_ID }} ', 'auto', {'allowAnchor': true}); + ga('send', 'pageview'); diff --git a/src/templates/nodes/custom/_comments.jade b/src/templates/nodes/custom/_comments.jade new file mode 100644 index 00000000..a5c72052 --- /dev/null +++ b/src/templates/nodes/custom/_comments.jade @@ -0,0 +1,445 @@ + +#comments-container + a(name="comments") + + section#comments-list + .comment-reply-container + | {% if current_user.is_authenticated %} + + | {% if has_method_POST %} + .comment-reply-avatar + img(src="{{ current_user.gravatar }}") + + .comment-reply-form + + .comment-reply-field + textarea( + id="comment_field", + data-parent_id="{{ parent_id }}", + placeholder="Join the conversation...",) + + .comment-reply-meta + .comment-details + .comment-rules + a( + title="Markdown Supported" + href="https://guides.github.com/features/mastering-markdown/") + i.pi-markdown + + .comment-author + span.commenting-as commenting as + span.author-name {{ current_user.full_name }} + + button.comment-action-cancel.btn.btn-outline( + type="button", + title="Cancel") + i.pi-cancel + button.comment-action-submit.btn.btn-outline( + id="comment_submit", + type="button", + title="Post Comment") + | Post Comment + span.hint (Ctrl+Enter) + + .comment-reply-preview + + | {% else %} + + | {# * It's authenticated, but has no 'POST' permission #} + .comment-reply-form + .comment-reply-field.sign-in + textarea( + disabled, + id="comment_field", + data-parent_id="{{ parent_id }}", + placeholder="") + .sign-in + | Join the conversation! Subscribe to Blender Cloud now. + | {% endif %} + + | {% else %} + | {# * It's not autenticated #} + .comment-reply-form + .comment-reply-field.sign-in + textarea( + disabled, + id="comment_field", + data-parent_id="{{ parent_id }}", + placeholder="") + .sign-in + a(href="{{ url_for('users.login') }}") Log in + | to comment. + + | {% endif %} + + section#comments-list-header + #comments-list-title + #comments-list-items + #comments-list-items-loading + i.pi-spin + + script#comment-template(type="text/x-handlebars-template") + | {% raw %} + + | {{#list items }} + + .comment-container( + id="{{ _id }}", + data-node_id="{{ _id }}", + class="{{#if is_team}}is-team{{/if}}{{#if is_reply}}is-reply{{else}}is-first{{/if}}") + + .comment-header + .comment-avatar + img(src="{{ gravatar }}") + + .comment-author(class="{{#if is_own}}own{{/if}}") + | {{ author }} + span.username ({{ author_username }}) + + | {{#if is_team}} + .comment-badge.badge-team(title="Project Member") team + | {{/if}} + + .comment-time {{ time_published }} {{#if time_edited }} (edited {{ time_edited }}){{/if}} + + .comment-content {{{ content }}} + | {{#if is_own}} + .comment-content-preview + | {{/if}} + + .comment-meta + .comment-rating( + class="{{#if is_rated}}rated{{/if}}{{#if is_rated_positive}} positive{{/if}}") + .comment-rating-value(title="Number of likes") {{ rating }} + .comment-action-rating.up(title="Like comment") + + .comment-action-reply(title="Reply to this comment") + span reply + | {{#if is_own}} + .comment-action-edit + span.edit_mode(title="Edit comment") edit + span.edit_save(title="Save comment") + i.pi-check + | save changes + span.edit_cancel(title="Cancel changes") + i.pi-cancel + | cancel + | {{/if}} + + | {{/list}} + | {% endraw %} + + +| {% block comment_scripts %} + +script. + // Markdown initialization + var convert = new Markdown.getSanitizingConverter(); + Markdown.Extra.init(convert); + convert = convert.makeHtml; + + // Define the template for handlebars + var source = $("#comment-template").html(); + var template = Handlebars.compile(source); + + + // Register the helper for generating the comments list + Handlebars.registerHelper('list', function(context, options) { + var ret = ""; + var comments_count = 0 + + // Loop through all first-level comments + for(var i=0, j=context.length; i 0) ? comments_count : 'No') + ((comments_count == 1) ? ' comment' : ' comments')); + + return ret; + }); + + // Helper for the if/else statement + Handlebars.registerHelper('if', function(conditional, options) { + if(conditional) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + + /* Build the markdown preview when typing in textarea */ + $(function() { + var $textarea = $('.comment-reply-field textarea'), + $container = $('.comment-reply-form'), + $preview = $('.comment-reply-preview'); + + // As we type in the textarea + $textarea.keyup(function(e) { + + // Convert markdown + $preview.html(convert($textarea.val())); + + // While we are at it, style when empty + if ($textarea.val()) { + $container.addClass('filled'); + } else { + $container.removeClass('filled'); + }; + + // Send on ctrl+enter + if ((e.keyCode == 10 || e.keyCode == 13) && e.ctrlKey){ + $( ".comment-action-submit" ).trigger( "click" ); + }; + + }).trigger('keyup'); + }); + + + // Get the comments list in JSON + $.getJSON( "{{url_for('nodes.comments_index')}}?parent_id={{ parent_id }}&format=json", function( data ) { + // Format using handlebars template + var comments = template(data); + + if (comments && comments.trim() !="") { + $('#comments-list-items').html(comments); + } else { + $('#comments-list-items').html(''); + } + }) + .done(function(){ + var scrollToId = location.hash; + if (scrollToId.length <= 1) return; + + document.getElementById(scrollToId.substr(1)).scrollIntoView(true); + $(scrollToId).addClass('comment-linked'); + }); + + /* Submit comment */ + $('.comment-action-submit').click(function(e){ + + var $this = $(this); + var $textarea = $('.comment-reply-field textarea'); + var commentField = document.getElementById('comment_field'); + var comment = commentField.value; + + function error(msg) { + // No content in the textarea + $this.addClass('button-field-error'); + $textarea.addClass('field-error') + $this.html(msg); + + setTimeout(function(){ + $this.html('Post Comment'); + $this.removeClass('button-field-error'); + $textarea.removeClass('field-error'); + }, 2500); + } + + if (comment.length < 5) { + if (comment.length == 0) error("Say something..."); + else error("Minimum 5 characters."); + return; + } + + $this.addClass('submitting'); + $this.html(' Posting...'); + + // Collect parent_id + parent_id = commentField.getAttribute('data-parent_id'); + + $.post("{{url_for('nodes.comments_create')}}", + // Submit content and parent_id for comment creation + {'content': comment, 'parent_id': parent_id} + ) + .fail(function(){ + $this.addClass('button-field-error'); + $textarea.addClass('field-error') + $this.html("Houston! Try again?"); + + setTimeout(function(){ + $this.html('Post Comment'); + $this.removeClass('button-field-error'); + $textarea.removeClass('field-error'); + }, 2500); + }) + .done(function(){ + // Load the comments + var url = "{{url_for('nodes.comments_index')}}?parent_id={{ parent_id }}"; + $.get(url, function(dataHtml) { + // Update the DOM injecting the generate HTML into the page + $('#comments-container').replaceWith(dataHtml); + }) + }); + }); + + + /* Edit comment */ + + // Markdown convert as we type in the textarea + $(document).on('keyup','body .comment-content textarea',function(e){ + + var $textarea = $(this), + $container = $(this).parent(), + $preview = $container.next(); + + // Convert markdown + $preview.html(convert($textarea.val())); + + // While we are at it, style if empty + if (!$textarea.val()) { + $container.addClass('empty'); + } else { + $container.removeClass('empty'); + }; + }).trigger('keyup'); + + + /* Enter edit mode */ + $(document).on('click','body .comment-action-edit span.edit_mode',function(){ + + $(this).hide(); + $(this).siblings('span.edit_cancel').show(); + $(this).siblings('span.edit_save').show(); + + var comment_content = $(this).parent().parent().siblings('.comment-content'); + var comment_id = comment_content.parent().attr('data-node_id'); + var height = comment_content.height(); + var url = '/nodes/' + comment_id + '/view?format=json'; + + $.get(url, function(data) { + var comment_raw = data['node']['properties']['content']; + comment_content.html(''); + + comment_content.addClass('editing') + .find('textarea') + .height(height) + .focus(); + comment_content.siblings('.comment-content-preview').show(); + }) + .fail(function(data){ + statusBarSet('error', 'Error entering edit mode.', 'pi-warning'); + }); + }); + + /* Return UI to normal, when cancelling or saving */ + function commentEditCancel(comment_container) { + var comment_id = comment_container.parent().attr('id'); + var url = '/nodes/' + comment_id + '/view?format=json'; + + $.get(url, function(data) { + var comment_raw = data['node']['properties']['content']; + + comment_container.html(convert(comment_raw)) + .removeClass('editing'); + comment_container.siblings('.comment-content-preview').html('').hide(); + }) + .fail(function(data){ + statusBarSet('error', 'Error canceling.', 'pi-warning'); + }); + } + + $(document).on('click','body .comment-action-edit span.edit_cancel',function(e){ + + $(this).hide(); + $(this).siblings('span.edit_save').hide(); + $(this).siblings('span.edit_mode').show(); + + var commentContainer = $(this).parent().parent().siblings('.comment-content'); + commentEditCancel(commentContainer); + }); + + /* Save edited comment */ + $(document).on('click','body .comment-action-edit span.edit_save',function(e){ + + var $this = $(this); + var commentContainer = $(this).parent().parent().siblings('.comment-content'); + var commentField = commentContainer.find('textarea'); + var comment = commentField.val(); + var commentId = commentContainer.parent().attr('id'); + + function error(msg) { + // No content in the textarea + $this.addClass('error') + .html(msg); + commentField.addClass('field-error') + + setTimeout(function(){ + $this.html(' save changes') + .removeClass('error'); + commentField.removeClass('field-error'); + }, 2500); + } + + if (comment.length < 5) { + if (comment.length == 0) error("Say something..."); + else error("Minimum 5 characters."); + return; + } + + $this.addClass('saving') + .html(' Saving...'); + + $.post('/nodes/comments/' + commentId, + {'content': comment} + ) + .fail(function(){ + $this.addClass('error') + .html("Houston! Try again?"); + commentField.addClass('field-error') + + setTimeout(function(){ + $this.html('Save changes') + .removeClass('error'); + commentField.removeClass('field-error'); + }, 2500); + }) + .done(function(){ + + commentEditCancel(commentContainer); + commentContainer + .html(convert(comment)); + commentContainer.next().text(comment); + + $this.html(' saved!') + .removeClass('saving') + .siblings('span.edit_cancel').hide(); + + setTimeout(function(){ + $this.html(' save changes') + .hide() + .siblings('span.edit_mode').show(); + }, 2500); + }); + }); + +| {% endblock %} diff --git a/src/templates/nodes/custom/_scripts.jade b/src/templates/nodes/custom/_scripts.jade new file mode 100644 index 00000000..ea015b35 --- /dev/null +++ b/src/templates/nodes/custom/_scripts.jade @@ -0,0 +1,189 @@ +script(type="text/javascript"). + + /* Convert Markdown */ + var convert_fields = '.node-details-description, .blog_index-item .item-content'; + var convert = new Markdown.getSanitizingConverter(); + Markdown.Extra.init(convert); + convert = convert.makeHtml; + + + /* Parse description/content fields to convert markdown */ + $(convert_fields).each(function(i){ + $(convert_fields).eq(i).html(convert($(convert_fields).eq(i).text())); + }); + + ProjectUtils.setProjectAttributes({isProject: false}); + + {% if node %} + ProjectUtils.setProjectAttributes({ + nodeId: '{{node._id}}', + nodeType: '{{node.node_type}}'}); + + var node_type = ProjectUtils.nodeType(); + var node_type_str = node_type; + + if (node_type === 'group'){ + node_type_str = 'Folder'; + } else if (node_type === 'group_texture') { + node_type_str = 'Texture Folder'; + } else if (node_type === 'group_hdri') { + node_type_str = 'HDRi Folder'; + } + $('a', '.button-edit').html(' Edit ' + node_type_str); + $('a', '.button-delete').html('Delete ' + node_type_str); + + {% if parent %} + ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'}); + {% endif %} + + + // If we are im preview mode, update the image source + var page_overlay = document.getElementById('page-overlay'); + + if (page_overlay.classList.contains('active')) { + var node_preview = document.getElementById('node-preview'); + + if (node_preview){ + if ($(node_preview).hasClass('image') || $(node_preview).hasClass('file')){ + var src = $(node_preview).find('img').attr('src'); + showOverlayPreviewImage(src); + } + } else { + $(page_overlay).html('
      No Preview Available
      '); + } + } + + function loadComments(){ + var commentsUrl = "{{ url_for('nodes.comments_index', parent_id=node._id) }}"; + + $.get(commentsUrl, function(dataHtml) { + }) + .done(function(dataHtml){ + // Update the DOM injecting the generate HTML into the page + $('#comments-container').replaceWith(dataHtml); + }) + .fail(function(e, data){ + statusBarSet('error', 'Couldn\'t load comments. Error: ' + data.errorThrown, 'pi-attention', 5000); + $('#comments-container').html(' Reload comments'); + }); + } + + loadComments(); + + $('body').on('click', '#comments-reload', function(){ + loadComments(); + }); + + {% if node.has_method('PUT') %} + $('.project-mode-view').show(); + {% else %} + $('.project-mode-view').hide(); + {% endif %} + + {% if node.picture %} + function showOverlayPreviewImage(src) { + $(page_overlay) + .addClass('active') + .html(''); + } + + $('#node-preview.image, #node-preview.file').click(function(e){ + e.preventDefault(); + e.stopPropagation(); + + showOverlayPreviewImage("{{ node.picture.thumbnail('l', api=api) }}"); + }); + + {% endif %} + + // Click anywhere in the page to hide the overlay + function hidePageOverlay() { + $(page_overlay) + .removeAttr('class') + .html(''); + } + + $(page_overlay).click(function(e) { + e.stopPropagation(); + e.preventDefault(); + + hidePageOverlay(); + }); + + function navigateTree(prev){ + var tree = $('#project_tree').jstree(true); + var curr = tree.get_selected(false); + + if (prev === undefined){ + var n = tree.get_next_dom(curr); + } else { + var n = tree.get_prev_dom(curr); + } + + if (n && n.length > 0) { + tree.deselect_all(); + tree.select_node(n); + } + } + + document.onkeydown = function(e) { + var event = document.all ? window.event : e; + switch (e.target.tagName.toLowerCase()) { + case "input": + case "textarea": + case "select": + case "button": + break + default: + if (event.keyCode==27) hidePageOverlay(); + if (event.keyCode==37) navigateTree(true); + if (event.keyCode==39) navigateTree(); + break; + } + } + + + $(page_overlay).find('.nav-prev').click(function(e){ + e.stopPropagation(); + e.preventDefault(); + + navigateTree(true); + }); + + $(page_overlay).find('.nav-next').click(function(e){ + e.stopPropagation(); + e.preventDefault(); + + navigateTree(); + }); + + // Auto-scale the image preview to the right aspect ratio + var node_preview = document.getElementById("node-preview-thumbnail"); + + if (node_preview) { + node_preview.addEventListener('load', function() { + var preview_aspect = this.naturalWidth / this.naturalHeight + + if (preview_aspect > 1.0){ + $('.node-preview, .node-preview-thumbnail').css({'max-height': 'auto', 'width': '100%'}); + $('.node-preview img').css({'max-height': '100%'}); + } + + }); + } + + $('#node-overlay').click(function(){ + $(this).removeClass('active').hide().html(); + }); + + if (typeof $().popover != 'undefined'){ + $('#asset-license').popover(); + } + + {% endif %} + + + if (typeof $().tooltip != 'undefined'){ + $('[data-toggle="tooltip"]').tooltip({'delay' : {'show': 1250, 'hide': 250}}); + } + diff --git a/src/templates/nodes/custom/asset/file/view_embed.jade b/src/templates/nodes/custom/asset/file/view_embed.jade new file mode 100644 index 00000000..ee243a83 --- /dev/null +++ b/src/templates/nodes/custom/asset/file/view_embed.jade @@ -0,0 +1,128 @@ +| {% block body %} + +#node-container + #node-overlay + + | {% if node.picture %} + section#node-preview.node-preview.file + img.node-preview-thumbnail#node-preview-thumbnail( + src="{{ node.picture.thumbnail('l', api=api) }}") + | {% endif %} + + + section.node-details-container.file + + .node-details-header + .node-title#node-title + | {{node.name}} + + .node-details-meta.header + ul.node-details-meta-list + | {% if node.permissions.world %} + li.node-details-meta-list-item.access.public( + data-toggle="tooltip", + data-placement="left", + title="Anybody can download. Share it!") + i.pi-lock-open + span Public + | {% endif %} + + | {% if node.file %} + li.node-details-meta-list-item.type + | {{ node.file.content_type }} + + li.node-details-meta-list-item.file.length + | {{ node.file.length | filesizeformat }} + | {% endif %} + + | {% if node.properties.license_type %} + | {% if node.properties.license_notes %} + li.node-details-meta-list-item.license( + id="asset-license", + data-toggle="popover", + data-placement="left", + data-trigger="hover", + data-content="{{ node.properties.license_notes }}", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% else %} + li.node-details-meta-list-item.license( + id="asset-license", + data-toggle="tooltip", + data-placement="bottom", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% endif %} + | {% endif %} + + | {% if node.file %} + li.node-details-meta-list-item.file.download(title="Download File") + | {% if node.file.link %} + a(href="{{ node.file.link }}", + title="Download file", + download="{{ node.file.filename }}") + button.btn.btn-default(type="button") + i.pi-download + | {% else %} + button.btn.btn-default.disabled.sorry(type="button") + i.pi-download + | {% endif %} + | {% endif %} + + + | {% if node.description %} + .node-details-description#node-description + | {{node.description}} + | {% endif %} + + | {% if node.properties.license_notes %} + .node-details-meta.license + | {{ node.properties.license_notes }} + | {% endif %} + + .node-details-meta.footer + ul.node-details-meta-list + li.node-details-meta-list-item.status + | {{node.properties.status}} + + li.node-details-meta-list-item.author + | {{ node.user.full_name }} + + li.node-details-meta-list-item.date(title="Created {{ node._created }}") + | {{ node._created | pretty_date }} + | {% if (node._created | pretty_date) != (node._updated | pretty_date) %} + span(title="Updated {{ node._updated }}") (updated {{ node._updated | pretty_date }}) + | {% endif %} + + + #comments-container + #comments-list-items-loading + i.pi-spin + +include ../../_scripts + +| {% endblock %} + +| {% block footer_scripts %} +script. + // Generate GA pageview + ga('send', 'pageview', location.pathname); + + var content_type = $("li.node-details-meta-list-item.type").text(); + var type_trimmed = content_type.substring(content_type.indexOf("/") + 1); + + if (type_trimmed == 'x-blender' || type_trimmed == 'blend'){ + type_trimmed = ''; + }; + + $("li.node-details-meta-list-item.type").html(type_trimmed); + + $('.sorry').click(function() { + $.get('/403', function(data) { + $('#node-overlay').html(data).addClass('active'); + }) + }); + +| {% endblock %} diff --git a/src/templates/nodes/custom/asset/image/view.jade b/src/templates/nodes/custom/asset/image/view.jade new file mode 100644 index 00000000..36bf706b --- /dev/null +++ b/src/templates/nodes/custom/asset/image/view.jade @@ -0,0 +1,4 @@ +| {% extends 'layout.html' %} + +| {% block footer_scripts %} +| {% endblock %} diff --git a/src/templates/nodes/custom/asset/image/view_embed.jade b/src/templates/nodes/custom/asset/image/view_embed.jade new file mode 100644 index 00000000..8dba9977 --- /dev/null +++ b/src/templates/nodes/custom/asset/image/view_embed.jade @@ -0,0 +1,128 @@ +| {% block body %} + +#node-container + #node-overlay + + | {% if node.picture %} + section#node-preview.node-preview.image + img.node-preview-thumbnail#node-preview-thumbnail( + src="{{ node.picture.thumbnail('l', api=api) }}") + | {% endif %} + + + section.node-details-container.image + + .node-details-header + .node-title#node-title + | {{node.name}} + + .node-details-meta.header + ul.node-details-meta-list + | {% if node.permissions.world %} + li.node-details-meta-list-item.access.public( + data-toggle="tooltip", + data-placement="left", + title="Anybody can download. Share it!") + i.pi-lock-open + span Public + | {% endif %} + | {% if node.short_link %} + li.node-details-meta-list-item.access.shared + a(href="{{ node.short_link }}") + i.pi-share + | Shared + | {% endif %} + + | {% if node.file %} + li.node-details-meta-list-item.type + | {{ node.file.content_type }} + + li.node-details-meta-list-item.image.length + | {{ node.file.length | filesizeformat }} + | {% endif %} + + | {% if node.properties.license_type %} + | {% if node.properties.license_notes %} + li.node-details-meta-list-item.license( + id="asset-license", + data-toggle="popover", + data-placement="left", + data-trigger="hover", + data-content="{{ node.properties.license_notes }}", + title=" {{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% else %} + li.node-details-meta-list-item.license( + id="asset-license", + data-toggle="tooltip", + data-placement="bottom", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% endif %} + | {% endif %} + + | {% if node.file %} + li.node-details-meta-list-item.image.download(title="Download Image") + | {% if node.file.link %} + a(href="{{ node.file.link }}", + title="Download image", + download="{{ node.file.filename }}") + button.btn.btn-default(type="button") + i.pi-download + | {% else %} + button.btn.btn-default.disabled.sorry(type="button") + i.pi-download + | {% endif %} + | {% endif %} + + | {% if node.description %} + .node-details-description#node-description + | {{node.description}} + | {% endif %} + + | {% if node.properties.license_notes %} + .node-details-meta.license + | {{ node.properties.license_notes }} + | {% endif %} + + .node-details-meta.footer + ul.node-details-meta-list + | {% if node.has_method('PUT') %} + li.node-details-meta-list-item.status + | {{node.properties.status}} + | {% endif %} + + li.node-details-meta-list-item.author + | {{ node.user.full_name }} + + li.node-details-meta-list-item.date(title="Created {{ node._created }}") + | {{ node._created | pretty_date }} + | {% if (node._created | pretty_date) != (node._updated | pretty_date) %} + span(title="Updated {{ node._updated }}") (updated {{ node._updated | pretty_date }}) + | {% endif %} + #comments-container + #comments-list-items-loading + i.pi-spin + +include ../../_scripts + +| {% endblock %} + +| {% block footer_scripts %} +script. + // Generate GA pageview + ga('send', 'pageview', location.pathname); + + var content_type = $("li.node-details-meta-list-item.type").text(); + $("li.node-details-meta-list-item.type").text(content_type.substring(content_type.indexOf("/") + 1)); + + $('.sorry').click(function() { + $.get('/403', function(data) { + $('#node-overlay').html(data).addClass('active'); + }) + }); + +| {% endblock %} + diff --git a/src/templates/nodes/custom/asset/video/view.jade b/src/templates/nodes/custom/asset/video/view.jade new file mode 100644 index 00000000..d13e9d53 --- /dev/null +++ b/src/templates/nodes/custom/asset/video/view.jade @@ -0,0 +1,6 @@ +| {% extends 'layout.html' %} +| {% from '_macros/_file_uploader_javascript.html' import render_file_uploader_javascript %} + +| {% block footer_scripts %} +| {{render_file_uploader_javascript()}} +| {% endblock %} diff --git a/src/templates/nodes/custom/asset/video/view_embed.jade b/src/templates/nodes/custom/asset/video/view_embed.jade new file mode 100644 index 00000000..d18bb1c1 --- /dev/null +++ b/src/templates/nodes/custom/asset/video/view_embed.jade @@ -0,0 +1,158 @@ +| {% block body %} + +#node-container + #node-overlay + + section.node-preview.video + #flowplayer_container.is-splash.play-button( + style="{% if node.picture %}background-image:url({{node.picture.thumbnail('l', api=api)}}); background-repeat:no-repeat; {% endif %}") + .fp-startscreen.fp-toggle + a.big-play-button + i.pi-play + .fp-endscreen + a.watch-again.fp-toggle + i.pi-replay + | Watch again + .fp-waiting + i.pi-spin.spin + + + section.node-details-container.video + + .node-details-header + .node-title#node-title + | {{node.name}} + + .node-details-meta.header + ul.node-details-meta-list + | {% if node.permissions.world %} + li.node-details-meta-list-item.access.public( + data-toggle="tooltip", + data-placement="bottom", + title="Anybody can download. Share it!") + i.pi-lock-open + span Public + | {% endif %} + + | {% if node.properties.license_type %} + | {% if node.properties.license_notes %} + li.node-details-meta-list-item.video.license( + id="asset-license", + data-toggle="popover", + data-placement="left", + data-trigger="hover", + data-content="{{ node.properties.license_notes }}", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% else %} + li.node-details-meta-list-item.video.license( + id="asset-license", + data-toggle="tooltip", + data-placement="bottom", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% endif %} + | {% endif %} + + | {% if node.file %} + | {% if node.file_variations %} + li.btn-group.node-details-meta-list-item.video.download( + title="Download Video") + button.btn.btn-default.dropdown-toggle( + type="button", + data-toggle="dropdown", + aria-haspopup="true", + aria-expanded="false") + i.pi-download + i.pi-angle-down.icon-dropdown-menu + + ul.dropdown-menu + | {% for child in node.file_variations %} + li + a(href="{{ child.link }}", + title="Download this video format", + download) + span.length {{ child.length | filesizeformat }} + + span.format {{ child.format }} + span.size {{ child.size }} + + | {% endfor %} + | {% else %} + li.btn-group.node-details-meta-list-item.video.download.disabled( + title="Download Video") + button.btn.btn-default.sorry(type="button") + i.pi-download + i.pi-angle-down.icon-dropdown-menu + | {% endif %} + | {% endif %} + + | {% if node.description %} + .node-details-description#node-description + | {{node.description}} + | {% endif %} + + | {% if node.properties.license_notes %} + .node-details-meta.license + | {{ node.properties.license_notes }} + | {% endif %} + + .node-details-meta.footer + ul.node-details-meta-list + li.node-details-meta-list-item.status + | {{node.properties.status}} + + li.node-details-meta-list-item.author + | {{ node.user.full_name }} + + li.node-details-meta-list-item.date(title="Created {{ node._created }}") + | {{ node._created | pretty_date }} + | {% if (node._created | pretty_date) != (node._updated | pretty_date) %} + span(title="Updated {{ node._updated }}") (updated {{ node._updated | pretty_date }}) + | {% endif %} + + + #comments-container + #comments-list-items-loading + i.pi-spin + +include ../../_scripts + +| {% endblock %} + +| {% block footer_scripts %} +script(type="text/javascript"). + $(function(){ + // Generate GA pageview + ga('send', 'pageview', location.pathname); + + var content_type = $("li.node-details-meta-list-item.type").text(); + $("li.node-details-meta-list-item.type").text(content_type.substring(content_type.indexOf("/") + 1)); + + var container = document.getElementById("flowplayer_container"); + + flowplayer(container, { + key: "{{config.FLOWPLAYER_KEY}}", + embed: false, + splash: true, + {% if node.video_sources %} + clip: { + sources: {{ node.video_sources | safe }} + } + {% else %} + disabled: true + {% endif %} + }); + + {% if not node.video_sources %} + $('#flowplayer_container, .sorry').click(function() { + $.get('/403', function(data) { + $('#node-overlay').html(data).addClass('active'); + }) + }); + {% endif %} + }); + +| {% endblock %} diff --git a/src/templates/nodes/custom/asset/view_theatre_embed.jade b/src/templates/nodes/custom/asset/view_theatre_embed.jade new file mode 100644 index 00000000..b9487408 --- /dev/null +++ b/src/templates/nodes/custom/asset/view_theatre_embed.jade @@ -0,0 +1,127 @@ +#theatre-media + img(src="{{ node.picture.thumbnail('h', api=api) }}", onmousedown="return false") + + ul#theatre-tools + li.theatre-tool-resize(title="Toggle Normal Size") + span + i.pi-resize-full + | {% if node.file and node.file.link %} + li.download + a(href="{{ node.file.link }}", + title="Download the original file", + download="{{ node.file.filename }}") + i.pi-download + | {% else %} + li.download.disabled + a(href="{{ url_for('users.login') }}", + title="Sign in to download the original file") + i.pi-download + | {% endif %} + +#theatre-info + .theatre-info-header + .theatre-info-title {{ node.name }} + .theatre-info-user {{ node.user.full_name }} + .theatre-info-date {{ node._created | pretty_date_time }} + + ul.theatre-info-details + li + span Type + span {{ node.file.content_type }} + li + span Dimensions + span {{ node.file.width }} x {{ node.file.height }} + li + span Size + span {{ node.file.length | filesizeformat }} + | {% if node.short_link %} + li + span Share link + a(href="{{ node.short_link }}") {{ node.short_link }} + | {% endif %} + + #comments-container + #comments-list-items-loading + i.pi-spin + +include ../_scripts + +script. + $(function () { + + // Load scrollbar for sidebar + Ps.initialize(document.getElementById('theatre-info'), {suppressScrollX: true}); + + var file_width = {{ node.file.width }}; + var file_height = {{ node.file.height }}; + var theatre_media = document.getElementById('theatre-media'); + var $theatre_media = $(theatre_media); + + function canZoom() { + return theatre_media.scrollWidth < file_width || + theatre_media.scrollHeight < file_height; + } + + // TODO: update this whenever the screen resizes. + if (canZoom()) $theatre_media.addClass('zoomed-out'); + + function theatreZoom() { + var started_zoomed_in = $theatre_media.hasClass('zoomed-in'); + + // See if we need to zoom in at all. Zooming out is always allowed. + if (!started_zoomed_in && !canZoom()) { + $theatre_media.removeClass('zoomed-out'); + return; + } + + // Use add/removeClass to ensure there is always exactly one of zoomed-{in,out}. + // If we were to use toggleClass() they could both be applied when we started + // without zoomed-out class. + if (started_zoomed_in) { + $theatre_media.removeClass('zoomed-in'); + $theatre_media.addClass('zoomed-out'); + Ps.destroy(theatre_media); + } else { + $theatre_media.addClass('zoomed-in'); + $theatre_media.removeClass('zoomed-out'); + Ps.initialize(theatre_media); + } + + // Style toolbar button + $('ul#theatre-tools li.theatre-tool-resize').toggleClass('active'); + } + + $('ul#theatre-tools li.theatre-tool-resize').on('click', function (e) { + theatreZoom(); + }); + + $('ul.nav.navbar-nav a.navbar-item.info').on('click', function (e) { + e.preventDefault(); + $('#theatre-container').toggleClass('with-info'); + }); + + $("#theatre-media img").on('click', function (e) { + var $parent = $(this).parent(); + var mouse_x = e.pageX; + var mouse_y = e.pageY; + + // Compute relative position before zooming in. + var pre_width = e.target.clientWidth; + var rel_x = e.offsetX / pre_width; + var rel_y = e.offsetY / e.target.clientHeight; + + theatreZoom(); + + var post_width = e.target.clientWidth; + + if (post_width > pre_width) { + // We zoomed in, scroll such that the target position is under the mouse. + var target_x = Math.round(rel_x * post_width); + var target_y = Math.round(rel_y * e.target.clientHeight); + + $parent + .scrollLeft(target_x - mouse_x + e.target.parentElement.parentElement.offsetLeft) + .scrollTop(target_y - mouse_y + e.target.parentElement.parentElement.offsetTop); + } + }); + }); diff --git a/src/templates/nodes/custom/blog/index.jade b/src/templates/nodes/custom/blog/index.jade new file mode 100644 index 00000000..94978002 --- /dev/null +++ b/src/templates/nodes/custom/blog/index.jade @@ -0,0 +1,130 @@ +| {% extends 'layout.html' %} + +| {% set title = 'blog' %} + +| {% block page_title %}Blog{% endblock%} + +| {% block body %} + +.container.box + #blog_container(class="{% if project._id == config.MAIN_PROJECT_ID %}cloud-blog{% endif %}") + + #blog_index-container + + | {% if project._id == config.MAIN_PROJECT_ID and project.node_type_has_method('post', 'POST', api=api) %} + a.btn.btn-default.button-create(href="{{url_for('nodes.posts_create', project_id=project._id)}}") + i.pi-plus + | Create New Post + | {% endif %} + + | {% if posts %} + + | {% for node in posts %} + + | {% if loop.first %} + | {% if node.picture %} + .blog_index-header + img(src="{{ node.picture.thumbnail('l', api=api) }}") + | {% endif %} + .blog_index-item + a.item-title( + href="{{ url_for_node(node=node) }}") + | {{node.name}} + + .item-info. + {{node._created | pretty_date }} + {% if node._created != node._updated %} + (updated {{node._updated | pretty_date }}) + {% endif %} + {% if node.properties.category %}| {{node.properties.category}}{% endif %} + | by {{node.user.full_name}} + | Leave a comment + {% if node.properties.status != 'published' %} | {{ node.properties.status}} {% endif %} + + .item-content + | {{node.properties.content}} + + .item-meta + a(href="{{ url_for_node(node=node) }}#comments") Leave a comment + + | {% else %} + + | {% if loop.index == 2 %} + h4.blog_index-title Blasts from the past + | {% endif %} + + .blog_index-item.list + | {% if node.picture %} + .item-header + img.image(src="{{ node.picture.thumbnail('s', api=api) }}") + | {% else %} + .item-header.nothumb + i.pi-document-text + | {% endif %} + a.item-title( + href="{{ url_for_node(node=node) }}") + | {{node.name}} + + .item-info. + {{node._created | pretty_date }} + {% if node._created != node._updated %} + (updated {{node._updated | pretty_date }}) + {% endif %} + {% if node.properties.category %}| {{node.properties.category}}{% endif %} + | by {{node.user.full_name}} + {% if node.properties.status != 'published' %} | {{ node.properties.status}} {% endif %} + + | {% endif %} {# loop #} + + | {% endfor %} {# posts #} + + | {% else %} + + .blog_index-item + .item-content No posts yet. + + | {% endif %} {# posts #} + + | {% if project._id != config.MAIN_PROJECT_ID %} + #blog_index-sidebar + .blog_project-card + a.item-header( + href="{{ url_for('projects.view', project_url=project.url) }}") + + .overlay + | {% if project.picture_header %} + img.background(src="{{ project.picture_header.thumbnail('m', api=api) }}") + | {% endif %} + + a.card-thumbnail( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {% if project.picture_square %} + img.thumb(src="{{ project.picture_square.thumbnail('m', api=api) }}") + | {% endif %} + + .item-info + + a.item-title( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {{ project.name }} + + | {% if project.summary %} + p.item-description + | {{project.summary|safe}} + | {% endif %} + + | {% if project.node_type_has_method('post', 'POST', api=api) %} + .blog_project-sidebar + a.btn.btn-default.button-create(href="{{url_for('nodes.posts_create', project_id=project._id)}}") + | Create New Post + | {% endif %} + | {% endif %} + +| {% endblock %} + +| {% block footer_scripts %} + +include ../_scripts +script hopToTop(); // Display jump to top button + +| {% endblock %} diff --git a/src/templates/nodes/custom/group/view_embed.jade b/src/templates/nodes/custom/group/view_embed.jade new file mode 100644 index 00000000..682ee18a --- /dev/null +++ b/src/templates/nodes/custom/group/view_embed.jade @@ -0,0 +1,254 @@ +| {% block body %} +#node-container + + section.node-preview.group + | {% if node.picture %} + img.backdrop(src="{{ node.picture.thumbnail('l', api=api) }}") + .overlay + | {% endif %} + .node-title#node-title + | {{node.name}} + + section.node-details-container.group + .node-details-meta.preview + ul.node-details-meta-list + li.node-details-meta-list-item.date(title="Created {{ node._created | pretty_date }}") + | {{ node._updated | pretty_date }} + + li.node-details-meta-list-item.author + | {{ node.user.full_name }} + + | {% if node.properties.status != 'published' %} + li.node-details-meta-list-item.status + | {{node.properties.status}} + | {% endif %} + + .node-details-meta-actions + .btn-browsetoggle( + title="Toggle between list/grid view", + data-toggle="tooltip", + data-placement="top") + i.pi-list + + + | {% if node.description %} + .node-details-description + | {{node.description}} + | {% endif %} + + section.node-children.group + + | {% if children %} + | {% for child in children %} + + | {# Browse type: List #} + a( + href="#", + data-node_id="{{ child._id }}", + class="item_icon list-node-children-item browse-list") + .list-node-children-item-thumbnail + + | {% if child.picture %} + img( + src="{{ child.picture.thumbnail('t', api=api)}} ") + | {% endif %} + + .list-node-children-item-thumbnail-icon + | {# If there's a type available, otherwise show a folder icon #} + | {% if child.properties.content_type %} + + | {# Show an icon if there's no thumbnail #} + | {% if not child.picture %} + | {% if child.properties.content_type == 'image' %} + i.dark.pi-image + | {% elif child.properties.content_type == 'video' %} + i.dark.pi-film-thick + | {% elif child.properties.content_type == 'file' %} + i.dark.pi-document + | {% endif %} + + | {% else %} + | {% if child.properties.content_type == 'video' %} + i.pi-play + | {% endif %} + | {% endif %} + + | {% else %} + | {% if not child.picture %} + i.dark.pi-folder + | {% endif %} + | {% endif %} + + | {% if child.permissions.world %} + .list-node-children-item-ribbon + span free + | {% endif %} + + .list-node-children-item-name {{ child.name }} + + .list-node-children-item-meta + | {% if child.properties.status != 'published' %} + span.status {{ child.properties.status }} + | {% endif %} + + | {% if child.properties.content_type == 'video' %} + span Video · + | {% elif child.properties.content_type == 'image' %} + span Image · + | {% elif child.properties.content_type == 'file' %} + span File · + | {% else %} + | {% if child.picture %} + span Folder · + | {% endif %} + | {% endif %} + + | {% if child._updated %} + span(title="Updated on {{ child._created }}") {{ child._updated | pretty_date }} + span.updated(title="Created on {{ child._updated }}") * + | {% else %} + span(title="Created on {{ child._created }}") {{ child._created | pretty_date }} + | {% endif %} + + + | {# Browse type: Icon #} + a(href="#", data-node_id="{{ child._id }}", title="{{ child.name }}", class="item_icon list-node-children-item browse-icon") + .list-node-children-item-thumbnail + + | {% if child.picture %} + img( + src="{{ child.picture.thumbnail('b', api=api)}} ") + | {% endif %} + + .list-node-children-item-thumbnail-icon + | {% if child.properties.content_type %} + + | {% if child.properties.content_type == 'video' %} + i.pi-play + | {% endif %} + + | {% else %} + i.pi-folder + | {% endif %} + + | {% if child.properties.status != 'published' %} + .list-node-children-item-status {{ child.properties.status }} + | {% endif %} + + | {% if child.permissions.world %} + .list-node-children-item-ribbon + span free + | {% endif %} + + .list-node-children-item-name + + | {% if child.properties.content_type == 'video' %} + i.pi-film-thick + | {% elif child.properties.content_type == 'image' %} + i.pi-image + | {% elif child.properties.content_type == 'file' %} + i.pi-document + | {% else %} + i.pi-folder + | {% endif %} + + span {{ child.name }} + + | {% endfor %} + | {% else %} + .list-node-children-container + .list-node-children-empty No items... yet! + | {% endif %} + + script. + // Generate GA pageview + ga('send', 'pageview', location.pathname); + + $('a.item_icon').unbind("click") + .click(function(e){ + e.preventDefault(); + + var nodeId = $(this).data('node_id'); + + if (ProjectUtils.projectId()) { + // When clicking on a node preview, we load its content + // displayNode will run asynchronously and set the bcloud_current_node_id + // as well, but we set it manually in the next line as well, to make sure + // that select_node on jstree works as expected, preventing the node to be + // loaded twice. + Cookies.set('bcloud_current_node_id', nodeId); + displayNode(nodeId); + + // Update tree with current selection + var jstree = $('#project_tree').jstree(true); + jstree.deselect_all(); + jstree.open_node('n_' + ProjectUtils.nodeId(), function() { + jstree.select_node('n_' + nodeId); + }); + } else { + // If there's project_id defined, we use the full link (for search) + window.location.replace('/nodes/' + nodeId + '/redir'); + }; + }); + + // Browse type: icon or list + function projectBrowseTypeIcon() { + $(".list-node-children-item.browse-list").hide(); + $(".list-node-children-item.browse-icon").show(); + $(".btn-browsetoggle").html(''); + }; + + function projectBrowseTypeList() { + $(".list-node-children-item.browse-list").show(); + $(".list-node-children-item.browse-icon").hide(); + $(".btn-browsetoggle").html(''); + }; + + function projectBrowseTypeCheck(){ + /* Only run if we're in a project, or search */ + if(document.getElementById("project-container") !== null || document.getElementById("search-container") !== null) { + + var browse_type = Cookies.getJSON('bcloud_ui'); + + if (browse_type && browse_type.group_browse_type) { + if (browse_type.group_browse_type == 'icon') { + projectBrowseTypeIcon(); + + } else if ( browse_type.group_browse_type == 'list' ) { + projectBrowseTypeList(); + } + } else { + projectBrowseTypeIcon(); + }; + }; + } + + function projectBrowseToggle(){ + + var browse_type = Cookies.getJSON('bcloud_ui'); + + if (browse_type && browse_type.group_browse_type) { + if (browse_type.group_browse_type == 'icon') { + projectBrowseTypeList(); + setJSONCookie('bcloud_ui', 'group_browse_type', 'list'); + } else if ( browse_type.group_browse_type == 'list' ) { + projectBrowseTypeIcon(); + setJSONCookie('bcloud_ui', 'group_browse_type', 'icon'); + } + } else { + projectBrowseTypeList(); + setJSONCookie('bcloud_ui', 'group_browse_type', 'list'); + } + } + + $('.btn-browsetoggle').on('click', function (e) { + e.preventDefault(); + projectBrowseToggle(); + }); + + projectBrowseTypeCheck(); + + +include ../_scripts + +| {% endblock %} diff --git a/src/templates/nodes/custom/group_hdri/view_embed.jade b/src/templates/nodes/custom/group_hdri/view_embed.jade new file mode 100644 index 00000000..d0e52870 --- /dev/null +++ b/src/templates/nodes/custom/group_hdri/view_embed.jade @@ -0,0 +1,159 @@ +| {% block body %} +#node-container.texture + + .texture-title#node-title + | {{node.name}} + + | {% if node.picture %} + .texture-backdrop( + style="background-image: url({{ node.picture.thumbnail('m', api=api) }})") + | {% endif %} + + | {% if children %} + section.node-row.texture-info + span.texture-info-files {{ children|length }} item{% if children|length != 1 %}s{% endif %} + | {% endif %} + + section.node-children.group.texture + + | {% if children %} + | {% for child in children %} + | {% if child.properties.status == 'published' %} + + a.list-node-children-container( + href="#", + data-node_id="{{ child._id }}", + class="item_icon {{child.node_type}} {% if child.picture %}thumbnail{% endif %}") + .list-node-children-item-preview + span.texture-name {{child.name}} + | {% if child.picture %} + img.texture-preview( + src="", + data-preview="{{ child.picture.thumbnail('m', api=api)}}", + alt='{{child.name}}') + | {% endif %} + .list-node-children-item(class="{{child.node_type}}") + .list-node-children-item-thumbnail + | {% if child.picture %} + img.texture-thumbnail(src="{{ child.picture.thumbnail('b', api=api)}}") + | {% else %} + .list-node-children-item-thumbnail-icon + | {% if child.node_type == 'group_hdri' %} + i.pi-folder-texture + | {% else %} + i.pi-texture + | {% endif %} + | {% endif %} + + | {% if child.properties.status != 'published' %} + .list-node-children-item-status {{ child.properties.status }} + | {% endif %} + + | {% if child.permissions.world %} + .list-node-children-item-ribbon + span free + | {% endif %} + + | {% if child.node_type == 'hdri' %} + .list-node-children-item-name + span.sizes {{ child.name }} + | {% if child.properties.files %} + span.variations + | {% if child.properties.files|length > 6 %} + span {{ child.properties.files|length }} resolutions + span from {{ child.properties.files[0].resolution }} + span to {{ child.properties.files[-1].resolution }} + | {% else %} + | {% for f in child.properties.files %} + span {{ f.resolution }} + | {% endfor %} + | {% endif %} + | {% endif %} + | {% endif %} + + | {% if child.node_type == 'group_hdri' %} + .list-node-children-item-name + i.pi-folder-texture + span {{ child.name }} + | {% endif %} + + | {% endif %} + | {% endfor %} + | {% else %} + .list-node-children-container + .list-node-children-empty No textures... yet! + | {% endif %} + +script. + // Generate GA pageview + ga('send', 'pageview', location.href); + + // Display texture preview on mouse hover + $('a.list-node-children-container.texture.thumbnail').hover( + function(){ + + var thumbnail = $(this); + + var src = thumbnail.find('.texture-thumbnail').attr('src'); + var src_xl = thumbnail.find('.texture-preview').data('preview'); + + // Load the bigger preview + var preview_img = thumbnail.find('.texture-preview'); + preview_img.attr('src', src_xl); + + + if (preview_img) { + + preview_img.load(function() { + + var preview = thumbnail.find('.list-node-children-item-preview'); + + // Positioning stuff + var offset = thumbnail.offset(); + var offset_min_x = $('.node-children').width() - preview.width(); + var offset_min_y = $('.node-children').height() - preview.height(); + + if (preview && offset.top > 300) { + $(preview).css({'top': (preview.height() * (-1))}); + }; + + if (offset.left > offset_min_x) { + $(preview).css({'right': 0}); + }; + + $(preview).addClass('active'); + }); + } + + + }, + function(){ + $('.list-node-children-item-preview').removeClass('active'); + }); + + // hide preview on mouse hover itself + $('.list-node-children-item-preview').hover(function(){ + $(this).removeClass('active'); + }); + + + $('a.item_icon') + .unbind("click") + .click(function(e){ + e.preventDefault(); + // When clicking on a node preview, we load its content + var nodeId = $(this).data('node_id'); + // displayNode will run asynchronously and set the bcloud_current_node_id + // as well, but we set it manually in the next line as well, to make sure + // that select_node on jstree works as expected, preventing the node to be + // loaded twice. + Cookies.set('bcloud_current_node_id', nodeId); + displayNode(nodeId); + // Update tree with current selection + $('#project_tree').jstree('select_node', 'n_' + nodeId); + }); + +include ../_scripts + +| {% endblock %} + diff --git a/src/templates/nodes/custom/group_texture/view_embed.jade b/src/templates/nodes/custom/group_texture/view_embed.jade new file mode 100644 index 00000000..53393f17 --- /dev/null +++ b/src/templates/nodes/custom/group_texture/view_embed.jade @@ -0,0 +1,190 @@ +| {% block body %} +#node-container.texture + + .texture-title#node-title + | {{node.name}} + + | {% if node.picture %} + .texture-backdrop( + style="background-image: url({{ node.picture.thumbnail('m', api=api) }})") + | {% endif %} + + | {% if children %} + section.node-row.texture-info + span.texture-info-files {{ children|length }} item{% if children|length != 1 %}s{% endif %} + | {% endif %} + + section.node-children.group.texture + + | {% if children %} + | {% for child in children %} + | {% if child.properties.status == 'published' %} + + a.list-node-children-container( + href="#", + data-node_id="{{ child._id }}", + class="item_icon {{child.node_type}} {% if child.picture %}thumbnail{% endif %}") + .list-node-children-item-preview + span.texture-name {{child.name}} + | {% if child.picture %} + img.texture-preview( + src="", + data-preview="{{ child.picture.thumbnail('m', api=api)}}", + alt='{{child.name}}') + | {% endif %} + .list-node-children-item(class="{{child.node_type}}") + .list-node-children-item-thumbnail + | {% if child.picture %} + img.texture-thumbnail(src="{{ child.picture.thumbnail('b', api=api)}}") + | {% else %} + .list-node-children-item-thumbnail-icon + | {% if child.node_type == 'group_texture' %} + i.pi-folder-texture + | {% else %} + i.pi-texture + | {% endif %} + | {% endif %} + + | {% if child.properties.status != 'published' %} + .list-node-children-item-status {{ child.properties.status }} + | {% endif %} + + | {% if child.permissions.world %} + .list-node-children-item-ribbon + span free + | {% endif %} + + | {% if child.node_type == 'texture' %} + .list-node-children-item-name + | {% if child.picture.width %} + span.sizes + | {{ child.picture.width }} + small x + | {{ child.picture.height }} + | {% else %} + span.sizes {{ child.name }} + | {% endif %} + span.icons + | {% if child.properties.is_tileable %} + i.pi-puzzle(title="Tileable", data-toggle="tooltip", data-placement="bottom", data-delay=0) + | {% endif %} + | {% if child.properties.files %} + span.variations + | {% for f in child.properties.files %} + + | {% if loop.last and loop.index > 5 %} + span.more +{{ loop.length - 5 }} more + | {% elif loop.index <= 5 %} + + | {% if f.map_type == 'color' %} + span Color + | {% elif f.map_type == 'bump' %} + span Bump + | {% elif f.map_type == 'specular' %} + span Specular + | {% elif f.map_type == 'normal' %} + span Normal Map + | {% elif f.map_type == 'translucency' %} + span Translucency + | {% elif f.map_type == 'emission' %} + span Emission + | {% elif f.map_type == 'alpha' %} + span Alpha + | {% elif f.map_type == 'id' %} + span ID Map + | {% else %} + span {{ f.map_type }} + | {% endif %} + + | {% endif %} + + | {% endfor %} + | {% endif %} + | {% endif %} + + | {% if child.node_type == 'group_texture' %} + .list-node-children-item-name + i.pi-folder-texture + span {{ child.name }} + | {% endif %} + + | {% endif %} + | {% endfor %} + | {% else %} + .list-node-children-container + .list-node-children-empty No textures... yet! + | {% endif %} + +script. + // Generate GA pageview + ga('send', 'pageview', location.href); + + // Display texture preview on mouse hover + $('a.list-node-children-container.texture.thumbnail').hover( + function(){ + + var thumbnail = $(this); + + var src = thumbnail.find('.texture-thumbnail').attr('src'); + var src_xl = thumbnail.find('.texture-preview').data('preview'); + + // Load the bigger preview + var preview_img = thumbnail.find('.texture-preview'); + preview_img.attr('src', src_xl); + + + if (preview_img) { + + preview_img.load(function() { + + var preview = thumbnail.find('.list-node-children-item-preview'); + + // Positioning stuff + var offset = thumbnail.offset(); + var offset_min_x = $('.node-children').width() - preview.width(); + var offset_min_y = $('.node-children').height() - preview.height(); + + if (preview && offset.top > 300) { + $(preview).css({'top': (preview.height() * (-1))}); + }; + + if (offset.left > offset_min_x) { + $(preview).css({'right': 0}); + }; + + $(preview).addClass('active'); + }); + } + + + }, + function(){ + $('.list-node-children-item-preview').removeClass('active'); + }); + + // hide preview on mouse hover itself + $('.list-node-children-item-preview').hover(function(){ + $(this).removeClass('active'); + }); + + + $('a.item_icon') + .unbind("click") + .click(function(e){ + e.preventDefault(); + // When clicking on a node preview, we load its content + var nodeId = $(this).data('node_id'); + // displayNode will run asynchronously and set the bcloud_current_node_id + // as well, but we set it manually in the next line as well, to make sure + // that select_node on jstree works as expected, preventing the node to be + // loaded twice. + Cookies.set('bcloud_current_node_id', nodeId); + displayNode(nodeId); + // Update tree with current selection + $('#project_tree').jstree('select_node', 'n_' + nodeId); + }); + +include ../_scripts + +| {% endblock %} + diff --git a/src/templates/nodes/custom/hdri/view_embed.jade b/src/templates/nodes/custom/hdri/view_embed.jade new file mode 100644 index 00000000..de09bc6a --- /dev/null +++ b/src/templates/nodes/custom/hdri/view_embed.jade @@ -0,0 +1,133 @@ +| {% block body %} + +#node-container.texture + #node-overlay + section.node-preview + | {% if node.picture %} + iframe( + width='100%', + height='450px', + scrolling='no', + frameborder='0', + allowfullscreen='', + src="{{url_for('main.vrview', preview=node.picture.thumbnail('l', api=api), image=node.picture.thumbnail('h', api=api), is_stereo='false')}}") + | {% endif %} + + section.node-details-container + + .node-details-header + .node-title#node-title + | {{node.name}} + + .node-details-meta.header + ul.node-details-meta-list + | {% if node.permissions.world %} + li.node-details-meta-list-item.access.public( + data-toggle="tooltip", + data-placement="bottom", + title="Anybody can download. Share it!") + i.pi-lock-open + span Public + | {% endif %} + + | {% if node.properties.license_type %} + | {% if node.properties.license_notes %} + li.node-details-meta-list-item.video.license( + id="asset-license", + data-toggle="popover", + data-placement="left", + data-trigger="hover", + data-content="{{ node.properties.license_notes }}", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% else %} + li.node-details-meta-list-item.video.license( + id="asset-license", + data-toggle="tooltip", + data-placement="bottom", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% endif %} + | {% endif %} + + | {% if node.properties.files %} + li.btn-group.node-details-meta-list-item.video.download( + title="Download HDRI") + button.btn.btn-default.dropdown-toggle( + type="button", + data-toggle="dropdown", + aria-haspopup="true", + aria-expanded="false") + i.pi-download + i.pi-angle-down.icon-dropdown-menu + + ul.dropdown-menu + | {% for var in node.properties.files %} + li + a(href="{{ var.file.link }}", + title="Download this HDRi format", + download) + span.length {{ var.file.length | filesizeformat }} + + span.format {{ var.file.format }} + span.size {{ var.resolution }} + + | {% endfor %} + | {% else %} + li.btn-group.node-details-meta-list-item.video.download.disabled( + title="Download HDRi") + button.btn.btn-default.sorry(type="button") + i.pi-download + i.pi-angle-down.icon-dropdown-menu + | {% endif %} + + | {% if node.description %} + .node-details-description#node-description + | {{node.description}} + | {% endif %} + + | {% if node.properties.license_notes %} + .node-details-meta.license + | {{ node.properties.license_notes }} + | {% endif %} + + .node-details-meta.footer + ul.node-details-meta-list + li.node-details-meta-list-item.status + | {{node.properties.status}} + + li.node-details-meta-list-item.author + | {{ node.user.full_name }} + + li.node-details-meta-list-item.date(title="Created {{ node._created }}") + | {{ node._created | pretty_date }} + | {% if (node._created | pretty_date) != (node._updated | pretty_date) %} + span(title="Updated {{ node._updated }}") (updated {{ node._updated | pretty_date }}) + | {% endif %} + + +include ../_scripts + +| {% endblock %} + +| {% block footer_scripts %} +script. + $('#asset-license').popover(); + // Generate GA pageview + ga('send', 'pageview', location.pathname); + + + $('.sorry').click(function() { + $.get('/403', function(data) { + $('#node-overlay').html(data).show().addClass('active'); + }) + }); + + $('#node-overlay').click(function(){ + $(this).removeClass('active').hide().html(); + }); + +| {% endblock %} + diff --git a/src/templates/nodes/custom/post/create.jade b/src/templates/nodes/custom/post/create.jade new file mode 100644 index 00000000..670af4ab --- /dev/null +++ b/src/templates/nodes/custom/post/create.jade @@ -0,0 +1,175 @@ +| {% extends 'layout.html' %} + +| {% set title = 'blog' %} + +| {% block page_title %}New {{ node_type.name }}{% endblock %} + +| {% block body %} +.container.box + form( + method='POST', + action="{{url_for('nodes.posts_create', project_id=project._id)}}") + + #blog_container.post-create + + | {% with errors = errors %} + | {% if errors %} + | {% for field in errors %} + .alert.alert-danger(role='alert') + strong {{field}} + | {% for message in errors[field] %} + | {{message}}| + | {% endfor %} + | {% endfor %} + | {% endif %} + | {% endwith %} + + #blog_index-sidebar + | {% if project._id != config.MAIN_PROJECT_ID %} + .blog_project-card + a.item-header( + href="{{ url_for('projects.view', project_url=project.url) }}") + + .overlay + | {% if project.picture_header %} + img.background(src="{{ project.picture_header.thumbnail('m', api=api) }}") + | {% endif %} + + a.card-thumbnail( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {% if project.picture_square %} + img.thumb(src="{{ project.picture_square.thumbnail('m', api=api) }}") + | {% endif %} + + .item-info + + a.item-title( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {{ project.name }} + + | {% endif %} + + .blog_project-sidebar + #blog_post-edit-form + | {% for field in form %} + | {% if field.name in ['picture', 'status'] %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% endif %} + | {% endfor %} + + input.btn.btn-default.button-create(type='submit', value='Create {{ node_type.name }}') + + a.btn.btn-default.button-back(href="{{ url_for('projects.view', project_url=project.url) }}blog") + | Back to Blog + + #blog_post-create-container + #blog_post-edit-title + | Create {{ node_type.name }} on {{ project.name }} + + #blog_post-edit-form + | {% for field in form %} + | {% if field.name == 'csrf_token' %} + | {{ field }} + | {% else %} + | {% if field.type == 'HiddenField' %} + | {{ field }} + | {% else %} + + | {% if field.name not in ['description', 'picture', 'category', 'status'] %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% endif %} + | {% endif %} + | {% endif %} + | {% endfor %} + + + +| {% endblock %} + +| {% block footer_scripts %} +script(type="text/javascript"). + + function FormatForUrl(str) { + return str.replace(/_/g, '-') + .replace(/ /g, '-') + .replace(/:/g, '-') + .replace(/\\/g, '-') + .replace(/\//g, '-') + .replace(/[^a-zA-Z0-9\-]+/g, '') + .replace(/-{2,}/g, '-') + .toLowerCase(); + }; + + var convert = new Markdown.getSanitizingConverter().makeHtml; + + /* Build the markdown preview when typing in textarea */ + $(function() { + + var $textarea = $('.form-group.content textarea'), + $loader = $('
      ').insertAfter($textarea), + $preview = $('
      ').insertAfter($loader); + + $loader.hide(); + + // Delay function to not start converting heavy posts immediately + var delay = (function(){ + var timer = 0; + return function(callback, ms){ + clearTimeout (timer); + timer = setTimeout(callback, ms); + }; + })(); + + $textarea.keyup(function() { + /* If there's an iframe (YouTube embed), delay markdown convert 1.5s */ + if (/iframe/i.test($textarea.val())) { + $loader.show(); + + delay(function(){ + // Convert markdown + $preview.html(convert($textarea.val())); + $loader.hide(); + }, 1500 ); + } else { + // Convert markdown + $preview.html(convert($textarea.val())); + }; + }).trigger('keyup'); + }); + + $(function() { + var $name_input = $('.form-group.name input'); + $name_input.keyup(function() { + $('#url').val(FormatForUrl($name_input.val())); + }).trigger('keyup'); + }); + + +| {% endblock %} + +| {% block footer_navigation %} +| {% endblock %} +| {% block footer %} +| {% endblock %} diff --git a/src/templates/nodes/custom/post/edit.jade b/src/templates/nodes/custom/post/edit.jade new file mode 100644 index 00000000..2c605de9 --- /dev/null +++ b/src/templates/nodes/custom/post/edit.jade @@ -0,0 +1,168 @@ +| {% extends 'layout.html' %} + +| {% set title = 'blog' %} + +| {% block page_title %}New {{ node_type.name }}{% endblock %} + +| {% block body %} + +.container.box + form( + method='POST', + action="{{url_for('nodes.posts_edit', post_id=post._id)}}") + + #blog_container.post-create + + | {% with errors = errors %} + | {% if errors %} + | {% for field in errors %} + .alert.alert-danger(role='alert') + strong {{field}} + | {% for message in errors[field] %} + | {{message}}| + | {% endfor %} + | {% endfor %} + | {% endif %} + | {% endwith %} + + #blog_index-sidebar + | {% if project._id != config.MAIN_PROJECT_ID %} + .blog_project-card + a.item-header( + href="{{ url_for('projects.view', project_url=project.url) }}") + + .overlay + | {% if project.picture_header %} + img.background(src="{{ project.picture_header.thumbnail('m', api=api) }}") + | {% endif %} + + a.card-thumbnail( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {% if project.picture_square %} + img.thumb(src="{{ project.picture_square.thumbnail('m', api=api) }}") + | {% endif %} + + .item-info + + a.item-title( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {{ project.name }} + + | {% endif %} + + .blog_project-sidebar + #blog_post-edit-form + | {% for field in form %} + | {% if field.name in ['picture', 'status'] %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% endif %} + | {% endfor %} + + button.btn.btn-default.button-create(type='submit') + i.pi-check + | Update {{ node_type.name }} + + a.btn.btn-default.button-back(href="{{ url_for_node(node=post) }}") + i.pi-angle-left + | Back to Post + + a.btn.btn-default.button-back(href="{{ url_for('projects.view', project_url=project.url) }}blog") + | Go to Blog + + #blog_post-edit-container + #blog_post-edit-title + | Edit {{ node_type.name }} + + #blog_post-edit-form + | {% for field in form %} + | {% if field.name == 'csrf_token' %} + | {{ field }} + | {% else %} + | {% if field.type == 'HiddenField' %} + | {{ field }} + | {% else %} + + | {% if field.name not in ['description', 'picture', 'category', 'status'] %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% endif %} + | {% endif %} + | {% endif %} + | {% endfor %} + + + +| {% endblock %} + +| {% block footer_scripts %} +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.iframe-transport.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.iframe-transport.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.fileupload.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/file_upload.min.js') }}") + +script(type="text/javascript"). + var convert = new Markdown.getSanitizingConverter().makeHtml; + ProjectUtils.setProjectAttributes({projectId: "{{project._id}}"}); + + /* Build the markdown preview when typing in textarea */ + $(function() { + + var $textarea = $('.form-group.content textarea'), + $loader = $('
      ').insertAfter($textarea), + $preview = $('
      ').insertAfter($loader); + + $loader.hide(); + + // Delay function to not start converting heavy posts immediately + var delay = (function(){ + var timer = 0; + return function(callback, ms){ + clearTimeout (timer); + timer = setTimeout(callback, ms); + }; + })(); + + $textarea.keyup(function() { + /* If there's an iframe (YouTube embed), delay markdown convert 1.5s */ + if (/iframe/i.test($textarea.val())) { + $loader.show(); + + delay(function(){ + // Convert markdown + $preview.html(convert($textarea.val())); + $loader.hide(); + }, 1500 ); + } else { + // Convert markdown + $preview.html(convert($textarea.val())); + }; + }).trigger('keyup'); + }); + +| {% endblock %} + +| {% block footer_navigation %} +| {% endblock %} +| {% block footer %} +| {% endblock %} diff --git a/src/templates/nodes/custom/post/view.jade b/src/templates/nodes/custom/post/view.jade new file mode 100644 index 00000000..b77c8871 --- /dev/null +++ b/src/templates/nodes/custom/post/view.jade @@ -0,0 +1,4 @@ +| {% extends 'layout.html' %} +| {% block page_title %}{{node.name}} - Blog{% endblock%} + +include view_embed diff --git a/src/templates/nodes/custom/post/view_embed.jade b/src/templates/nodes/custom/post/view_embed.jade new file mode 100644 index 00000000..be58eea5 --- /dev/null +++ b/src/templates/nodes/custom/post/view_embed.jade @@ -0,0 +1,128 @@ +| {% block og %} + +| {% set title = 'blog' %} + +| {% if project %} +meta(property="og:title", content="{{ node.name }}{% if project._id == config.MAIN_PROJECT_ID %} — Blender Cloud Blog{% else%} - {{ project.name }} — Blender Cloud{% endif %}") +| {% endif %} +| {% if project and project.properties.picture_header %} +meta(property="og:image", content="{{ project.properties.picture_header.thumbnail('l', api=api) }}") +| {% elif node and node.picture %} +meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}") +| {% endif %} +meta(property="og:description", content="{{ node.properties.content }}") +meta(property="og:type", content="article") +meta(property="article:type", content="{{node.user.full_name}}") +meta(property="article:published_time", content="{{node._created | pretty_date }}") +meta(property="og:see_also", content="https://cloud.blender.org/blog") +meta(property="og:url", content="https://cloud.blender.org{{ url_for_node(node=node) }}") +| {% endblock %} + +| {% block tw %} +meta(name="twitter:card", content="summary_large_image") +| {% if project._id == config.MAIN_PROJECT_ID %} +meta(property="twitter:title", content="{{ node.name }} — Blender Cloud Blog") +| {% else %} +meta(property="twitter:title", content="{{ node.name }} - {{ project.name }} Blog — Blender Cloud") +| {% endif %} +| {% if project and project.properties.picture_header %} +meta(name="twitter:image", content="{{ project.properties.picture_header.thumbnail('l', api=api) }}") +| {% elif node and node.picture %} +meta(name="twitter:image", content="{{ node.picture.thumbnail('l', api=api) }}") +| {% else %} +meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/backgrounds/background_caminandes_3_02.jpg')}}") +| {% endif %} +meta(name="twitter:description", content="{{ node.properties.content }}") +| {% endblock %} + +| {% block body %} + +.container.box + #blog_container(class="{% if project._id == config.MAIN_PROJECT_ID %}cloud-blog{% endif %}") + + #blog_post-container + | {% if project._id == config.MAIN_PROJECT_ID %} + a.btn.btn-default.button-back(href="{{ url_for('projects.view', project_url=project.url) }}blog") + | Back to Blog + + | {% if node.has_method('PUT') %} + a.btn.btn-default.button-edit(href="{{url_for('nodes.posts_edit', post_id=node._id)}}") + i.pi-edit + | Edit Post + | {% endif %} + + .clearfix + | {% endif %} + + | {% if node.picture %} + .blog_index-header + img(src="{{ node.picture.thumbnail('l', api=api) }}") + | {% endif %} + .blog_index-item + + .item-title + | {{node.name}} + + .item-info. + {{node._created | pretty_date }} + {% if node._created != node._updated %} + (updated {{node._updated | pretty_date }}) + {% endif %} + {% if node.properties.category %}| {{node.properties.category}}{% endif %} + | by {{node.user.full_name}} + + .item-content + | {{ node.properties.content }} + + + #comments-container + #comments-list-items-loading + i.pi-spin + + | {% if project._id != config.MAIN_PROJECT_ID %} + #blog_index-sidebar + .blog_project-card + a.item-header( + href="{{ url_for('projects.view', project_url=project.url) }}") + + .overlay + | {% if project.picture_header %} + img.background(src="{{ project.picture_header.thumbnail('m', api=api) }}") + | {% endif %} + + a.card-thumbnail( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {% if project.picture_square %} + img.thumb(src="{{ project.picture_square.thumbnail('m', api=api) }}") + | {% endif %} + + .item-info + + a.item-title( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {{ project.name }} + + | {% if project.summary %} + p.item-description + | {{project.summary|safe}} + | {% endif %} + .blog_project-sidebar + | {% if node.has_method('PUT') %} + a.btn.btn-default.button-create(href="{{url_for('nodes.posts_edit', post_id=node._id)}}") + | Edit Post + | {% endif %} + + a.btn.btn-default.button-back(href="{{ url_for('projects.view', project_url=project.url) }}blog") + | Back to Blog + | {% endif %} + + +| {% endblock %} + + +| {% block footer_scripts %} + +include ../_scripts +script hopToTop(); // Display jump to top button + +| {% endblock %} diff --git a/src/templates/nodes/custom/storage/index_embed.jade b/src/templates/nodes/custom/storage/index_embed.jade new file mode 100644 index 00000000..b1615608 --- /dev/null +++ b/src/templates/nodes/custom/storage/index_embed.jade @@ -0,0 +1,53 @@ +| {% block body %} + +#node-container + + section.node-details-container.storage + + .node-details-header + .node-title + | {{node.name}} + + + section.node-children.storage + + | {% if node.children %} + | {% for child in node.children %} + + a(href="#", data-node_id="{{ node._id }}" data-path="{{ child['path'] }}", title="{{ child['name'] }}", class="item_icon") + .list-node-children-item + .list-node-children-item-thumbnail + + .list-node-children-item-thumbnail-icon + | {% if child['content_type'] == 'video' %} + i.pi-film + | {% elif child['content_type'] == 'image' %} + i.pi-image + | {% elif child['content_type'] == 'file' %} + i.pi-document + | {% elif child['content_type'] == 'binary' %} + i.pi-file-archive + | {% else %} + i.pi-folder + | {% endif %} + + .list-node-children-item-name + + span {{ child['name'] }} + + | {% endfor %} + | {% endif %} + + script. + $('a.item_icon').click(function(e){ + // When clicking on a node preview, we load its content + e.preventDefault; + var nodeId = $(this).data('node_id'); + var path = $(this).data('path'); + displayStorage(nodeId, path); + // Update tree with current selection + //$('#project_tree').jstree('select_node', 'n_' + nodeId); + }); + +| {% endblock %} + diff --git a/src/templates/nodes/custom/storage/view_embed.jade b/src/templates/nodes/custom/storage/view_embed.jade new file mode 100644 index 00000000..e77efe88 --- /dev/null +++ b/src/templates/nodes/custom/storage/view_embed.jade @@ -0,0 +1,33 @@ +| {% block body %} + +#node-container + + section.node-details-container.storage + + .node-details-header + .node-title + | {{node.name}} + + //- .node-details-description + //- | {{node.description}} + + .node-details-meta + + ul.node-details-meta-list + li.node-details-meta-list-item.status + | {{node.status}} + + li.node-details-meta-list-item.date(title="Created {{ node._created | pretty_date }}") + | {{ node._updated | pretty_date }} + + li.node-details-meta-list-item.file.length + | {{ node.length | filesizeformat }} + + li.node-details-meta-list-item.file.download + a(href="{% if node.has_method('GET') %}{{ node.download_link }}{% else %}{{ url_for('users.login') }}{% endif %}") + button.btn.btn-default(type="button") + | Download + + +| {% endblock %} + diff --git a/src/templates/nodes/custom/texture/view_embed.jade b/src/templates/nodes/custom/texture/view_embed.jade new file mode 100644 index 00000000..9b57323b --- /dev/null +++ b/src/templates/nodes/custom/texture/view_embed.jade @@ -0,0 +1,195 @@ +| {% block body %} + +#node-container.texture + #node-overlay + + .texture-title#node-title + | {{node.name}} + + | {% if node.properties.license_type %} + | {% if node.properties.license_notes %} + .texture-license( + id="asset-license", + data-toggle="popover", + data-placement="left", + data-trigger="hover", + data-content="{{ node.properties.license_notes }}", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% else %} + .texture-license( + id="asset-license", + data-toggle="tooltip", + data-placement="bottom", + title="{{ node.properties.license_type }}") + + i(class="pi-license-{{ node.properties.license_type }}") + | {% endif %} + | {% endif %} + + + section.node-row.texture-info + | {% if node.properties.files %} + span.texture-info-files + i.pi-texture + | {{ node.properties.files|length }} map{% if node.properties.files|length != 1 %}s{% endif %} + | {% endif %} + span.texture-info-seamless + i.pi-puzzle + | {% if not node.properties.is_tileable %}Not {% endif %}Seamless + + | {% if node.properties.files %} + | {% for f in node.properties.files %} + section.node-row.texture-map + section.node-preview.texture + img.node-preview-thumbnail( + src="{{ f.file.thumbnail('m', api=api) }}", + data-preview="{{ f.file.thumbnail('l', api=api) }}", + data-aspect_ratio="{{ node.properties.aspect_ratio }}") + + | {% if f.map_type == 'color' %} + | {% set map_type = 'Color Map' %} + | {% elif f.map_type == 'bump' %} + | {% set map_type = 'Bump Map' %} + | {% elif f.map_type == 'specular' %} + | {% set map_type = 'Specular Map' %} + | {% elif f.map_type == 'normal' %} + | {% set map_type = 'Normal Map' %} + | {% elif f.map_type == 'translucency' %} + | {% set map_type = 'Translucency' %} + | {% elif f.map_type == 'emission' %} + | {% set map_type = 'Emission' %} + | {% elif f.map_type == 'alpha' %} + | {% set map_type = 'Alpha' %} + | {% elif f.map_type == 'id' %} + | {% set map_type = 'ID Map' %} + | {% else %} + | {% set map_type = f.map_type %} + | {% endif %} + + section.node-details-container.texture + + .node-details-header + .node-title {{map_type}} + + .node-details-attributes + span.sizes + span.x + | Width: + strong {{ f.file.width }} + span.y + | Height: + strong {{ f.file.height }} + span.length + | {{ f.file.length | filesizeformat }} + span.content_type + | {{ f.file.content_type }} + + .node-details-meta + ul.node-details-meta-list + li.node-details-meta-list-item.texture.download + | {% if f.file.link %} + a(href="{{ f.file.link }}",, + title="Download texture", + download="{{ f.file.filename }}") + button.btn.btn-default(type="button") + i.pi-download + | Download + | {% else %} + button.btn.btn-default.disabled.sorry(type="button") + | Download + | {% endif %} + | {% endfor %} + | {% else %} + section.node-row + section.node-details-container.texture + .node-details-header.nofiles + .node-title No texture maps... yet! + + | {% endif %} + + +include ../_scripts + +| {% endblock %} + +| {% block footer_scripts %} +script. + $('#asset-license').popover(); + // Generate GA pageview + ga('send', 'pageview', location.pathname); + + var str = $('.texture-title').text(); + var to_replace = /_color|_bump|_specular|_normal|_translucency|_emission|_alpha|_tileable|.jpg|.png/g; + $('.texture-title').text(str.replace(to_replace,'').replace(/_/g,' ')); + + $('.node-preview-thumbnail').each(function(i){ + $(this).closest('.node-preview').css({'height' : $(this).width() / $(this).data('aspect_ratio')}); + + var thumbnail = $(this); + var src = $(this).attr('src'); + var src_xl = $(thumbnail).data('preview'); + var src_xl_width, src_xl_height; + + /* Make dummy img in memory otherwise we have css issues */ + $("") + .attr('src', src_xl) + .load(function(){ + src_xl_width = this.width; + src_xl_height = this.height; + }); + + $(this).on('click', function(e){ + e.preventDefault(); + }); + + $(this).hover( + function(){ + var preview = $(this); + + /* Replace image src with larger one */ + if (src_xl_width > 350 || src_xl_height > 250) { + $(thumbnail).attr('src', src_xl); + $(preview).css({width: src_xl_width + 'px', height: src_xl_height + 'px'}); + } + + var parent = $(preview).parent(); + var parentOffset = parent.offset(); + + if (src_xl_width > 600 || src_xl_height > 300) { + + $(document).on('mousemove', function(e){ + $(preview).css({ + left: e.pageX - parentOffset.left - (src_xl_width / 2), + top: e.pageY - parentOffset.top - (src_xl_height / 2), + transform: 'initial', + cursor: 'grabbing', + }); + }); + }; + + }, + function(){ + $(document).off('mousemove'); + $(this).attr('src', src); + + $(this).css({left: '50%', top: "50%", width: '100%', height: 'auto', transform: 'translate(-50%, -50%)'}); + + } + ); + + }); + + $('.sorry').click(function() { + $.get('/403', function(data) { + $('#node-overlay').html(data).show().addClass('active'); + }) + }); + + $('#node-overlay').click(function(){ + $(this).removeClass('active').hide().html(); + }); + +| {% endblock %} + diff --git a/src/templates/nodes/edit.jade b/src/templates/nodes/edit.jade new file mode 100644 index 00000000..c1aa39cc --- /dev/null +++ b/src/templates/nodes/edit.jade @@ -0,0 +1,11 @@ +| {% extends 'layout.html' %} + +| {% block body %} +div.container + div.page-content + | {% include 'nodes/edit_embed.html' %} +| {% endblock %} + +| {% block footer_scripts %} +| {% include '_macros/_file_uploader_javascript.html' %} +| {% endblock %} diff --git a/src/templates/nodes/edit_embed.jade b/src/templates/nodes/edit_embed.jade new file mode 100644 index 00000000..4f01d6c2 --- /dev/null +++ b/src/templates/nodes/edit_embed.jade @@ -0,0 +1,347 @@ +| {% from '_macros/_node_edit_form.html' import render_field %} + +| {% block body %} + +| {% with errors = errors %} +| {% if errors %} + +| {% for field in errors %} +.alert.alert-danger(role="alert") + strong {{field}} + | {% for message in errors[field] %} + | {{message}}| + | {% endfor %} + +| {% endfor %} + +| {% endif %} +| {% endwith %} + +| {% if error!="" %} +.alert.alert-danger(role="alert") + | {{error}} +| {% endif %} + +#node-edit-container + + form( + id="node-edit-form", + class="{{ node.node_type }}", + method="POST", + enctype="multipart/form-data", + action="{{url_for('nodes.edit', node_id=node._id)}}") + + | {% for field in form %} + + | {% if field.name == 'csrf_token' %} + | {{ field }} + + | {% elif field.type == 'HiddenField' %} + | {{ field }} + + | {% elif field.name == 'attachments' %} + #attachments-actions + .btn.btn-info#attachments-action-add + i.pi-plus + | Add New Attachment + + | {{ render_field(field) }} + + | {% elif field.name == 'files' %} + .files-header + #files-actions + #files-action-add + i.pi-plus + | Add New File + | {{ render_field(field) }} + + | {% else %} + | {{ render_field(field) }} + + | {% endif %} + + | {% endfor %} + + ul.project-edit-tools.bottom + li.button-cancel + a#item_cancel.item-cancel.project-mode-edit( + href="javascript:void(0);", + title="Cancel changes") + i.button-cancel-icon.pi-cancel + | Cancel + + li.button-save + a#item_save.item-save.project-mode-edit( + href="javascript:void(0);", + title="Save changes") + i.button-save-icon.pi-check + | Save Changes +script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.ui.widget.min.js') }}") +script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.iframe-transport.min.js') }}") +script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.fileupload.min.js') }}") +script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.select2.min.js') }}") +script(src="{{ url_for('static_pillar', filename='assets/js/file_upload.min.js') }}") + +script(type="text/javascript"). + + $(function () { + $('#tags').select2(); + }); + + var convert = new Markdown.getSanitizingConverter(); + Markdown.Extra.init(convert); + convert = convert.makeHtml; + + /* Build the markdown preview when typing in textarea */ + $(function() { + + var $textarea = $('.form-group.description textarea'), + $loader = $('
      ').insertAfter($textarea), + $preview = $('
      ').insertAfter($loader); + + $loader.hide(); + + // Delay function to not start converting heavy posts immediately + var delay = (function(){ + var timer = 0; + return function(callback, ms){ + clearTimeout (timer); + timer = setTimeout(callback, ms); + }; + })(); + + $textarea.keyup(function() { + /* If there's an iframe (YouTube embed), delay markdown convert 1.5s */ + if (/iframe/i.test($textarea.val())) { + $loader.show(); + + delay(function(){ + // Convert markdown + $preview.html(convert($textarea.val())); + $loader.hide(); + }, 1500 ); + } else { + // Convert markdown + $preview.html(convert($textarea.val())); + } + }).trigger('keyup'); + }); + + $(function() { + $('input, textarea').keypress(function () { + // Unused: save status of the page as 'edited' + ProjectUtils.setProjectAttributes({isModified: true}); + // Set the beforeunload to warn the user of unsaved changes + $(window).on('beforeunload', function () { + return 'You have unsaved changes in your asset.'; + }); + }); + }); + + $("#item_save").unbind( "click" ); + $("#item_cancel").unbind( "click" ); + $(".file_delete").unbind( "click" ); + + /* Reset Save Changes button status */ + $("li.button-save").removeClass('field-error saving'); + $("li.button-save a#item_save").html(' Save Changes'); + + + /* Submit changes */ + $("#node-edit-form").unbind( "submit" ) + .submit(function(e) { + e.preventDefault(); + + /* Let us know started saving */ + $("li.button-save").addClass('saving'); + $("li.button-save a#item_save").html(' Saving...'); + + $.ajax({ + url: "{{url_for('nodes.edit', node_id=node._id)}}", + data: $(this).serialize(), + type: 'POST' + }) + .fail(function(data){ + /* Something went wrong, print it */ + if (data.status == 422) { + statusBarSet('error', 'The submitted data could not be validated.', 8000); + } else { + statusBarSet('error', 'Error! We\'ve been notified and are working on it - ' + + data.status + ' ' + data.statusText, 8000); + } + + $("li.button-save").addClass('field-error'); + $("li.button-save a#item_save").html(' Houston!'); + + /* Back to normal */ + setTimeout(function(){ + $("li.button-save").removeClass('saving field-error'); + $("li.button-save a#item_save").html(' Save Changes'); + }, 8000); + }) + .done(function(dataHtml){ + /* Success! */ + + /* Load content*/ + $('#project_context').html(dataHtml); + statusBarSet('success', 'Saved Successfully', 'pi-check'); + + /* Style button */ + $("li.button-save").removeClass('saving field-error'); + $("li.button-save a#item_save").html(' Save Changes'); + + // XXX TODO - Keeps displaying 'loading', needs further investigation + //- $('#project_tree').jstree("refresh"); + + updateUi(ProjectUtils.nodeId(), 'view'); + }); + + }); + + $('#item_save, .item-save').click(function(e){ + e.preventDefault(); + + // Assets always need a file + if ($('.form-group.file #file').val() == ''){ + $('.form-group.file').addClass('error'); + statusBarSet('error', 'No File Selected', 'pi-warning', 5000); + } else { + $('.form-group.file').removeClass('error'); + $("#node-edit-form").submit(); + + // Disable beforeunolad when submitting a form + $(window).off('beforeunload'); + } + }); + + $('#item_cancel, .item-cancel').click(function(e){ + displayNode('{{node._id}}'); + }); + + var attrs = ['for', 'id', 'name', 'data-field-name']; + function resetAttributeNames(section) { + var tags = section.find('input, select, label, div, a'); + var idx = section.index(); + tags.each(function () { + var $this = $(this); + + // Renumber certain attributes. + $.each(attrs, function (i, attr) { + var attr_val = $this.attr(attr); + if (attr_val) { + $this.attr(attr, attr_val.replace(/-\d+/, '-' + idx)) + } + }); + + // Clear input field values + var tagname = $this.prop('tagName'); + if (tagname == 'INPUT') { + if ($this.attr('type') == 'checkbox') { + $this.prop('checked', false); + } else { + $this.val(''); + } + } else if (tagname == 'SELECT') { + $this.find(':nth-child(1)').prop('selected', true); + } + }); + + // Click on all file delete buttons to clear all file widgets. + section.find('a.file_delete').click(); + section.find('div.form-upload-progress-bar').hide(); + } + + var initUploadFields = function(selector_string) { + // console.log($(selector_string)); + $(selector_string).fileupload({ + dataType: 'json', + acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i, + replaceFileInput: false, + dropZone: $(this), + formData: {}, + progressall: function (e, data) { + // Update progressbar during upload + var progress = parseInt(data.loaded / data.total * 100, 10); + $(this).next().find('.form-upload-progress-bar').css( + {'width': progress + '%', 'display': 'block'} + ).removeClass('progress-error').addClass('progress-active'); + fieldUpload = $(this); + }, + done: function (e, data) { + // Get the first file upload result (we only need one) + var fileData = data.result.files[0]; + // Create a file object on the server and retrieve its id + statusBarSet('info', 'Uploading File...', 'pi-upload-cloud'); + + $('.button-save').addClass('disabled'); + $('li.button-save a#item_save').html(' Uploading Preview...'); + + var payload = { + name: fileData.name, + size: fileData.size, + type: fileData.type, + field_name: $(this).data('field-name'), + project_id: ProjectUtils.projectId() + } + $.post("/files/create", payload) + .done(function (data) { + if (data.status === 'success') { + // If successful, add id to the picture hidden field + var field_name = '#' + data.data.field_name; + if ($(field_name).val()) { + $('.node-preview-thumbnail').hide(); + deleteFile($(field_name), data.data.id); + } else { + $(field_name).val(data.data.id); + } + + var previewThumbnail = fieldUpload.prev().prev(); + + $(previewThumbnail).attr('src', data.data.link); + $('.node-preview-thumbnail').show(); + statusBarSet('success', 'File Uploaded Successfully', 'pi-check'); + + $('.button-save').removeClass('disabled'); + $('li.button-save a#item_save').html(' Save Changes'); + $('.progress-active').removeClass('progress-active progress-error'); + } + }) + .fail(function(data) { + $('.button-save').removeClass('disabled'); + $('li.button-save a#item_save').html(' Save Changes'); + + statusBarSet(data.textStatus, 'Upload error: ' + data.errorThrown, 'pi-attention', 8000); + }); + }, + fail: function (e, data) { + $('.button-save').removeClass('disabled'); + $('li.button-save a#item_save').html(' Save Changes'); + + statusBarSet(data.textStatus, 'Upload error: ' + data.errorThrown, 'pi-attention', 8000); + $('.progress-active').addClass('progress-error').removeClass('progress-active'); + } + }); + } + + if (document.getElementById("attachments") !== null) { + $("#attachments-action-add").on('click', function(){ + var lastRepeatingGroup = $('#attachments > li').last(); + var cloned = lastRepeatingGroup.clone(true); + cloned.insertAfter(lastRepeatingGroup); + resetAttributeNames(cloned); + }); + } + + if (document.getElementById("files") !== null) { + $("#files-action-add").on('click', function () { + var lastRepeatingGroup = $('#files > li').last(); + var cloned = lastRepeatingGroup.clone(false); + cloned.insertAfter(lastRepeatingGroup); + resetAttributeNames(cloned); + cloned.find('.fileupload').each(setup_file_uploader) + }); + } + //- console.log($._data($(elementSelector)[0], "events")); + + +| {% endblock %} diff --git a/src/templates/nodes/search.jade b/src/templates/nodes/search.jade new file mode 100644 index 00000000..60a44bcf --- /dev/null +++ b/src/templates/nodes/search.jade @@ -0,0 +1,301 @@ +| {% extends 'layout.html' %} +| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %} + +| {% block og %} +meta(property="og:type", content="website") +| {% if og_picture %} +meta(property="og:image", content="{{ og_picture.thumbnail('l', api=api) }}") +| {% endif %} +| {% if project %} +meta(property="og:title", content="{{project.name}} - Blender Cloud") +meta(property="og:url", content="{{url_for('projects.view', project_url=project.url, _external=True)}}") +meta(property="og:description", content="{{project.summary}}") +| {% endif %} +| {% endblock %} + +| {% block tw %} +| {% if og_picture %} +meta(property="twitter:image", content="{{ og_picture.thumbnail('l', api=api) }}") +| {% endif %} +| {% if project %} +meta(name="twitter:title", content="{{project.name}} on Blender Cloud") +meta(name="twitter:description", content="{{project.summary}}") +| {% endif %} +| {% endblock %} + +| {% block body %} + +#search-container + + | {% if project %} + #project_sidebar + ul.project-tabs + li.tabs-thumbnail( + title="About", + data-toggle="tooltip", + data-placement="left", + class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}") + a(href="{{url_for('projects.about', project_url=project.url, _external=True)}}") + #project-loading + i.pi-spin + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('b', api=api) }}") + | {% else %} + i.pi-home + | {% endif %} + li.tabs-browse( + title="Browse", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + i.pi-tree-flow + li.tabs-search.active( + title="Search", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}") + i.pi-search + | {% endif %} + + #search-sidebar + input.search-field( + type="text", + name="q", + id="q", + autocomplete="off", + spellcheck="false", + autocorrect="false", + placeholder="Search by Title, Type...") + + .search-list-filters + .filter-list + | View as: + ul.filter-list + li.filter-list-type.grid( + title="Browse as grid", + data-list-type="grid") + i.pi-layout + li.filter-list-type.list( + title="Browse as list", + data-list-type="list") + i.pi-list + + #accordion.panel-group.accordion(role="tablist", aria-multiselectable="true") + #facets + + #pagination + + .search-list-stats + #stats + + #search-list + #hits + + #search-details + #search-error + #search-hit-container + + +| {% raw %} +// Facet template +script(type="text/template", id="facet-template") + .panel.panel-default + a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}') + .panel-heading(role='tab') + .panel-title {{ title }} + .panel-collapse.collapse.in(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne') + .panel-body + | {{#values}} + a.facet_link.toggleRefine( + class='{{#refined}}refined{{/refined}}', + data-facet='{{ facet }}', + data-value='{{ value }}', + href='#') + span + | {{ label }} + small.facet_count.pull-right {{ count }} + | {{/values}} + + +// Hit template +script(type="text/template", id="hit-template") + .search-hit(data-hit-id='{{ objectID }}') + #search-loading.search-loading + .spinner + span.spin · + .search-hit-thumbnail + | {{#picture}} + img(src="{{{ picture }}}") + | {{/picture}} + | {{^picture}} + .search-hit-thumbnail-icon + | {{#media}} + i(class="pi-{{{ media }}}") + | {{/media}} + | {{^media}} + i.dark(class="pi-{{{ node_type }}}") + | {{/media}} + | {{/picture}} + | {{#is_free}} + .search-hit-ribbon + span free + | {{/is_free}} + .search-hit-name + | {{{ _highlightResult.name.value }}} + .search-hit-meta + span.project {{{ project.name }}} · + span.node_type {{{ node_type }}} + | {{#media}} + span.media · {{{ media }}} + | {{/media}} + span.when {{{ created }}} + span.context + a(href="/nodes/{{ objectID }}/redir") view in context + + +// Pagination template +script(type="text/template", id="pagination-template") + ul.search-pagination. +
    • + {{#pages}} +
    • {{ number }}
    • + {{/pages}} +
    • + +// Stats template +script(type="text/template", id="stats-template") + span {{ nbHits }} result{{#nbHits_plural}}s{{/nbHits_plural}} + small ({{ processingTimeMS }}ms) +| {% endraw %} + +| {% endblock %} + +| {% block footer_scripts %} +script(src="//releases.flowplayer.org/6.0.5/flowplayer.min.js", async) +script(). + var APPLICATION_ID = '{{config.ALGOLIA_USER}}'; + var SEARCH_ONLY_API_KEY = '{{config.ALGOLIA_PUBLIC_KEY}}'; + var INDEX_NAME = '{{config.ALGOLIA_INDEX_NODES}}'; + var sortByCountDesc = null; + var FACET_CONFIG = [ + { name: 'node_type', title: 'Type', disjunctive: false, sortFunction: sortByCountDesc }, + { name: 'media', title: 'Media', disjunctive: false, sortFunction: sortByCountDesc }, + { name: 'tags', title: 'Tags', disjunctive: false, sortFunction: sortByCountDesc }, + { name: 'is_free', title: 'Free Access', disjunctive: false, sortFunction: sortByCountDesc }, + ]; + {% if project %} + FACET_CONFIG.push({ name: 'project._id', title: 'Project', disjunctive: false, hidden: true, value: '{{project._id}}' }) + {% endif %} + +script(src="//cdn.jsdelivr.net/algoliasearch.helper/2/algoliasearch.helper.min.js") +script(src="//cdn.jsdelivr.net/hogan.js/3.0.0/hogan.common.js") +script(src="{{ url_for('static_pillar', filename='assets/js/algolia_search.min.js') }}") + +script(type="text/javascript"). + + function displayUser(userId) { + var url = '/nodes/' + userId + '/view'; + + $.get(url, function(dataHtml){ + $('#search-hit-container').html(dataHtml); + }) + .done(function(){ + $('.search-loading').removeClass('active'); + $('#search-error').hide(); + $('#search-hit-container').show(); + }) + .fail(function(data){ + $('.search-loading').removeClass('active'); + $('#search-hit-container').hide(); + $('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText); + }); + } + + $('body').on('click', '.search-hit', function(){ + if ($('.search-loading').hasClass('active')){ + $(this).removeClass('active'); + } + $(this).find('#search-loading').addClass('active'); + + displayUser($(this).data('hit-id')); + $('.search-hit').removeClass('active'); + $(this).addClass('active'); + }); + + // Remove focus from search input so that the click event bound to .search-hit + // can be fired on the first click. + $(searchList).hover(function(){ + $('#q').blur(); + }); + $('#search-sidebar').hover(function(){ + $('#q').focus(); + }); + + /* UI Stuff */ + + /* List types, grid or list (default)*/ + var uiListType = Cookies.getJSON('bcloud_ui'); + var searchList = document.getElementById('search-list'); + + function uiSetListType(type){ + $('.filter-list-type').removeClass('active'); + + if (type == 'grid'){ + $(searchList).addClass('view-grid'); + $('.filter-list-type.grid').addClass('active'); + } else { + $(searchList).removeClass('view-grid'); + $('.filter-list-type.list').addClass('active'); + } + } + + if (uiListType && uiListType.search_browse_type == 'grid'){ + uiSetListType('grid'); + } else { + uiSetListType('list'); + } + + $('.filter-list-type').on('click', function(){ + if ($(this).attr('data-list-type') == 'grid'){ + uiSetListType('grid'); + setJSONCookie('bcloud_ui', 'search_browse_type', 'grid'); + } else { + uiSetListType('list'); + setJSONCookie('bcloud_ui', 'search_browse_type', 'list'); + } + }); + + /* Scrollbars */ + if (typeof Ps !== 'undefined'){ + Ps.initialize(searchList, {suppressScrollX: true}); + Ps.initialize(document.getElementById('search-hit-container'), {suppressScrollX: true}); + } + + /* Hide site-wide search, kinda confusing */ + $('.search-input').hide(); + + /* Resize container so we can have custom scrollbars */ + container_offset = $('#search-container').offset(); + + function containerResizeY(window_height){ + + var container_height = window_height - container_offset.top; + + if (container_height > parseInt($('#search-container').css("min-height"))) { + $('#search-container').css( + {'max-height': container_height + 'px', 'height': container_height + 'px'} + ); + $('#search-list, #search-hit-container').css( + {'max-height': container_height + 'px', 'height': container_height + 'px'} + ); + }; + }; + + $(window).on("load resize",function(){ + containerResizeY($(window).height()); + }); + +| {% endblock %} + +| {% block footer_navigation %}{% endblock %} +| {% block footer %}{% endblock %} diff --git a/src/templates/projects/_scripts.jade b/src/templates/projects/_scripts.jade new file mode 100644 index 00000000..3e280c99 --- /dev/null +++ b/src/templates/projects/_scripts.jade @@ -0,0 +1,22 @@ +script(type="text/javascript"). + + /* Convert Markdown */ + var convert = new Markdown.getSanitizingConverter().makeHtml; + var convert_fields = '.node-details-description, .blog_index-item .item-content'; + + /* Parse description/content fields to convert markdown */ + $(convert_fields).each(function(i){ + $(convert_fields).eq(i).html(convert($(convert_fields).eq(i).text())); + }); + + ProjectUtils.setProjectAttributes({isProject: true, nodeId: '', parentNodeId: ''}); + var movingMode = Cookies.getJSON('bcloud_moving_node'); + + if (movingMode){ + $('#item_move_accept').removeClass('disabled').html(' Move to Root'); + + if (movingMode.node_type === 'texture'){ + $('#item_move_accept').addClass('disabled').html('Select a Texture Folder'); + } + + }; diff --git a/src/templates/projects/edit.jade b/src/templates/projects/edit.jade new file mode 100644 index 00000000..91a6cf54 --- /dev/null +++ b/src/templates/projects/edit.jade @@ -0,0 +1,250 @@ +| {% extends 'layout.html' %} + +| {% set title = 'edit' %} + +| {% block page_title %}Edit {{ project.name }}{% endblock %} + +| {% block body %} +#project-container + #project-side-container + #project_sidebar + ul.project-tabs + li.tabs-thumbnail( + title="About", + data-toggle="tooltip", + data-placement="left", + class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}") + a(href="{{url_for('projects.about', project_url=project.url, _external=True)}}") + #project-loading + i.pi-spin + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('b', api=api) }}") + | {% else %} + i.pi-home + | {% endif %} + li.tabs-browse( + title="Browse", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + i.pi-tree-flow + | {% if not project.is_private %} + li.tabs-search( + title="Search", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}") + i.pi-search + | {% endif %} + + .project_nav-toggle-btn( + title="Expand Navigation [T]", + data-toggle="tooltip", + data-placement="right") + i.pi-angle-double-left + + #project_nav + #project_nav-container + #project_nav-header + .project-title + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + | {{ project.name }} + + // TODO - make list a macro + #project_tree + ul.project_nav-edit-list + li(class="{% if title == 'edit' %}active{% endif %}") + a(href="{{ url_for('projects.edit', project_url=project.url) }}") + i.pi-list + | Overview + li(class="{% if title == 'sharing' %}active{% endif %}") + a(href="{{ url_for('projects.sharing', project_url=project.url) }}") + i.pi-share + | Sharing + li(class="{% if title == 'edit_node_types' %}active{% endif %}") + a(href="{{ url_for('projects.edit_node_types', project_url=project.url) }}") + i.pi-puzzle + | Node Types + + .project_split(title="Toggle Navigation [T]") + + #project_context-container + #project_context-header + span#project-statusbar + + span#project-edit-title + | Edit Project + + ul.project-edit-tools + + // Edit Mode + li.button-cancel + a#item_cancel.project-mode-edit( + href="{{url_for('projects.view', project_url=project.url, _external=True)}}", + title="Cancel changes") + i.button-cancel-icon.pi-back + | Go to Project + + li.button-save + a#item_save.project-mode-edit( + href="#", + title="Save changes") + i.button-save-icon.pi-check + | Save Changes + #project_context + #node-edit-container + form( + id="node-edit-form" + method='POST', + action="{{url_for('projects.edit', project_url=project.url)}}") + + | {% with errors = errors %} + | {% if errors %} + | {% for field in errors %} + .alert.alert-danger(role='alert') + strong {{field}} + | {% for message in errors[field] %} + | {{message}}| + | {% endfor %} + | {% endfor %} + | {% endif %} + | {% endwith %} + + | {% for field in form %} + + | {% if field.name == 'csrf_token' %} + | {{ field }} + | {% else %} + | {% if field.type == 'HiddenField' %} + | {{ field }} + | {% else %} + + | {% if field.name not in hidden_fields %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {% if field.name == 'picture' %} + | {% if post.picture %} + img.node-preview-thumbnail(src="{{ post.picture.thumbnail('m', api=api) }}") + a(href="#", class="file_delete", data-field-name="picture", data-file_id="{{post.picture._id}}") Delete + | {% endif %} + | {% endif %} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% else %} + | {{ field(class='hidden') }} + | {% endif %} + + | {% endif %} + | {% endif %} + + | {% endfor %} + + + ul.project-edit-tools.bottom + li.button-cancel + a#item_cancel.project-mode-edit( + href="{{url_for('projects.view', project_url=project.url, _external=True)}}", + title="Cancel changes") + i.button-cancel-icon.pi-back + | Go to Project + + li.button-save + a#item_save.project-mode-edit( + href="#", + title="Save changes") + i.button-save-icon.pi-check + | Save Changes + + +| {% endblock %} + +| {% block footer_scripts %} +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.ui.widget.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.iframe-transport.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.fileupload.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/file_upload.min.js') }}") + +script(type="text/javascript"). + + /* UI Stuff */ + $(window).on("load resize",function(){ + containerResizeY($(window).height()); + }); + + /* Initialize scrollbars */ + if ((typeof Ps !== 'undefined') && window.innerWidth > 768){ + Ps.initialize(document.getElementById('project_tree'), {suppressScrollX: true}); + } + + $('.project-mode-edit').show(); + + ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: true, nodeId: ''}); + var convert = new Markdown.getSanitizingConverter().makeHtml; + + $('.button-save').on('click', function(e){ + e.preventDefault(); + // Disable beforeunolad when submitting a form + $(window).off('beforeunload'); + + $(this).children('a').html(' Saving'); + $('#node-edit-form').submit(); + }); + + /* Build the markdown preview when typing in textarea */ + $(function() { + + var $textarea = $('.form-group.description textarea'), + $loader = $('
      ').insertAfter($textarea), + $preview = $('
      ').insertAfter($loader); + + $loader.hide(); + + // Delay function to not start converting heavy posts immediately + var delay = (function(){ + var timer = 0; + return function(callback, ms){ + clearTimeout (timer); + timer = setTimeout(callback, ms); + }; + })(); + + $textarea.keyup(function() { + /* If there's an iframe (YouTube embed), delay markdown convert 1.5s */ + if (/iframe/i.test($textarea.val())) { + $loader.show(); + + delay(function(){ + // Convert markdown + $preview.html(convert($textarea.val())); + $loader.hide(); + }, 1500 ); + } else { + // Convert markdown + $preview.html(convert($textarea.val())); + }; + }).trigger('keyup'); + + $('input, textarea').keypress(function () { + // Unused: save status of the page as 'edited' + ProjectUtils.setProjectAttributes({isModified: true}); + // Set the beforeunload to warn the user of unsaved changes + $(window).on('beforeunload', function () { + return 'You have unsaved changes in your project.'; + }); + }); + }); + +| {% endblock %} + +| {% block footer_navigation %} +| {% endblock %} +| {% block footer %} +| {% endblock %} diff --git a/src/templates/projects/edit_node_type.jade b/src/templates/projects/edit_node_type.jade new file mode 100644 index 00000000..7469d05e --- /dev/null +++ b/src/templates/projects/edit_node_type.jade @@ -0,0 +1,88 @@ +| {% extends 'layout.html' %} + +| {% set title = 'edit_node_types' %} + +| {% block page_title %}Project {{ project.name }}{% endblock %} + +| {% block body %} +.container.box + form( + method='POST', + action="{{url_for('projects.edit_node_type', project_url=project.url, node_type_name=node_type['name'])}}") + + #blog_container.post-create + + | {% with errors = errors %} + | {% if errors %} + | {% for field in errors %} + .alert.alert-danger(role='alert') + strong {{field}} + | {% for message in errors[field] %} + | {{message}}| + | {% endfor %} + | {% endfor %} + | {% endif %} + | {% endwith %} + + #blog_index-sidebar + .blog_project-sidebar + input.btn.btn-default.button-create(type='submit', value="Update {{ node_type['name'] }}") + a.btn.btn-default.button-back(href="{{ url_for('projects.view', project_url=project.url) }}") + | Back to Project + + #blog_post-edit-container + #blog_post-edit-title + | Edit {{ node_type['name'] }} + + #blog_post-edit-form + | {% for field in form %} + | {% if field.name == 'csrf_token' %} + | {{ field }} + | {% else %} + | {% if field.type == 'HiddenField' %} + | {{ field }} + | {% else %} + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% endif %} + | {% endif %} + | {% endfor %} + +| {% endblock %} + +| {% block footer_scripts%} +script(src="https://cdn.jsdelivr.net/g/ace@1.2.3(noconflict/ace.js+noconflict/mode-json.js)") + +script. + var dynSchemaEditorContainer = $("
      ", {id: "dyn_schema_editor"}); + $(".form-group.dyn_schema").before(dynSchemaEditorContainer); + var dynSchemaEditor = ace.edit("dyn_schema_editor"); + dynSchemaEditor.getSession().setValue($("#dyn_schema").val()); + + var formSchemaEditorContainer = $("
      ", {id: "form_schema_editor"}); + $(".form-group.form_schema").before(formSchemaEditorContainer); + var formSchemaEditor = ace.edit("form_schema_editor"); + formSchemaEditor.getSession().setValue($("#form_schema").val()); + + var permissionsEditorContainer = $("
      ", {id: "permissions_editor"}); + $(".form-group.permissions").before(permissionsEditorContainer); + var permissionsEditor = ace.edit("permissions_editor"); + permissionsEditor.getSession().setValue($("#permissions").val()); + + $("form").submit(function(e) { + $("#dyn_schema").val(dynSchemaEditor.getSession().getValue()); + $("#form_schema").val(formSchemaEditor.getSession().getValue()); + $("#permissions").val(permissionsEditor.getSession().getValue()); + }); +| {% endblock %} + + diff --git a/src/templates/projects/edit_node_types.jade b/src/templates/projects/edit_node_types.jade new file mode 100644 index 00000000..dac4183a --- /dev/null +++ b/src/templates/projects/edit_node_types.jade @@ -0,0 +1,110 @@ +| {% extends 'layout.html' %} + +| {% set title = 'edit_node_types' %} + +| {% block page_title %}Node Types: {{ project.name }}{% endblock %} + +| {% block body %} +#project-container + #project-side-container + #project_sidebar + ul.project-tabs + li.tabs-thumbnail( + title="About", + data-toggle="tooltip", + data-placement="left", + class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}") + a(href="{{url_for('projects.about', project_url=project.url, _external=True)}}") + #project-loading + i.pi-spin + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('b', api=api) }}") + | {% else %} + i.pi-home + | {% endif %} + li.tabs-browse( + title="Browse", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + i.pi-tree-flow + | {% if not project.is_private %} + li.tabs-search( + title="Search", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}") + i.pi-search + | {% endif %} + + .project_nav-toggle-btn( + title="Expand Navigation [T]", + data-toggle="tooltip", + data-placement="right") + i.pi-angle-double-left + + #project_nav + #project_nav-container + #project_nav-header + .project-title + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + | {{ project.name }} + + // TODO - make list a macro + #project_tree + ul.project_nav-edit-list + li(class="{% if title == 'edit' %}active{% endif %}") + a(href="{{ url_for('projects.edit', project_url=project.url) }}") + i.pi-list + | Overview + li(class="{% if title == 'sharing' %}active{% endif %}") + a(href="{{ url_for('projects.sharing', project_url=project.url) }}") + i.pi-share + | Sharing + li(class="{% if title == 'edit_node_types' %}active{% endif %}") + a(href="{{ url_for('projects.edit_node_types', project_url=project.url) }}") + i.pi-puzzle + | Node Types + + .project_split(title="Toggle Navigation [T]") + + + #project_context-container + #project_context-header + span#project-statusbar + + span#project-edit-title + | Edit Project + + #project_context + #node-edit-container + div(id="node-edit-form") + h3 Node Types (coming soon) + p. + Nodes are all the items that can be found in a project. + Everything is a node: a file, a folder, a comment. They are + defined with custom properties and properly presented to you. + When we add support for new node types in the future, it means we + allow the creation of new items (such as textures). + + | {% if current_user.has_role('admin') %} + ul + | {% for node_type in project.node_types %} + li + a(href="{{ url_for('projects.edit_node_type', project_url=project.url, node_type_name=node_type.name) }}") + | {{node_type.name}} + | {% endfor %} + | {% endif %} + +| {% endblock %} + +| {% block footer_scripts %} +script(type="text/javascript"). + $(window).on("load resize",function(){ + containerResizeY($(window).height()); + }); +| {% endblock %} +| {% block footer_navigation %} +| {% endblock %} +| {% block footer %} +| {% endblock %} diff --git a/src/templates/projects/home_images.jade b/src/templates/projects/home_images.jade new file mode 100644 index 00000000..7399d796 --- /dev/null +++ b/src/templates/projects/home_images.jade @@ -0,0 +1,139 @@ +| {% extends 'projects/home_layout.html' %} +| {% set subtab = 'images' %} +| {% set learn_more_btn_url = '/blog/introducing-image-sharing' %} +| {% block currenttab %} +section.nav-tabs__tab.active#tab-images + .tab_header-container + | {% if not shared_images %} + .tab_header-intro( + style="background-image: url({{ url_for('static', filename='assets/img/backgrounds/pattern_01.jpg')}})") + .tab_header-intro_text + h2 Share what you see. + p. + Got a nice render, a Blender oddity, or a cool screenshot? +
      + Share it instantly from within Blender to the world! + .tab_header-intro_icons + i.pi-blender + i.pi-heart-filled + i.pi-picture-album + | {% endif %} + + | {% if shared_images %} + div#home-images__list + | {% for node in shared_images %} + div.home-images__list-item + .home-images__list-details + a.title(href="{{ url_for_node(node=node) }}?t") + | {{ node.name }} + | {% if node.picture %} + a.home-images__list-thumbnail( + href="{{ url_for_node(node=node) }}?t") + img(src="{{ node.picture.thumbnail('l', api=api) }}") + | {% endif %} + .home-images__list-details + ul.meta + li.when(title="{{ node._created }}") {{ node._created | pretty_date_time }} + li.delete-image + a.delete-prompt(href='javascript:void(0);') + | Delete + span.delete-confirm + | Are you sure? + a.delete-confirm(href='javascript:void(0);', + data-image-id="{{ node._id }}") + i.pi-check + | Yes, delete + a.delete-cancel(href='javascript:void(0);') + i.pi-cancel + | No, cancel + | {% if node.short_link %} + li + a(href="{{ node.short_link }}") {{ node.short_link }} + | {% endif %} + | {% endfor %} + | {% else %} + .blender_sync-main.empty + .blender_sync-main-header + span.blender_sync-main-title + | Share some images using the + a( + href="https://cloud.blender.org/r/downloads/blender_cloud-latest-bundle.zip") + | Blender Cloud add-on. + + | {% endif %} +| {% endblock %} + +| {% block side_announcement %} +.title + a(href="https://cloud.blender.org/blog/introducing-image-sharing") Image Sharing + +.lead + p. + Share your renders, painted textures, and other images, straight from Blender + to the cloud. + hr + | {% if show_addon_download_buttons %} + p. + Image Sharing requires a Blender Cloud subscription, which you have! + | {% else %} + p. + Image Sharing requires a Blender Cloud subscription. + + .buttons + a.btn.btn-default.btn-outline.green(href="https://store.blender.org/product/membership/") + | Join Now + | {% endif %} +| {% endblock %} + +| {% block footer_scripts %} +| {{ super() }} +script. + var urlNodeDelete = "{{url_for('projects.delete_node')}}"; + + $(document).ready(function() { + // 'Delete' link on images + var $home_image_list = $('#home-images__list'); + $home_image_list.find('a.delete-prompt').on('click', function(e){ + $(this) + .hide() + .next().show(); + }); + + // 'Cancel delete' link on images + $home_image_list.find('a.delete-cancel').on('click', function(e){ + $(this).parent() + .hide() + .prev().show(); + }); + + // 'Confirm delete' link on images + $home_image_list.find('a.delete-confirm').on('click', function (e) { + var image_id = this.dataset.imageId; + + var $this = $(this); + var parent = $this.closest('.home-images__list-item'); + console.log('My parent is', parent); + var error_elt = $this.parent(); + + $.ajax({ + type: 'POST', + url: urlNodeDelete, + data: {node_id: image_id}, + success: function () { + if (parent.siblings().length == 0) { + // This was the last shared image. Reload the page, + // so that we can show the correct "no images shared" + // content with Jinja2. + window.location = window.location; + } + parent.hide('slow', function() { parent.remove(); }); + }, + error: function (jqxhr, textStatus, errorThrown) { + error_elt.text('Unable to delete image; ' + textStatus + ': ' + errorThrown); + } + }); + }); + + hopToTop(); // Display jump to top button + }); +| {% endblock %} diff --git a/src/templates/projects/home_index.jade b/src/templates/projects/home_index.jade new file mode 100644 index 00000000..d4d59224 --- /dev/null +++ b/src/templates/projects/home_index.jade @@ -0,0 +1,43 @@ +| {% extends 'projects/home_layout.html' %} +| {% set subtab = 'blender_sync' %} +| {% set learn_more_btn_url = '/blog/introducing-blender-sync' %} +| {% block currenttab %} +section.nav-tabs__tab.active#tab-blender_sync + .tab_header-container + .tab_header-intro( + style="background-image: url({{ url_for('static', filename='assets/img/backgrounds/pattern_01.jpg')}})") + .tab_header-intro_text + h2 Connect Blender with the Cloud + p + | Save your Blender preferences once, load them anywhere. +
      + | Use the + =' ' + a(href='https://cloud.blender.org/r/downloads/blender_cloud-latest-bundle.zip') Blender Cloud add-on + =' ' + | to synchronise your settings from within Blender. + .tab_header-intro_icons + i.pi-blender + i.pi-heart-filled + i.pi-blender-cloud + + | {% for version in synced_versions %} + .blender_sync-main + .blender_sync-main-header + h2.blender_sync-main-title + i.pi-blender + | Blender {{ version.version }} + .blender_sync-main-last + | Last synced on: {{ version.date|pretty_date }} + | {% else %} + .blender_sync-main.empty + .blender_sync-main-header + span.blender_sync-main-title + | No settings synced yet +
      + a.download( + href='https://cloud.blender.org/r/downloads/blender_cloud-latest-bundle.zip') + | Download add-on + | {% endfor %} +| {% endblock %} + diff --git a/src/templates/projects/home_layout.jade b/src/templates/projects/home_layout.jade new file mode 100644 index 00000000..e6fa114a --- /dev/null +++ b/src/templates/projects/home_layout.jade @@ -0,0 +1,79 @@ +| {% extends 'layout.html' %} +| {% from '_macros/_navigation.html' import navigation_tabs %} + +| {% set title = 'home' %} + +| {% block og %} +meta(property="og:title", content="Blender Cloud - Home") +meta(property="og:url", content="https://cloud.blender.org{{ request.path }}") +meta(property="og:type", content="website") +| {% endblock %} + +| {% block tw %} +meta(name="twitter:card", content="summary_large_image") +meta(name="twitter:site", content="@Blender_Cloud") +meta(name="twitter:title", content="Blender Cloud") +meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/backgrounds/cloud_services_oti.jpg')}}") +| {% endblock %} + +| {% block page_title %} +| {{current_user.full_name}} +| {% endblock %} + +| {% block body %} +.dashboard-container + section#main + | {{ navigation_tabs(title) }} + + section#projects + + section#sub-nav-tabs.home + ul#sub-nav-tabs__list + li.nav-tabs__list-tab#subtab-blender_sync(data-tab-url='.') + i.pi-blender + | Blender Sync + + li.nav-tabs__list-tab#subtab-images(data-tab-url='images') + i.pi-picture + | Images + | {% block currenttab %}{% endblock %} + + section#side + section#announcement + img.header( + src="{{ url_for('static', filename='assets/img/blender_sync_header.jpg') }}") + .text + | {% block side_announcement %} + .title + a(href="https://cloud.blender.org/blog/introducing-blender-sync") Blender Sync + + .lead + span. + Save your settings once. Use them anywhere. + Carry your Blender configuration with you, use our free add-on to sync your keymaps and preferences. +
      + Syncing is free for everyone. No subscription required. + | {% endblock %} + | {% if show_addon_download_buttons %} + .buttons + a.btn.btn-default.btn-outline.orange( + href="https://cloud.blender.org/r/downloads/blender_cloud-latest-bundle.zip") + i.pi-download + | Download v{{ config.BLENDER_CLOUD_ADDON_VERSION }} + a.btn.btn-default.btn-outline.blue( + href="{{ learn_more_btn_url }}") + | Learn More + | {% endif %} +| {% endblock %} + + +| {% block footer_scripts %} +script. + $(document).ready(function () { + $('#subtab-{{ subtab }}').addClass('active'); + var $nav_tabs = $('#sub-nav-tabs__list').find('li.nav-tabs__list-tab'); + $nav_tabs.on('click', function (e) { + window.location = $(this).attr('data-tab-url'); + }); + }); +| {% endblock %} diff --git a/src/templates/projects/index_collection.jade b/src/templates/projects/index_collection.jade new file mode 100644 index 00000000..f07559d6 --- /dev/null +++ b/src/templates/projects/index_collection.jade @@ -0,0 +1,87 @@ +| {% extends 'layout.html' %} + +| {% block og %} +meta(property="og:title", content="{% if title == 'open-projects' %}Open Projects{% elif title == 'training' %}Training{% endif %}") +// XXX - Replace with actual url +meta(property="og:url", content="https://cloud.blender.org") +meta(property="og:type", content="website") +| {% endblock %} + +| {% block tw %} +meta(name="twitter:card", content="summary_large_image") +meta(name="twitter:site", content="@Blender_Cloud") +meta(name="twitter:title", content="{% if title == 'open-projects' %}Open Projects{% elif title == 'training' %}Training{% endif %} on Blender Cloud") +meta(name="twitter:description", content="{% if title == 'open-projects' %}Full production data and tutorials from all open movies, for you to use freely{% elif title == 'training' %}Production quality training by 3D professionals{% endif %}") +meta(name="twitter:image", content="{% if title == 'training' %}{{ url_for('static', filename='assets/img/backgrounds/background_caminandes_3_03.jpg')}}{% else %}{{ url_for('static', filename='assets/img/backgrounds/background_agent327_01.jpg')}}{% endif %}") +| {% endblock %} + +| {% block page_title %} +| {% if title == 'open-projects' %}Open Projects{% elif title == 'training' %}Training{% else %}Projects{% endif %} +| {% endblock %} + +| {% block body %} + +#project-container + + #node_index-container + #node_index-header.collection + img.background-header(src="{% if title == 'training' %}{{ url_for('static', filename='assets/img/backgrounds/background_caminandes_3_03.jpg')}}{% else %}{{ url_for('static', filename='assets/img/backgrounds/background_agent327_01.jpg')}}{% endif %}") + #node_index-collection-info + | {% if title == 'open-projects' %} + .node_index-collection-name + span Open Projects + .node_index-collection-description + span. + The iconic Blender Institute Open Movies. + Featuring all the production files, assets, artwork, and never-seen-before content. + | {% elif title == 'training' %} + .node_index-collection-name + span Training + .node_index-collection-description + span. + Character modeling, 3D printing, VFX, rigging and more. + | {% endif %} + + .node_index-collection + + | {% for project in projects %} + | {% if (project.status == 'published') or (project.status == 'pending' and current_user.is_authenticated) and project._id != config.MAIN_PROJECT_ID %} + + .node_index-collection-card.project( + data-url="{{ url_for('projects.view', project_url=project.url) }}", + tabindex="{{ loop.index }}") + | {% if project.picture_header %} + a.item-header( + href="{{ url_for('projects.view', project_url=project.url) }}") + img(src="{{ project.picture_header.thumbnail('m', api=api) }}") + | {% endif %} + + .item-info + a.item-title( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {{project.name}} + | {% if project.status == 'pending' and current_user.is_authenticated and current_user.has_role('admin') %} + small (pending) + | {% endif %} + + | {% if project.summary %} + p.item-description + | {{project.summary|safe}} + | {% endif %} + + a.learn-more LEARN MORE + + | {% endif %} + | {% endfor %} + + +| {% endblock %} + + +| {% block footer_scripts %} +script. + $('.node_index-collection-card.project').on('click', function(e){ + e.preventDefault(); + window.location.href = $(this).data('url'); + }); +| {% endblock %} diff --git a/src/templates/projects/index_dashboard.jade b/src/templates/projects/index_dashboard.jade new file mode 100644 index 00000000..7df2951f --- /dev/null +++ b/src/templates/projects/index_dashboard.jade @@ -0,0 +1,232 @@ +| {% extends 'layout.html' %} +| {% from '_macros/_navigation.html' import navigation_tabs %} + +| {% set title = 'dashboard' %} + +| {% block og %} +meta(property="og:title", content="Dashboard") +meta(property="og:url", content="https://cloud.blender.org/{{ request.path }}") +meta(property="og:type", content="website") +| {% endblock %} + +| {% block tw %} +meta(name="twitter:card", content="summary_large_image") +meta(name="twitter:site", content="@Blender_Cloud") +meta(name="twitter:title", content="Blender Cloud") +meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/backgrounds/cloud_services_oti.jpg')}}") +| {% endblock %} + +| {% block page_title %} +| {{current_user.full_name}} +| {% endblock %} + +| {% block body %} +.dashboard-container + section#main + | {{ navigation_tabs(title) }} + + section#projects + + section#sub-nav-tabs.projects + ul#sub-nav-tabs__list + li.nav-tabs__list-tab.active(data-tab-toggle='own_projects') + | Own Projects + | {% if projects_user|length != 0 %} + span ({{ projects_user|length }}) + | {% endif %} + + li.nav-tabs__list-tab(data-tab-toggle='shared') + | Shared with me + | {% if projects_shared|length != 0 %} + span ({{ projects_shared|length }}) + | {% endif %} + + | {% if (current_user.has_role('subscriber') or current_user.has_role('admin')) %} + li.create( + data-url="{{ url_for('projects.create') }}") + a#project-create( + href="{{ url_for('projects.create') }}") + i.pi-plus + | Create Project + | {% endif %} + + section.nav-tabs__tab.active#own_projects + ul.projects__list + | {% if projects_user %} + | {% for project in projects_user %} + li.projects__list-item( + data-url="{{ url_for('projects.view', project_url=project.url) }}") + a.projects__list-thumbnail( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('s', api=api) }}") + | {% else %} + i.pi-blender-cloud + | {% endif %} + .projects__list-details + a.title(href="{{ url_for('projects.view', project_url=project.url) }}") + | {{ project.name }} + + ul.meta + li.when(title="{{ project._created }}") {{ project._created | pretty_date }} + li.edit + a(href="{{ url_for('projects.edit', project_url=project.url) }}") Edit + | {% if project.status == 'pending' and current_user.is_authenticated and current_user.has_role('admin') %} + li.pending Not Published + | {% endif %} + + | {% endfor %} + | {% else %} + li.projects__list-item + a.projects__list-thumbnail + i.pi-plus + .projects__list-details + a.title(href="{{ url_for('projects.create') }}") + | Create a project to get started! + | {% endif %} + + section.nav-tabs__tab#shared + ul.projects__list + | {% if projects_shared %} + | {% for project in projects_shared %} + li.projects__list-item( + data-url="{{ url_for('projects.view', project_url=project.url) }}") + a.projects__list-thumbnail( + href="{{ url_for('projects.view', project_url=project.url) }}") + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('s', api=api) }}") + | {% else %} + i.pi-blender-cloud + | {% endif %} + .projects__list-details + a.title(href="{{ url_for('projects.view', project_url=project.url) }}") + | {{ project.name }} + + ul.meta + li.when {{ project._created | pretty_date }} + li.who by {{ project.user.full_name }} + li.edit + a(href="{{ url_for('projects.edit', project_url=project.url) }}") Edit + | {% if project.status == 'pending' and current_user.is_authenticated and current_user.has_role('admin') %} + li.pending Not Published + | {% endif %} + + li.leave + span.user-remove-prompt + | Leave Project + + span.user-remove + | Are you sure? + span.user-remove-confirm( + user-id="{{ current_user.objectid }}", + project-url="{{url_for('projects.sharing', project_url=project.url)}}") + i.pi-check + | Yes, leave + span.user-remove-cancel + i.pi-cancel + | No, cancel + + | {% endfor %} + | {% else %} + li.projects__list-item + a.projects__list-thumbnail + i.pi-heart + .projects__list-details + .title + | No projects shared with you... yet! + | {% endif %} + + section#side + section#announcement + img.header( + src="{{ url_for('static', filename='assets/img/backgrounds/services_projects.jpg')}}") + .text + .title Projects + .lead + span. + Create and manage your own personal projects. + Upload assets and collaborate with other Blender Cloud members. + .buttons + a.btn.btn-default.btn-outline.blue( + href="https://cloud.blender.org/blog/introducing-private-projects") + | Learn More + +| {% endblock %} + + +| {% block footer_scripts %} +script. + $(document).ready(function() { + + $('li.projects__list-item').click(function(e){ + url = $(this).data('url'); + if (typeof url === 'undefined') return; + + window.location.href = url; + if (console) console.log(url); + + $(this).addClass('active'); + $(this).find('.projects__list-thumbnail i') + .removeAttr('class') + .addClass('pi-spin spin'); + }); + + // Tabs behavior + var $nav_tabs_list = $('#sub-nav-tabs__list'); + var $nav_tabs = $nav_tabs_list.find('li.nav-tabs__list-tab'); + $nav_tabs.on('click', function(e){ + e.preventDefault(); + + $nav_tabs.removeClass('active'); + $(this).addClass('active'); + + $('.nav-tabs__tab').hide(); + $('#' + $(this).attr('data-tab-toggle')).show(); + }); + + // Create project + $nav_tabs_list.find('li.create').on('click', function(e){ + e.preventDefault(); + + $(this).addClass('disabled'); + $('a', this).html(' Creating project...'); + + window.location.href = $(this).data('url'); + }); + + // Leave project + var $projects_list = $('ul.projects__list'); + $projects_list.find('span.user-remove-prompt').on('click', function(e){ + e.stopPropagation(); + e.preventDefault(); + + $(this).next().show(); + $(this).hide(); + }); + + $projects_list.find('span.user-remove-cancel').on('click', function(e){ + e.stopPropagation(); + e.preventDefault(); + + $(this).parent().prev().show(); + $(this).parent().hide(); + }); + + $projects_list.find('span.user-remove-confirm').on('click', function(e){ + e.stopPropagation(); + e.preventDefault(); + var parent = $(this).closest('projects__list-item'); + + function removeUser(userId, projectUrl){ + $.post(projectUrl, {user_id: userId, action: 'remove'}) + .done(function (data) { + parent.remove(); + }); + } + + removeUser($(this).attr('user-id'), $(this).attr('project-url')); + }); + + hopToTop(); // Display jump to top button + }); +| {% endblock %} diff --git a/src/templates/projects/sharing.jade b/src/templates/projects/sharing.jade new file mode 100644 index 00000000..9e0bd175 --- /dev/null +++ b/src/templates/projects/sharing.jade @@ -0,0 +1,266 @@ +| {% extends 'layout.html' %} + +| {% set title = 'sharing' %} + +| {% block page_title %}Sharing: {{ project.name }}{% endblock %} + +| {% block body %} +#project-container + #project-side-container + #project_sidebar + ul.project-tabs + li.tabs-thumbnail( + title="About", + data-toggle="tooltip", + data-placement="left", + class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}") + a(href="{{url_for('projects.about', project_url=project.url, _external=True)}}") + #project-loading + i.pi-spin + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('b', api=api) }}") + | {% else %} + i.pi-home + | {% endif %} + li.tabs-browse( + title="Browse", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + i.pi-tree-flow + | {% if not project.is_private %} + li.tabs-search( + title="Search", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}") + i.pi-search + | {% endif %} + + .project_nav-toggle-btn( + title="Expand Navigation [T]", + data-toggle="tooltip", + data-placement="right") + i.pi-angle-double-left + + #project_nav + #project_nav-container + #project_nav-header + .project-title + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + | {{ project.name }} + + // TODO - make list a macro + #project_tree + ul.project_nav-edit-list + li(class="{% if title == 'edit' %}active{% endif %}") + a(href="{{ url_for('projects.edit', project_url=project.url) }}") + i.pi-list + | Overview + li(class="{% if title == 'sharing' %}active{% endif %}") + a(href="{{ url_for('projects.sharing', project_url=project.url) }}") + i.pi-share + | Sharing + li(class="{% if title == 'edit_node_types' %}active{% endif %}") + a(href="{{ url_for('projects.edit_node_types', project_url=project.url) }}") + i.pi-puzzle + | Node Types + + .project_split(title="Toggle Navigation [T]") + + #project_context-container + #project_context-header + span#project-statusbar + + span#project-edit-title + | Manage users for this project + + #project_context + #node-edit-container + #node-edit-form + .col-md-6 + | {% if (project.user == current_user.objectid or current_user.has_role('admin')) %} + .sharing-users-search + .form-group + input#user-select.form-control( + name='contacts', + type='text', + placeholder='Add users by name') + | {% else %} + .sharing-users-search + .disabled Only project owners can manage users + | {% endif %} + + + ul.sharing-users-list + | {% for user in users %} + li.sharing-users-item( + user-id="{{ user['_id'] }}", + class="{% if current_user.objectid == user['_id'] %}self{% endif %}") + .sharing-users-avatar + img(src="{{ user['avatar'] }}") + .sharing-users-details + span.sharing-users-name + | {{user['full_name']}} + | {% if project.user == user['_id'] and current_user.objectid == user['_id'] %} + small (You, owner) + | {% elif project.user == user['_id'] %} + small (Owner) + | {% elif current_user.objectid == user['_id'] %} + small (You) + | {% endif %} + span.sharing-users-extra {{user['username']}} + .sharing-users-action + | {# Only allow deletion if we are: admin, project owners, or current_user in the team #} + | {% if current_user.has_role('admin') or (project.user == current_user.objectid) or (current_user.objectid == user['_id']) %} + + | {% if project.user == user['_id'] %} + span + i.pi-happy(title="Hi boss!") + | {% elif current_user.objectid == user['_id'] %} + button.user-remove(title="Leave this project") Leave + | {% else %} + button.user-remove(title="Remove this user from your project") + i.pi-trash + | {% endif %} + + | {% endif %} + | {% endfor %} + + .col-md-6 + .sharing-users-info + h4 What can team members do? + p. + Team members are able to upload new content to the + project; as well as view, edit, and comment on the content previously created. + +| {% endblock %} + +| {% block footer_navigation %} +| {% endblock %} + +| {% block footer_scripts %} +script(type="text/javascript"). + $(window).on("load resize",function(){ + containerResizeY($(window).height()); + }); +| {% if (project.user == current_user.objectid or current_user.has_role('admin')) %} +script(src='//cdn.jsdelivr.net/autocomplete.js/0/autocomplete.jquery.min.js') +script. + $(document).ready(function() { + var APPLICATION_ID = '{{config.ALGOLIA_USER}}' + var SEARCH_ONLY_API_KEY = '{{config.ALGOLIA_PUBLIC_KEY}}'; + var INDEX_NAME = '{{config.ALGOLIA_INDEX_USERS}}'; + var client = algoliasearch(APPLICATION_ID, SEARCH_ONLY_API_KEY); + var index = client.initIndex(INDEX_NAME); + + $('#user-select').autocomplete({hint: false}, [ + { + source: function (q, cb) { + index.search(q, {hitsPerPage: 5}, function (error, content) { + if (error) { + cb([]); + return; + } + cb(content.hits, content); + }); + }, + displayKey: 'full_name', + minLength: 2, + limit: 10, + templates: { + suggestion: function (hit) { + return hit._highlightResult.full_name.value + ' (' + hit._highlightResult.username.value + ')'; + } + } + } + ]).on('autocomplete:selected', function (event, hit, dataset) { + + var lis = document.getElementsByClassName('sharing-users-item'); + var has_match = false; + + for (var i = 0; i < lis.length; ++i) { + + // Check if the user already is in the list + if ($(lis[i]).attr('user-id') == hit.objectID){ + + $(lis[i]).addClass('active'); + setTimeout(function(){ $('.sharing-users-item').removeClass('active');}, 350); + statusBarSet('info', 'User is already part of the project', 'pi-info'); + + has_match = false; + break; + } else { + has_match = true; + continue; + } + }; + + if (has_match){ + addUser(hit.objectID); + } + + }); + + + function addUser(userId){ + if (userId && userId.length > 0) { + $.post("{{url_for('projects.sharing', project_url=project.url)}}", + {user_id: userId, action: 'add'}) + .done(function (data) { + + $("ul.sharing-users-list").prepend('' + + ''); + + $("ul.sharing-users-list").find("[user-id='" + userId + "']").addClass('added'); + setTimeout(function(){ $('.sharing-users-item').removeClass('added');}, 350); + statusBarSet('success', 'User added to this project!', 'pi-grin'); + }) + .fail(function (jsxhr){ + data = jsxhr.responseJSON; + statusBarSet('error', 'Could not add user (' + data.message + ')', 'pi-warning'); + }); + } else { + statusBarSet('error', 'Please select a user from the list', 'pi-warning'); + } + }; + + + + }); + +| {% endif %} +script. + $(document).ready(function() { + $('body').on('click', '.user-remove', function(e) { + var userId = $(this).parent().parent().attr('user-id'); + removeUser(userId); + }); + + function removeUser(userId){ + $.post("{{url_for('projects.sharing', project_url=project.url)}}", + {user_id: userId, action: 'remove'}) + .done(function (data) { + $("ul.sharing-users-list").find("[user-id='" + userId + "']").remove(); + statusBarSet('success', 'User removed from this project', 'pi-trash'); + }) + .fail(function (data){ + statusBarSet('error', 'Could not remove user (' + data._status + ')', 'pi-warning'); + }); + } + }); + +| {% endblock %} diff --git a/src/templates/projects/view.jade b/src/templates/projects/view.jade new file mode 100644 index 00000000..e070b019 --- /dev/null +++ b/src/templates/projects/view.jade @@ -0,0 +1,586 @@ +| {% extends 'layout.html' %} +| {% from '_macros/_add_new_menu.html' import add_new_menu %} + +| {% block page_title %}{{project.name}}{% endblock%} + +| {% block og %} +meta(property="og:type", content="website") +| {% if og_picture %} +meta(property="og:image", content="{{ og_picture.thumbnail('l', api=api) }}") +| {% endif %} +| {% if show_project %} +meta(property="og:title", content="{{project.name}} - Blender Cloud") +meta(property="og:url", content="{{url_for('projects.view', project_url=project.url, _external=True)}}") +meta(property="og:description", content="{{project.summary}}") +| {% else %} +meta(property="og:title", content="{{node.name}} - Blender Cloud") +meta(property="og:url", content="{{url_for('projects.view_node', project_url=project.url, node_id=node._id)}}") +meta(property="og:description", content="{{node.description}}") +| {% endif %} +| {% endblock %} + +| {% block tw %} +| {% if og_picture %} +meta(property="twitter:image", content="{{ og_picture.thumbnail('l', api=api) }}") +| {% endif %} +| {% if show_project %} +meta(name="twitter:title", content="{{project.name}} on Blender Cloud") +meta(name="twitter:description", content="{{project.summary}}") +| {% else %} +meta(name="twitter:title", content="{{node.name}} on Blender Cloud") +meta(name="twitter:description", content="{{node.description}}") +| {% endif %} +| {% endblock %} + +| {% block head %} +link(href="//cdnjs.cloudflare.com/ajax/libs/jstree/3.3.1/themes/default/style.min.css", rel="stylesheet") +| {% endblock %} + +| {% block css %} +link(href="{{ url_for('static_pillar', filename='assets/css/project-main.css', v=040820161) }}", rel="stylesheet") +| {% endblock %} + +| {% block body %} +#project-container + #project-side-container + #project_sidebar + ul.project-tabs + li.tabs-thumbnail( + title="About", + data-toggle="tooltip", + data-placement="left", + class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}") + a(href="{{url_for('projects.about', project_url=project.url, _external=True)}}") + #project-loading + i.pi-spin + | {% if project.picture_square %} + img(src="{{ project.picture_square.thumbnail('b', api=api) }}") + | {% else %} + i.pi-home + | {% endif %} + li.tabs-browse( + title="Browse", + data-toggle="tooltip", + data-placement="left", + class="{% if title != 'about' %}active{% endif %}") + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + i.pi-tree-flow + | {% if not project.is_private %} + li.tabs-search( + title="Search", + data-toggle="tooltip", + data-placement="left") + a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}") + i.pi-search + | {% endif %} + + .project_nav-toggle-btn( + title="Expand Navigation [T]", + data-toggle="tooltip", + data-placement="right") + i.pi-angle-double-left + + #project_nav(class="{{ title }}") + #project_nav-container + | {% if title != 'about' %} + #project_nav-header + .project-title + a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}") + | {{ project.name }} + + #project_tree + | {% endif %} + + .project_split(title="Toggle Navigation [T]") + + + #project_context-container + | {% if project.has_method('PUT') %} + #project_context-header + span#project-statusbar + + ul.project-edit-tools.disabled + li.button-dropdown + a#item_add.dropdown-toggle.project-mode-view( + type="button", + data-toggle="dropdown", + aria-haspopup="true", + aria-expanded="false") + i.button-add-icon.pi-collection-plus + | New... + + ul.dropdown-menu.add_new-menu + | {{ add_new_menu(project.node_types) }} + + li.button-edit + a#item_edit.project-mode-view( + href="javascript:void(0);", + title="Edit", + data-project_id="{{project._id}}") + i.button-edit-icon.pi-edit + | Edit Project + + li.button-dropdown + a.dropdown-toggle.project-mode-view( + type="button", + data-toggle="dropdown", + aria-haspopup="true", + aria-expanded="false") + i.pi-more-vertical + + ul.dropdown-menu + | {% if current_user.has_role('admin') %} + li.button-featured + a#item_featured( + href="javascript:void(0);", + title="Feature on project's homepage", + data-toggle="tooltip", + data-placement="left") + i.button-featured-icon.pi-star + | Toggle Featured + + li.button-toggle-public + a#item_toggle_public( + href="javascript:void(0);", + title="Toggle public", + data-toggle="tooltip", + data-placement="left") + i.pi-lock-open + | Toggle public + | {% endif %} + + li.button-toggle-projheader + a#item_toggle_projheader( + href="javascript:void(0);", + title="Feature as project's header", + data-toggle="tooltip", + data-placement="left") + i.button-featured-icon.pi-star + | Toggle Project Header video + + li.button-move + a#item_move( + href="javascript:void(0);", + title="Move into a folder...", + data-toggle="tooltip", + data-placement="left") + i.button-move-icon.pi-move + | Move + + li.button-delete + a#item_delete( + href="javascript:void(0);", + title="Delete", + data-toggle="tooltip", + data-placement="left") + i.pi-trash + | Delete Project + + // Edit Mode + li.button-cancel + a#item_cancel.project-mode-edit( + href="javascript:void(0);", + title="Cancel changes") + i.button-cancel-icon.pi-cancel + | Cancel + + li.button-save + a#item_save.project-mode-edit( + href="javascript:void(0);", + title="Save changes") + i.button-save-icon.pi-check + | Save Changes + + | {% endif %} + + #project_context + | {% if show_project %} + | {% include "projects/view_embed.html" %} + | {% endif %} + + #overlay-mode-move-container + .overlay-container + .title + i.pi-angle-left + | Select the folder where you want to move it + .buttons + button#item_move_accept.move.disabled + | Select a Folder + button#item_move_cancel.cancel + i.pi-cancel + | Cancel + +| {% endblock %} + +| {% block footer_navigation %}{% endblock %} +| {% block footer %}{% endblock %} + +| {% block footer_scripts %} +script(src="//cdnjs.cloudflare.com/ajax/libs/jstree/3.3.1/jstree.min.js") +script(src="//releases.flowplayer.org/6.0.5/flowplayer.min.js") + +| {% if project.has_method('PUT') %} +| {# JS containing the Edit, Add, Featured, and Move functions #} +script(type="text/javascript", src="{{ url_for('static_pillar', filename='assets/js/project-edit.min.js', v=190520161) }}") +| {% endif %} + +script. + {% if show_project %} + ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: true, nodeId: ''}); + {% else %} + ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: false, nodeId: '{{node._id}}'}); + {% endif %} + + var projectTree = document.getElementById('project_tree'); + + /* Initialize project_tree scrollbar */ + if ((typeof Ps !== 'undefined') && projectTree && window.innerWidth > 768){ + Ps.initialize(projectTree, {suppressScrollX: true}); + } + + var urlNodeMove = "{{url_for('projects.move_node')}}"; + var urlNodeFeature = "{{url_for('projects.add_featured_node')}}"; + var urlNodeDelete = "{{url_for('projects.delete_node')}}"; + var urlNodeTogglePublic = "{{url_for('projects.toggle_node_public')}}"; + var urlNodeToggleProjHeader = "{{url_for('projects.toggle_node_project_header')}}"; + var urlProjectDelete = "{{url_for('projects.delete')}}"; + var urlProjectEdit = "{{url_for('projects.edit', project_url=project.url)}}"; + + function updateToggleProjHeaderMenuItem() { + var $toggle_projheader = $('#item_toggle_projheader'); + + if (ProjectUtils.isProject()) { + $toggle_projheader.hide(); + return; + } + if (ProjectUtils.nodeType() == 'asset') { + $toggle_projheader.show(); + } else { + $toggle_projheader.hide(); + } + } + $(updateToggleProjHeaderMenuItem); + + // Function to update the interface on loadNodeContent, and edit/saving assets + function updateUi(nodeId, mode){ + + if (mode === 'view') { + $('.project-mode-view').show(); + $('.project-mode-edit').hide(); + + $("#node-edit-form").unbind( "submit" ); + $("#item_save").unbind( "click" ); + $("#item_cancel").unbind( "click" ); + } else if (mode === 'edit') { + $('.project-mode-view').hide(); + $('.project-mode-edit').show(); + } else { + if (console) console.log('Invalid mode:', mode); + } + + // Prevent flicker by scrolling to top + $("#project_context-container").scrollTop(0); + + // Enable specific items under the Add New dropdown + if (ProjectUtils.nodeType() === 'group') { + addMenuEnable(['asset', 'group']); + + } else if (ProjectUtils.nodeType() === 'group_texture') { + addMenuEnable(['group_texture', 'texture']); + + } else if (ProjectUtils.nodeType() === 'group_hdri') { + addMenuEnable(['group_hdri', 'hdri']); + + } else if (!ProjectUtils.isProject()) { + addMenuEnable(false); + } + + updateToggleProjHeaderMenuItem(); + + var nodeTitle = document.getElementById('node-title'); + var nodeTitleText = $(nodeTitle).text() + " - {{project.name}} - Blender Cloud"; + + document.title = nodeTitleText; + + // TODO: Maybe remove this, now it's also in loadNodeContent(), but double-check + // it's done like that in all users of updateUi(). + $('#project-loading').removeAttr('class'); + } + + + function loadNodeContent(url, nodeId) { + $('#project-loading').addClass('active'); + + $.get(url, function(dataHtml) { + // Update the DOM injecting the generate HTML into the page + $('#project_context').html(dataHtml); + }) + .done(function(){ + updateUi(nodeId, 'view'); + }) + .fail(function(dataResponse) { + $('#project_context').html($('') + }); + +| {% endblock %} diff --git a/src/templates/stats.jade b/src/templates/stats.jade new file mode 100644 index 00000000..2ae14414 --- /dev/null +++ b/src/templates/stats.jade @@ -0,0 +1,120 @@ +| {% extends 'layout.html' %} +| {% block page_title %}Stats{% endblock %} + +| {% block body %} +.container + #stats-container.page-content + .row + .col-md-6 + .box + span.stats__graph-title.income + span.stats__graph-title-amount + small $ + | 20307 + span.stats__graph-title-label Monthly Income + + #site-stats.stats__graph + + .col-md-6 + .box + span.stats__graph-title subscribers + span.stats__graph-title-amount 1807 + span.stats__graph-title-label Active Subscribers + + #site-stats2.stats__graph + + hr + + .row + .col-md-12 + .box + .row + .col-md-6.text-left + p. + The Blender Cloud is our Open Production platform - a hub for creating and sharing open content and training online. The Blender Institute projects - developers and artists who work on compelling technical creative targets - are made possible thanks to the support of subscribers to the Cloud. + + p. + We created this page to share with you the numbers that make the Cloud. + + p. + Thank you very much for your support. +
      + The Blender Institute team + + .col-md-6 stats__join + a(href="https://cloud.blender.org/join") + h3. + Get a subscription + + h3. + Now only $10 per month + + .btn.btn-default + | Join the Cloud + + hr + + .row.stats__data + .col-md-12 + + .row + + .col-md-3 stats__data-type_money + .box + h2. + $ 20307 + + h3. + Monthly Income + + i.fa.fa-money backicon +
      +
      + + .col-md-2 stats__data-type_money + .box + i.fa.fa-users backicon + h2. + 1807 + + h3. + Active Subscribers + +
      +
      + + .col-md-2 stats__data-type_quantity + .box + i.fa.fa-film backicon + h2. + 245 + + h3. + Hours of Video + +
      +
      + + .col-md-2 stats__data-type_quantity + .box + i.fa.fa-database backicon + h2. + 94 + + h3. + Gigabytes of Data + +
      + + + .col-md-3 stats__data-type_quantity + .box + i.fa.fa-cloud-download backicon + h2. + 3641 + + h3. + Downloadable Assets + + +| {% endblock %} diff --git a/src/templates/upload.jade b/src/templates/upload.jade new file mode 100644 index 00000000..7fd2e517 --- /dev/null +++ b/src/templates/upload.jade @@ -0,0 +1,25 @@ +| {% extends 'layout.html' %} + +| {% block head %} +| {{ super() }} + +// blueimp Gallery styles +link(rel="stylesheet", href="{{ url_for('static_pillar', filename='assets/css/blueimp/blueimp-gallery.min.css') }}") + +// CSS to style the file input field as button and adjust the Bootstrap progress bars +link(rel="stylesheet", href="{{ url_for('static_pillar', filename='jquery-file-upload/css/jquery.fileupload.css') }}") +link(rel="stylesheet", href="{{ url_for('static_pillar', filename='jquery-file-upload/css/jquery.fileupload-ui.css') }}") + +| {% endblock %} + +| {% block body %} +.container + #project-container(style="background-color:white;padding:20px") + + | {% include '_macros/_file_uploader_form.html' %} + +| {% endblock %} + +| {% block footer_scripts %} +| {% include '_macros/_file_uploader_javascript.html' %} +| {% endblock %} diff --git a/src/templates/upload_embed.jade b/src/templates/upload_embed.jade new file mode 100644 index 00000000..bda188b0 --- /dev/null +++ b/src/templates/upload_embed.jade @@ -0,0 +1,4 @@ + +#node-add-container + | {% include '_macros/_file_uploader_form.html' %} + | {% include '_macros/_file_uploader_javascript.html' %} diff --git a/src/templates/users/edit_embed.jade b/src/templates/users/edit_embed.jade new file mode 100644 index 00000000..35073da0 --- /dev/null +++ b/src/templates/users/edit_embed.jade @@ -0,0 +1,78 @@ +| {% block body %} + +#user-edit-container + + #user-edit-header + .user-edit-name {{user.full_name}} + .user-edit-username {{user.username}} + .user-edit-email {{user.email}} + + form( + id="user-edit-form", + method="POST", + enctype="multipart/form-data", + action="{{url_for('users.users_edit', user_id=user._id)}}") + + | {% for field in form %} + + | {% if field.name == 'csrf_token' %} + | {{ field }} + + | {% else %} + + | {% if field.type == 'HiddenField' %} + | {{ field }} + + | {% else %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + + | {% endif %} + + | {% endif %} + + | {% endfor %} + + + a#button-cancel.btn.btn-default(href="#", data-user-id='{{user._id}}') Cancel + + input#submit_edit_user.btn.btn-default( + data-user-id="{{user._id}}", + type="submit" value="Submit") + + #user-edit-notification + + +script(type="text/javascript"). + $('#roles').select2(); + + $('#user-edit-form').submit(function(e){ + e.preventDefault(); + //- console.log($(this).serialize()); + $.post($(this).attr('action'), $(this).serialize()) + .done(function(data){ + $('#user-edit-notification').addClass('success').html('Success!'); + }) + .fail(function(data){ + $('#user-edit-notification').addClass('fail').html('Houston!'); + }); + //- $("#user-edit-form").submit(); + }); + + $('#button-cancel').click(function(e){ + $('#user-container').html('') + }); + + + +| {% endblock %} diff --git a/src/templates/users/index.jade b/src/templates/users/index.jade new file mode 100644 index 00000000..317eb8bd --- /dev/null +++ b/src/templates/users/index.jade @@ -0,0 +1,120 @@ +| {% extends 'layout.html' %} +| {% block page_title %}Users{% endblock %} + +| {% block body %} + +#search-container + #search-sidebar + input.search-field( + type="text", + name="q", + id="q", + autocomplete="off", + spellcheck="false", + autocorrect="false", + placeholder="Search by Full Name, Username...") + + .search-list-filters + #accordion.panel-group.accordion(role="tablist", aria-multiselectable="true") + #facets + + #pagination + + .search-list-stats + #stats + + #search-list + #hits + + #search-details + #search-hit-container + + +| {% raw %} +// Facet template +script(type="text/template", id="facet-template") + .panel.panel-default + a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}') + .panel-heading(role='tab') + .panel-title {{ title }} + .panel-collapse.collapse.in(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne') + .panel-body + | {{#values}} + a.facet_link.toggleRefine( + class='{{#refined}}refined{{/refined}}', + data-facet='{{ facet }}', + data-value='{{ value }}', + href='#') + span + | {{ label }} + small.facet_count.text-muted.pull-right {{ count }} + | {{/values}} + + +// Hit template +script(type="text/template", id="hit-template") + .search-hit.users(data-user-id='{{ objectID }}') + .search-hit-name + | {{{ _highlightResult.full_name.value }}} + small ({{{ username }}}) + .search-hit-roles + | {{{ roles }}} + + +// Pagination template +script(type="text/template", id="pagination-template") + ul.search-pagination. +
    • + {{#pages}} +
    • {{ number }}
    • + {{/pages}} +
    • + +// Stats template +script(type="text/template", id="stats-template") + h5 {{ nbHits }} result{{#nbHits_plural}}s{{/nbHits_plural}} + span ({{ processingTimeMS }}ms) +| {% endraw %} + +| {% endblock %} + +| {% block footer_scripts %} +script(). + var APPLICATION_ID = '{{config.ALGOLIA_USER}}'; + var SEARCH_ONLY_API_KEY = '{{config.ALGOLIA_PUBLIC_KEY}}'; + var INDEX_NAME = '{{config.ALGOLIA_INDEX_USERS}}'; + var sortByCountDesc = null; + var FACET_CONFIG = [ + { name: 'roles', title: 'Roles', disjunctive: false, sortFunction: sortByCountDesc }, + ]; + +script(src="//cdn.jsdelivr.net/algoliasearch/3/algoliasearch.min.js") +script(src="//cdn.jsdelivr.net/algoliasearch.helper/2/algoliasearch.helper.min.js") +script(src="//cdn.jsdelivr.net/hogan.js/3.0.0/hogan.common.js") +script(src="{{ url_for('static_pillar', filename='assets/js/algolia_search.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.select2.min.js') }}") + +script(type="text/javascript"). + + if (typeof Ps !== 'undefined'){ + Ps.initialize(document.getElementById('hits'), {suppressScrollX: true}); + } + + function displayUser(userId) { + var url = '/u/' + userId + '/edit?embed=1'; + $.get(url, function(dataHtml){ + $('#search-hit-container').html(dataHtml); + }); + } + + $('body').on('click', '.search-hit', function(){ + displayUser($(this).data('user-id')); + }); + + // Remove focus from search input so that the click event bound to .user-hit + // can be fired on the first click. + $('#search-list').hover(function(){ + $('#q').blur(); + }); + +| {% endblock %} diff --git a/src/templates/users/login.jade b/src/templates/users/login.jade new file mode 100644 index 00000000..39444109 --- /dev/null +++ b/src/templates/users/login.jade @@ -0,0 +1,45 @@ +| {% extends 'layout.html' %} + +| {% block body %} +.container + #login-container + .login-title Welcome back! + + .login-info + | Log in using your shared username and password. + + .login-form + form#login-form(method="POST", action="{{url_for('users.login_local')}}") + .form-group + | {{ form.username.label }} + | {{ form.username(class='form-control') }} + + .form-group + | {{ form.password.label }} + | {{ form.password(class='form-control') }} + + .buttons + .login-button-container + //a.forgot(href="https://blender.org/id/reset") forgot your password? + button.btn.btn-default.button-login(type="submit") + i.pi-log-in + | Login + + //a.btn.btn-default.button-register(href="https://blender.org/id/register", target="_blank") + // i.pi-star-outline + // | Create Account + + +| {% endblock %} + +| {% block footer_scripts %} +script. + $('.button-login').on('click', function(e){ + e.preventDefault(); + $(this).html(' Hold on...'); + + $('#login-form').submit(); + }); +| {% endblock %} + +| {% block footer_container %}{% endblock %} diff --git a/src/templates/users/settings/_sidebar.jade b/src/templates/users/settings/_sidebar.jade new file mode 100644 index 00000000..0caf2b1b --- /dev/null +++ b/src/templates/users/settings/_sidebar.jade @@ -0,0 +1,20 @@ +#settings-sidebar + .settings-header + .settings-title Settings + .settings-content + ul + a(class="{% if title == 'profile' %}active{% endif %}", + href="{{ url_for('users.settings_profile') }}") + li + i.pi-vcard + | Profile + a(class="{% if title == 'emails' %}active{% endif %}", + href="{{ url_for('users.settings_emails') }}") + li + i.pi-email + | Emails + a(class="{% if title == 'billing' %}active{% endif %}", + href="{{ url_for('users.settings_billing') }}") + li + i.pi-credit-card + | Subscription diff --git a/src/templates/users/settings/billing.jade b/src/templates/users/settings/billing.jade new file mode 100644 index 00000000..397688bc --- /dev/null +++ b/src/templates/users/settings/billing.jade @@ -0,0 +1,55 @@ +| {% extends 'layout.html' %} +| {% block body %} +.container + #settings + include _sidebar + #settings-container + .settings-header + .settings-title Subscription + + .settings-content + + | {% if store_user['cloud_access'] %} + h3.subscription-active + i.pi-check + | Your subscription is active + h4 Thank you for supporting us! + + hr + + p Subscription expires on: {{ store_user['expiration_date'][:10] }} + a(href="https://store.blender.org/my-account/") Manage your subscription on Blender Store + + hr + + | {# This text is confusing (refers to the total payments ever made by the user) + .settings-billing-info. + Paid balance: {{ store_user['paid_balance'] }} {{ store_user['balance_currency'] }} + | #} + + | {% else %} + + | {% if 'demo' in groups %} + h3.subscription-demo + i.pi-heart-filled + | You have a free account + + hr + + p You have full access to the Blender Cloud, provided by the Blender Institute. This account is meant for free evaluation of the service. Get in touch with #[a(href="mailto:cloudsupport@blender.org") cloudsupport@blender.org] if you have any questions. + + | {% else %} + h3.subscription-missing + i.pi-info + | You do not have an active subscription. + h3 + a(href="https://store.blender.org/product/membership/") Get full access to Blender Cloud now! + | {% endif %} + + | {% endif %} + + + + + +| {% endblock %} diff --git a/src/templates/users/settings/emails.jade b/src/templates/users/settings/emails.jade new file mode 100644 index 00000000..9cfd6315 --- /dev/null +++ b/src/templates/users/settings/emails.jade @@ -0,0 +1,27 @@ +| {% extends 'layout.html' %} +| {% block body %} +.container + #settings + include _sidebar + #settings-container + .settings-header + .settings-title Emails + + .settings-content + + .settings-form + form#settings-form(method='POST', action="{{url_for('users.settings_emails')}}") + | {{ form.csrf_token }} + | {% for subfield in form.email_communications %} + .form-group. + {{ subfield }} + {{ subfield.label }} + | {% endfor %} + + .buttons + button.btn.btn-default.button-submit(type='submit') + i.pi-check + | Save Changes + + +| {% endblock %} diff --git a/src/templates/users/settings/profile.jade b/src/templates/users/settings/profile.jade new file mode 100644 index 00000000..c0d4b248 --- /dev/null +++ b/src/templates/users/settings/profile.jade @@ -0,0 +1,45 @@ +| {% extends 'layout.html' %} +| {% block body %} +.container + #settings + include _sidebar + #settings-container + .settings-header + .settings-title Profile + + .settings-content + .settings-form + form#settings-form(method='POST', action="{{url_for('users.settings_profile')}}") + .left + .form-group + | {{ form.full_name.label }} + | {{ form.full_name(size=20, class='form-control') }} + | {% if form.full_name.errors %} + | {% for error in form.full_name.errors %}{{ error|e }}{% endfor %} + | {% endif %} + + .form-group + | {{ form.username.label }} + | {{ form.username(size=20, class='form-control') }} + | {% if form.username.errors %} + | {% for error in form.username.errors %}{{ error|e }}{% endfor %} + | {% endif %} + + + .form-group.settings-password + | Change your password at the + a(href="https://blender.org/id/change") Blender ID + + .right + .settings-avatar + a(href="https://gravatar.com/") + img(src="{{ current_user.gravatar }}") + span Change Gravatar + + .buttons + button.btn.btn-default.button-submit(type='submit') + i.pi-check + | Save Changes + + +| {% endblock %} diff --git a/src/templates/users/tasks.jade b/src/templates/users/tasks.jade new file mode 100644 index 00000000..3110ffba --- /dev/null +++ b/src/templates/users/tasks.jade @@ -0,0 +1,170 @@ +| {% extends 'layout.html' %} + +| {% block header_items %} +link(href='//cdn.datatables.net/plug-ins/1.10.7/integration/bootstrap/3/dataTables.bootstrap.css', rel='stylesheet') +| {% endblock %} + +| {% block body %} +#shots-main.col-md-8 + table#user_tasks.table.table-striped.table-hover( + cellpadding='0', cellspacing='0', border='0') + thead + tr + th {# 0 #} + th {# 1 #} + th {# 2 #} + th {# 3 #} + th {# 4 #} Shot + th {# 5 #} Name + th {# 6 #} Description + th {# 7 #} Duration + th {# 8 #} Status +| {% endblock %} +| {% block sidebar %} +#shots-sidebar.col-md-4 + #shot_details_container + #task_details_container +| {% endblock %} + +| {% block footer_scripts %} +script(type='text/javascript', src='//cdn.datatables.net/1.10.7/js/jquery.dataTables.min.js') +script(). + $(document).ready(function(){ + + function render_timing(timing) { + var timing_text = ''; + if (timing['cut_in'] && timing['cut_out']) { + timing_frames = timing['cut_out'] - timing['cut_in']; + + timing_text += ''; + timing_text += Math.round(timing_frames / 24); + timing_text += 's'; + } + return timing_text; + } + + function render_status_options(status) { + var selected = false; + var options = [] + + statuses = ['todo', 'in_progress', 'on_hold', 'review', 'approved', 'final'] + $.each(statuses, function(key, value) { + selected = false; + if (status === value) { + selected = true; + }; + option = $("