From 62b3afeeedfe054345f86093e2d243e956c1e3c9 Mon Sep 17 00:00:00 2001 From: Cédric Bonhomme Date: Wed, 26 Feb 2020 11:27:31 +0100 Subject: The project is now using Poetry. --- .buildpacks | 2 - .gitignore | 8 +- Pipfile | 34 - Pipfile.lock | 504 ------- Procfile | 2 - README.md | 23 +- app.json | 79 -- install.sh | 24 +- newspipe/bootstrap.py | 80 ++ newspipe/conf.py | 126 ++ newspipe/conf/conf.cfg-sample | 31 + newspipe/crawler/default_crawler.py | 181 +++ newspipe/lib/__init__.py | 0 newspipe/lib/article_utils.py | 186 +++ newspipe/lib/data.py | 210 +++ newspipe/lib/feed_utils.py | 125 ++ newspipe/lib/misc_utils.py | 185 +++ newspipe/lib/utils.py | 89 ++ newspipe/manager.py | 86 ++ newspipe/notifications/emails.py | 132 ++ newspipe/notifications/notifications.py | 53 + newspipe/runserver.py | 66 + newspipe/web/__init__.py | 8 + newspipe/web/controllers/__init__.py | 12 + newspipe/web/controllers/abstract.py | 161 +++ newspipe/web/controllers/article.py | 87 ++ newspipe/web/controllers/bookmark.py | 32 + newspipe/web/controllers/category.py | 12 + newspipe/web/controllers/feed.py | 98 ++ newspipe/web/controllers/icon.py | 23 + newspipe/web/controllers/tag.py | 22 + newspipe/web/controllers/user.py | 28 + newspipe/web/decorators.py | 27 + newspipe/web/forms.py | 220 +++ newspipe/web/js/actions/MenuActions.js | 40 + newspipe/web/js/actions/MiddlePanelActions.js | 132 ++ newspipe/web/js/actions/RightPanelActions.js | 42 + newspipe/web/js/app.js | 18 + newspipe/web/js/components/MainApp.react.js | 29 + newspipe/web/js/components/Menu.react.js | 305 +++++ newspipe/web/js/components/MiddlePanel.react.js | 267 ++++ newspipe/web/js/components/Navbar.react.js | 138 ++ newspipe/web/js/components/RightPanel.react.js | 463 +++++++ newspipe/web/js/components/time.react.js | 15 + newspipe/web/js/constants/JarrConstants.js | 13 + newspipe/web/js/dispatcher/JarrDispatcher.js | 16 + .../js/dispatcher/__tests__/AppDispatcher-test.js | 72 + newspipe/web/js/stores/MenuStore.js | 135 ++ newspipe/web/js/stores/MiddlePanelStore.js | 126 ++ newspipe/web/js/stores/RightPanelStore.js | 77 ++ newspipe/web/js/stores/__tests__/TodoStore-test.js | 90 ++ newspipe/web/lib/__init__.py | 0 newspipe/web/lib/user_utils.py | 23 + newspipe/web/lib/view_utils.py | 26 + newspipe/web/models/__init__.py | 87 ++ newspipe/web/models/article.py | 87 ++ newspipe/web/models/bookmark.py | 68 + newspipe/web/models/category.py | 29 + newspipe/web/models/feed.py | 91 ++ newspipe/web/models/icon.py | 10 + newspipe/web/models/right_mixin.py | 63 + newspipe/web/models/role.py | 39 + newspipe/web/models/tag.py | 36 + newspipe/web/models/user.py | 108 ++ newspipe/web/static/css/bootstrap-theme.min.css | 1 + .../web/static/css/bootstrap-theme.min.css.map | 1 + newspipe/web/static/css/bootstrap.min.css | 1 + newspipe/web/static/css/bootstrap.min.css.map | 1 + newspipe/web/static/css/customized-bootstrap.css | 55 + newspipe/web/static/css/one-page-app.css | 167 +++ newspipe/web/static/fonts | 1 + newspipe/web/static/img/favicon.ico | Bin 0 -> 1150 bytes newspipe/web/static/img/newspipe.png | Bin 0 -> 1547 bytes newspipe/web/static/img/newspipe.svg | 84 ++ newspipe/web/static/img/pinboard.png | Bin 0 -> 597 bytes newspipe/web/static/img/reddit.png | Bin 0 -> 525 bytes newspipe/web/static/img/twitter.png | Bin 0 -> 640 bytes newspipe/web/static/js/articles.js | 191 +++ newspipe/web/static/js/feed.js | 22 + newspipe/web/static/js/jquery.js | 4 + newspipe/web/templates/about.html | 23 + newspipe/web/templates/about_more.html | 12 + newspipe/web/templates/admin/create_user.html | 26 + newspipe/web/templates/admin/dashboard.html | 68 + newspipe/web/templates/article.html | 35 + newspipe/web/templates/article_pub.html | 24 + newspipe/web/templates/bookmarks.html | 74 ++ newspipe/web/templates/categories.html | 36 + newspipe/web/templates/duplicates.html | 30 + newspipe/web/templates/edit_bookmark.html | 84 ++ newspipe/web/templates/edit_category.html | 23 + newspipe/web/templates/edit_feed.html | 98 ++ .../web/templates/emails/account_activation.txt | 9 + newspipe/web/templates/emails/new_password.txt | 8 + newspipe/web/templates/errors/404.html | 12 + newspipe/web/templates/errors/500.html | 12 + newspipe/web/templates/feed.html | 76 ++ newspipe/web/templates/feed_list.html | 55 + .../web/templates/feed_list_per_categories.html | 52 + newspipe/web/templates/feed_list_simple.html | 35 + newspipe/web/templates/feeds.html | 7 + newspipe/web/templates/history.html | 26 + newspipe/web/templates/home.html | 9 + newspipe/web/templates/inactives.html | 26 + newspipe/web/templates/layout.html | 155 +++ newspipe/web/templates/login.html | 28 + newspipe/web/templates/management.html | 72 + newspipe/web/templates/opml.xml | 13 + newspipe/web/templates/popular.html | 27 + newspipe/web/templates/profile.html | 65 + newspipe/web/templates/profile_public.html | 45 + newspipe/web/templates/signup.html | 24 + newspipe/web/templates/user_stream.html | 70 + newspipe/web/translations/babel.cfg | 3 + .../web/translations/fr/LC_MESSAGES/messages.mo | Bin 0 -> 19672 bytes .../web/translations/fr/LC_MESSAGES/messages.po | 1403 ++++++++++++++++++++ newspipe/web/translations/internationalization.sh | 4 + newspipe/web/translations/messages.pot | 1050 +++++++++++++++ newspipe/web/views/__init__.py | 23 + newspipe/web/views/admin.py | 119 ++ newspipe/web/views/api/__init__.py | 0 newspipe/web/views/api/v2/__init__.py | 3 + newspipe/web/views/api/v2/article.py | 53 + newspipe/web/views/api/v2/category.py | 27 + newspipe/web/views/api/v2/common.py | 222 ++++ newspipe/web/views/api/v2/feed.py | 47 + newspipe/web/views/api/v3/__init__.py | 3 + newspipe/web/views/api/v3/article.py | 84 ++ newspipe/web/views/api/v3/common.py | 109 ++ newspipe/web/views/api/v3/feed.py | 58 + newspipe/web/views/article.py | 154 +++ newspipe/web/views/bookmark.py | 256 ++++ newspipe/web/views/category.py | 86 ++ newspipe/web/views/common.py | 53 + newspipe/web/views/feed.py | 306 +++++ newspipe/web/views/home.py | 172 +++ newspipe/web/views/icon.py | 15 + newspipe/web/views/session_mgmt.py | 113 ++ newspipe/web/views/user.py | 203 +++ newspipe/web/views/views.py | 95 ++ poetry.lock | 923 +++++++++++++ pyproject.toml | 44 + runtime.txt | 1 - src/bootstrap.py | 80 -- src/conf.py | 126 -- src/conf/conf.cfg-sample | 31 - src/crawler/default_crawler.py | 181 --- src/lib/__init__.py | 0 src/lib/article_utils.py | 186 --- src/lib/data.py | 210 --- src/lib/feed_utils.py | 125 -- src/lib/misc_utils.py | 185 --- src/lib/utils.py | 89 -- src/manager.py | 86 -- src/notifications/emails.py | 132 -- src/notifications/notifications.py | 53 - src/runserver.py | 66 - src/web/__init__.py | 8 - src/web/controllers/__init__.py | 12 - src/web/controllers/abstract.py | 161 --- src/web/controllers/article.py | 87 -- src/web/controllers/bookmark.py | 32 - src/web/controllers/category.py | 12 - src/web/controllers/feed.py | 98 -- src/web/controllers/icon.py | 23 - src/web/controllers/tag.py | 22 - src/web/controllers/user.py | 28 - src/web/decorators.py | 27 - src/web/forms.py | 220 --- src/web/js/actions/MenuActions.js | 40 - src/web/js/actions/MiddlePanelActions.js | 132 -- src/web/js/actions/RightPanelActions.js | 42 - src/web/js/app.js | 18 - src/web/js/components/MainApp.react.js | 29 - src/web/js/components/Menu.react.js | 305 ----- src/web/js/components/MiddlePanel.react.js | 267 ---- src/web/js/components/Navbar.react.js | 138 -- src/web/js/components/RightPanel.react.js | 463 ------- src/web/js/components/time.react.js | 15 - src/web/js/constants/JarrConstants.js | 13 - src/web/js/dispatcher/JarrDispatcher.js | 16 - .../js/dispatcher/__tests__/AppDispatcher-test.js | 72 - src/web/js/stores/MenuStore.js | 135 -- src/web/js/stores/MiddlePanelStore.js | 126 -- src/web/js/stores/RightPanelStore.js | 77 -- src/web/js/stores/__tests__/TodoStore-test.js | 90 -- src/web/lib/__init__.py | 0 src/web/lib/user_utils.py | 23 - src/web/lib/view_utils.py | 26 - src/web/models/__init__.py | 87 -- src/web/models/article.py | 87 -- src/web/models/bookmark.py | 68 - src/web/models/category.py | 29 - src/web/models/feed.py | 91 -- src/web/models/icon.py | 10 - src/web/models/right_mixin.py | 63 - src/web/models/role.py | 39 - src/web/models/tag.py | 36 - src/web/models/user.py | 108 -- src/web/static/css/bootstrap-theme.min.css | 1 - src/web/static/css/bootstrap-theme.min.css.map | 1 - src/web/static/css/bootstrap.min.css | 1 - src/web/static/css/bootstrap.min.css.map | 1 - src/web/static/css/customized-bootstrap.css | 55 - src/web/static/css/one-page-app.css | 167 --- src/web/static/fonts | 1 - src/web/static/img/favicon.ico | Bin 1150 -> 0 bytes src/web/static/img/newspipe.png | Bin 1547 -> 0 bytes src/web/static/img/newspipe.svg | 84 -- src/web/static/img/pinboard.png | Bin 597 -> 0 bytes src/web/static/img/reddit.png | Bin 525 -> 0 bytes src/web/static/img/twitter.png | Bin 640 -> 0 bytes src/web/static/js/articles.js | 191 --- src/web/static/js/feed.js | 22 - src/web/static/js/jquery.js | 4 - src/web/templates/about.html | 23 - src/web/templates/about_more.html | 12 - src/web/templates/admin/create_user.html | 26 - src/web/templates/admin/dashboard.html | 68 - src/web/templates/article.html | 35 - src/web/templates/article_pub.html | 24 - src/web/templates/bookmarks.html | 74 -- src/web/templates/categories.html | 36 - src/web/templates/duplicates.html | 30 - src/web/templates/edit_bookmark.html | 84 -- src/web/templates/edit_category.html | 23 - src/web/templates/edit_feed.html | 98 -- src/web/templates/emails/account_activation.txt | 9 - src/web/templates/emails/new_password.txt | 8 - src/web/templates/errors/404.html | 12 - src/web/templates/errors/500.html | 12 - src/web/templates/feed.html | 76 -- src/web/templates/feed_list.html | 55 - src/web/templates/feed_list_per_categories.html | 52 - src/web/templates/feed_list_simple.html | 35 - src/web/templates/feeds.html | 7 - src/web/templates/history.html | 26 - src/web/templates/home.html | 9 - src/web/templates/inactives.html | 26 - src/web/templates/layout.html | 155 --- src/web/templates/login.html | 28 - src/web/templates/management.html | 72 - src/web/templates/opml.xml | 13 - src/web/templates/popular.html | 27 - src/web/templates/profile.html | 65 - src/web/templates/profile_public.html | 45 - src/web/templates/signup.html | 24 - src/web/templates/user_stream.html | 70 - src/web/translations/babel.cfg | 3 - src/web/translations/fr/LC_MESSAGES/messages.mo | Bin 19672 -> 0 bytes src/web/translations/fr/LC_MESSAGES/messages.po | 1403 -------------------- src/web/translations/internationalization.sh | 4 - src/web/translations/messages.pot | 1050 --------------- src/web/views/__init__.py | 23 - src/web/views/admin.py | 119 -- src/web/views/api/__init__.py | 0 src/web/views/api/v2/__init__.py | 3 - src/web/views/api/v2/article.py | 53 - src/web/views/api/v2/category.py | 27 - src/web/views/api/v2/common.py | 222 ---- src/web/views/api/v2/feed.py | 47 - src/web/views/api/v3/__init__.py | 3 - src/web/views/api/v3/article.py | 84 -- src/web/views/api/v3/common.py | 109 -- src/web/views/api/v3/feed.py | 58 - src/web/views/article.py | 154 --- src/web/views/bookmark.py | 256 ---- src/web/views/category.py | 86 -- src/web/views/common.py | 53 - src/web/views/feed.py | 306 ----- src/web/views/home.py | 172 --- src/web/views/icon.py | 15 - src/web/views/session_mgmt.py | 113 -- src/web/views/user.py | 203 --- src/web/views/views.py | 95 -- 275 files changed, 12465 insertions(+), 12125 deletions(-) delete mode 100644 .buildpacks delete mode 100644 Pipfile delete mode 100644 Pipfile.lock delete mode 100644 Procfile delete mode 100644 app.json create mode 100644 newspipe/bootstrap.py create mode 100644 newspipe/conf.py create mode 100644 newspipe/conf/conf.cfg-sample create mode 100644 newspipe/crawler/default_crawler.py create mode 100644 newspipe/lib/__init__.py create mode 100644 newspipe/lib/article_utils.py create mode 100644 newspipe/lib/data.py create mode 100644 newspipe/lib/feed_utils.py create mode 100755 newspipe/lib/misc_utils.py create mode 100644 newspipe/lib/utils.py create mode 100755 newspipe/manager.py create mode 100644 newspipe/notifications/emails.py create mode 100644 newspipe/notifications/notifications.py create mode 100755 newspipe/runserver.py create mode 100644 newspipe/web/__init__.py create mode 100644 newspipe/web/controllers/__init__.py create mode 100644 newspipe/web/controllers/abstract.py create mode 100644 newspipe/web/controllers/article.py create mode 100644 newspipe/web/controllers/bookmark.py create mode 100644 newspipe/web/controllers/category.py create mode 100644 newspipe/web/controllers/feed.py create mode 100644 newspipe/web/controllers/icon.py create mode 100644 newspipe/web/controllers/tag.py create mode 100644 newspipe/web/controllers/user.py create mode 100644 newspipe/web/decorators.py create mode 100644 newspipe/web/forms.py create mode 100644 newspipe/web/js/actions/MenuActions.js create mode 100644 newspipe/web/js/actions/MiddlePanelActions.js create mode 100644 newspipe/web/js/actions/RightPanelActions.js create mode 100644 newspipe/web/js/app.js create mode 100644 newspipe/web/js/components/MainApp.react.js create mode 100644 newspipe/web/js/components/Menu.react.js create mode 100644 newspipe/web/js/components/MiddlePanel.react.js create mode 100644 newspipe/web/js/components/Navbar.react.js create mode 100644 newspipe/web/js/components/RightPanel.react.js create mode 100644 newspipe/web/js/components/time.react.js create mode 100644 newspipe/web/js/constants/JarrConstants.js create mode 100644 newspipe/web/js/dispatcher/JarrDispatcher.js create mode 100644 newspipe/web/js/dispatcher/__tests__/AppDispatcher-test.js create mode 100644 newspipe/web/js/stores/MenuStore.js create mode 100644 newspipe/web/js/stores/MiddlePanelStore.js create mode 100644 newspipe/web/js/stores/RightPanelStore.js create mode 100644 newspipe/web/js/stores/__tests__/TodoStore-test.js create mode 100644 newspipe/web/lib/__init__.py create mode 100644 newspipe/web/lib/user_utils.py create mode 100644 newspipe/web/lib/view_utils.py create mode 100644 newspipe/web/models/__init__.py create mode 100644 newspipe/web/models/article.py create mode 100644 newspipe/web/models/bookmark.py create mode 100644 newspipe/web/models/category.py create mode 100644 newspipe/web/models/feed.py create mode 100644 newspipe/web/models/icon.py create mode 100644 newspipe/web/models/right_mixin.py create mode 100644 newspipe/web/models/role.py create mode 100644 newspipe/web/models/tag.py create mode 100644 newspipe/web/models/user.py create mode 120000 newspipe/web/static/css/bootstrap-theme.min.css create mode 120000 newspipe/web/static/css/bootstrap-theme.min.css.map create mode 120000 newspipe/web/static/css/bootstrap.min.css create mode 120000 newspipe/web/static/css/bootstrap.min.css.map create mode 100644 newspipe/web/static/css/customized-bootstrap.css create mode 100644 newspipe/web/static/css/one-page-app.css create mode 120000 newspipe/web/static/fonts create mode 100644 newspipe/web/static/img/favicon.ico create mode 100644 newspipe/web/static/img/newspipe.png create mode 100644 newspipe/web/static/img/newspipe.svg create mode 100644 newspipe/web/static/img/pinboard.png create mode 100755 newspipe/web/static/img/reddit.png create mode 100644 newspipe/web/static/img/twitter.png create mode 100644 newspipe/web/static/js/articles.js create mode 100644 newspipe/web/static/js/feed.js create mode 100644 newspipe/web/static/js/jquery.js create mode 100644 newspipe/web/templates/about.html create mode 100644 newspipe/web/templates/about_more.html create mode 100644 newspipe/web/templates/admin/create_user.html create mode 100644 newspipe/web/templates/admin/dashboard.html create mode 100644 newspipe/web/templates/article.html create mode 100644 newspipe/web/templates/article_pub.html create mode 100644 newspipe/web/templates/bookmarks.html create mode 100644 newspipe/web/templates/categories.html create mode 100644 newspipe/web/templates/duplicates.html create mode 100644 newspipe/web/templates/edit_bookmark.html create mode 100644 newspipe/web/templates/edit_category.html create mode 100644 newspipe/web/templates/edit_feed.html create mode 100644 newspipe/web/templates/emails/account_activation.txt create mode 100644 newspipe/web/templates/emails/new_password.txt create mode 100644 newspipe/web/templates/errors/404.html create mode 100644 newspipe/web/templates/errors/500.html create mode 100644 newspipe/web/templates/feed.html create mode 100644 newspipe/web/templates/feed_list.html create mode 100644 newspipe/web/templates/feed_list_per_categories.html create mode 100644 newspipe/web/templates/feed_list_simple.html create mode 100644 newspipe/web/templates/feeds.html create mode 100644 newspipe/web/templates/history.html create mode 100644 newspipe/web/templates/home.html create mode 100644 newspipe/web/templates/inactives.html create mode 100644 newspipe/web/templates/layout.html create mode 100644 newspipe/web/templates/login.html create mode 100644 newspipe/web/templates/management.html create mode 100644 newspipe/web/templates/opml.xml create mode 100644 newspipe/web/templates/popular.html create mode 100644 newspipe/web/templates/profile.html create mode 100644 newspipe/web/templates/profile_public.html create mode 100644 newspipe/web/templates/signup.html create mode 100644 newspipe/web/templates/user_stream.html create mode 100644 newspipe/web/translations/babel.cfg create mode 100644 newspipe/web/translations/fr/LC_MESSAGES/messages.mo create mode 100644 newspipe/web/translations/fr/LC_MESSAGES/messages.po create mode 100755 newspipe/web/translations/internationalization.sh create mode 100644 newspipe/web/translations/messages.pot create mode 100644 newspipe/web/views/__init__.py create mode 100644 newspipe/web/views/admin.py create mode 100644 newspipe/web/views/api/__init__.py create mode 100644 newspipe/web/views/api/v2/__init__.py create mode 100644 newspipe/web/views/api/v2/article.py create mode 100644 newspipe/web/views/api/v2/category.py create mode 100644 newspipe/web/views/api/v2/common.py create mode 100644 newspipe/web/views/api/v2/feed.py create mode 100644 newspipe/web/views/api/v3/__init__.py create mode 100644 newspipe/web/views/api/v3/article.py create mode 100644 newspipe/web/views/api/v3/common.py create mode 100644 newspipe/web/views/api/v3/feed.py create mode 100644 newspipe/web/views/article.py create mode 100644 newspipe/web/views/bookmark.py create mode 100644 newspipe/web/views/category.py create mode 100644 newspipe/web/views/common.py create mode 100644 newspipe/web/views/feed.py create mode 100644 newspipe/web/views/home.py create mode 100644 newspipe/web/views/icon.py create mode 100644 newspipe/web/views/session_mgmt.py create mode 100644 newspipe/web/views/user.py create mode 100644 newspipe/web/views/views.py create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 runtime.txt delete mode 100644 src/bootstrap.py delete mode 100644 src/conf.py delete mode 100644 src/conf/conf.cfg-sample delete mode 100644 src/crawler/default_crawler.py delete mode 100644 src/lib/__init__.py delete mode 100644 src/lib/article_utils.py delete mode 100644 src/lib/data.py delete mode 100644 src/lib/feed_utils.py delete mode 100755 src/lib/misc_utils.py delete mode 100644 src/lib/utils.py delete mode 100755 src/manager.py delete mode 100644 src/notifications/emails.py delete mode 100644 src/notifications/notifications.py delete mode 100755 src/runserver.py delete mode 100644 src/web/__init__.py delete mode 100644 src/web/controllers/__init__.py delete mode 100644 src/web/controllers/abstract.py delete mode 100644 src/web/controllers/article.py delete mode 100644 src/web/controllers/bookmark.py delete mode 100644 src/web/controllers/category.py delete mode 100644 src/web/controllers/feed.py delete mode 100644 src/web/controllers/icon.py delete mode 100644 src/web/controllers/tag.py delete mode 100644 src/web/controllers/user.py delete mode 100644 src/web/decorators.py delete mode 100644 src/web/forms.py delete mode 100644 src/web/js/actions/MenuActions.js delete mode 100644 src/web/js/actions/MiddlePanelActions.js delete mode 100644 src/web/js/actions/RightPanelActions.js delete mode 100644 src/web/js/app.js delete mode 100644 src/web/js/components/MainApp.react.js delete mode 100644 src/web/js/components/Menu.react.js delete mode 100644 src/web/js/components/MiddlePanel.react.js delete mode 100644 src/web/js/components/Navbar.react.js delete mode 100644 src/web/js/components/RightPanel.react.js delete mode 100644 src/web/js/components/time.react.js delete mode 100644 src/web/js/constants/JarrConstants.js delete mode 100644 src/web/js/dispatcher/JarrDispatcher.js delete mode 100644 src/web/js/dispatcher/__tests__/AppDispatcher-test.js delete mode 100644 src/web/js/stores/MenuStore.js delete mode 100644 src/web/js/stores/MiddlePanelStore.js delete mode 100644 src/web/js/stores/RightPanelStore.js delete mode 100644 src/web/js/stores/__tests__/TodoStore-test.js delete mode 100644 src/web/lib/__init__.py delete mode 100644 src/web/lib/user_utils.py delete mode 100644 src/web/lib/view_utils.py delete mode 100644 src/web/models/__init__.py delete mode 100644 src/web/models/article.py delete mode 100644 src/web/models/bookmark.py delete mode 100644 src/web/models/category.py delete mode 100644 src/web/models/feed.py delete mode 100644 src/web/models/icon.py delete mode 100644 src/web/models/right_mixin.py delete mode 100644 src/web/models/role.py delete mode 100644 src/web/models/tag.py delete mode 100644 src/web/models/user.py delete mode 120000 src/web/static/css/bootstrap-theme.min.css delete mode 120000 src/web/static/css/bootstrap-theme.min.css.map delete mode 120000 src/web/static/css/bootstrap.min.css delete mode 120000 src/web/static/css/bootstrap.min.css.map delete mode 100644 src/web/static/css/customized-bootstrap.css delete mode 100644 src/web/static/css/one-page-app.css delete mode 120000 src/web/static/fonts delete mode 100644 src/web/static/img/favicon.ico delete mode 100644 src/web/static/img/newspipe.png delete mode 100644 src/web/static/img/newspipe.svg delete mode 100644 src/web/static/img/pinboard.png delete mode 100755 src/web/static/img/reddit.png delete mode 100644 src/web/static/img/twitter.png delete mode 100644 src/web/static/js/articles.js delete mode 100644 src/web/static/js/feed.js delete mode 100644 src/web/static/js/jquery.js delete mode 100644 src/web/templates/about.html delete mode 100644 src/web/templates/about_more.html delete mode 100644 src/web/templates/admin/create_user.html delete mode 100644 src/web/templates/admin/dashboard.html delete mode 100644 src/web/templates/article.html delete mode 100644 src/web/templates/article_pub.html delete mode 100644 src/web/templates/bookmarks.html delete mode 100644 src/web/templates/categories.html delete mode 100644 src/web/templates/duplicates.html delete mode 100644 src/web/templates/edit_bookmark.html delete mode 100644 src/web/templates/edit_category.html delete mode 100644 src/web/templates/edit_feed.html delete mode 100644 src/web/templates/emails/account_activation.txt delete mode 100644 src/web/templates/emails/new_password.txt delete mode 100644 src/web/templates/errors/404.html delete mode 100644 src/web/templates/errors/500.html delete mode 100644 src/web/templates/feed.html delete mode 100644 src/web/templates/feed_list.html delete mode 100644 src/web/templates/feed_list_per_categories.html delete mode 100644 src/web/templates/feed_list_simple.html delete mode 100644 src/web/templates/feeds.html delete mode 100644 src/web/templates/history.html delete mode 100644 src/web/templates/home.html delete mode 100644 src/web/templates/inactives.html delete mode 100644 src/web/templates/layout.html delete mode 100644 src/web/templates/login.html delete mode 100644 src/web/templates/management.html delete mode 100644 src/web/templates/opml.xml delete mode 100644 src/web/templates/popular.html delete mode 100644 src/web/templates/profile.html delete mode 100644 src/web/templates/profile_public.html delete mode 100644 src/web/templates/signup.html delete mode 100644 src/web/templates/user_stream.html delete mode 100644 src/web/translations/babel.cfg delete mode 100644 src/web/translations/fr/LC_MESSAGES/messages.mo delete mode 100644 src/web/translations/fr/LC_MESSAGES/messages.po delete mode 100755 src/web/translations/internationalization.sh delete mode 100644 src/web/translations/messages.pot delete mode 100644 src/web/views/__init__.py delete mode 100644 src/web/views/admin.py delete mode 100644 src/web/views/api/__init__.py delete mode 100644 src/web/views/api/v2/__init__.py delete mode 100644 src/web/views/api/v2/article.py delete mode 100644 src/web/views/api/v2/category.py delete mode 100644 src/web/views/api/v2/common.py delete mode 100644 src/web/views/api/v2/feed.py delete mode 100644 src/web/views/api/v3/__init__.py delete mode 100644 src/web/views/api/v3/article.py delete mode 100644 src/web/views/api/v3/common.py delete mode 100644 src/web/views/api/v3/feed.py delete mode 100644 src/web/views/article.py delete mode 100644 src/web/views/bookmark.py delete mode 100644 src/web/views/category.py delete mode 100644 src/web/views/common.py delete mode 100644 src/web/views/feed.py delete mode 100644 src/web/views/home.py delete mode 100644 src/web/views/icon.py delete mode 100644 src/web/views/session_mgmt.py delete mode 100644 src/web/views/user.py delete mode 100644 src/web/views/views.py diff --git a/.buildpacks b/.buildpacks deleted file mode 100644 index f1de1de6..00000000 --- a/.buildpacks +++ /dev/null @@ -1,2 +0,0 @@ -https://github.com/heroku/heroku-buildpack-nodejs -https://github.com/ejholmes/heroku-buildpack-bower diff --git a/.gitignore b/.gitignore index 780b0a5e..17a7e884 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,12 @@ eproject.cfg venv build -src/conf/conf.cfg +newspipe.egg-info/ + +newspipe/conf/conf.cfg .coverage # js and node files node_modules -src/web/static/bower_components/ -src/web/static/js/bundle.min.js +newspipe/web/static/bower_components/ +newspipe/web/static/js/bundle.min.js diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 588235ee..00000000 --- a/Pipfile +++ /dev/null @@ -1,34 +0,0 @@ -[[source]] -url = "https://pypi.python.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -aiohttp = "*" -requests = "*" -chardet = "*" -requests-futures = "*" -feedparser = "*" -"beautifulsoup4" = "*" -lxml = "*" -opml = "*" -SQLAlchemy = "*" -alembic = "*" -Flask = "*" -Flask-SQLAlchemy = "*" -Flask-Login = "*" -Flask-Principal = "*" -Flask-WTF = "*" -Flask-RESTful = "*" -Flask-Restless = "*" -Flask-paginate = "*" -Flask-Babel = "*" -Flask-SSLify = "*" -Flask-Migrate = "*" -Flask-Script = "*" -WTForms = "*" -sendgrid = "*" -python-dateutil = "*" -psycopg2-binary = "*" - -[dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index f9b9dff8..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,504 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "6b30dc177331d8eda5e9c7531c9871b33a650c1610b0eddbad9345bad862c7f4" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.python.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiohttp": { - "hashes": [ - "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e", - "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326", - "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a", - "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654", - "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a", - "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4", - "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17", - "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec", - "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd", - "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48", - "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59", - "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965" - ], - "index": "pypi", - "version": "==3.6.2" - }, - "alembic": { - "hashes": [ - "sha256:2df2519a5b002f881517693b95626905a39c5faf4b5a1f94de4f1441095d1d26" - ], - "index": "pypi", - "version": "==1.4.0" - }, - "aniso8601": { - "hashes": [ - "sha256:529dcb1f5f26ee0df6c0a1ee84b7b27197c3c50fc3a6321d66c544689237d072", - "sha256:c033f63d028b9a58e3ab0c2c7d0532ab4bfa7452bfc788fbfe3ddabd327b181a" - ], - "version": "==8.0.0" - }, - "async-timeout": { - "hashes": [ - "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", - "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" - ], - "version": "==3.0.1" - }, - "attrs": { - "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" - ], - "version": "==19.3.0" - }, - "babel": { - "hashes": [ - "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", - "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" - ], - "version": "==2.8.0" - }, - "beautifulsoup4": { - "hashes": [ - "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a", - "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887", - "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae" - ], - "index": "pypi", - "version": "==4.8.2" - }, - "blinker": { - "hashes": [ - "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" - ], - "version": "==1.4" - }, - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "index": "pypi", - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" - }, - "feedparser": { - "hashes": [ - "sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9", - "sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c", - "sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02" - ], - "index": "pypi", - "version": "==5.2.1" - }, - "flask": { - "hashes": [ - "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", - "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6" - ], - "index": "pypi", - "version": "==1.1.1" - }, - "flask-babel": { - "hashes": [ - "sha256:247f4ec34cf605d03781f480bccb1a5acb719df1d1a2a743c091ab3db5d5fde2", - "sha256:d6a70468f9a8919d59fba2a291a003da3a05ff884275dddbd965f3b98b09ab3e" - ], - "index": "pypi", - "version": "==1.0.0" - }, - "flask-login": { - "hashes": [ - "sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b", - "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "flask-migrate": { - "hashes": [ - "sha256:6fb038be63d4c60727d5dfa5f581a6189af5b4e2925bc378697b4f0a40cfb4e1", - "sha256:a96ff1875a49a40bd3e8ac04fce73fdb0870b9211e6168608cbafa4eb839d502" - ], - "index": "pypi", - "version": "==2.5.2" - }, - "flask-paginate": { - "hashes": [ - "sha256:d2aa07b4ef27f56f973482aaa06a0d93dc769a3e4d3e9c382a305ab72ac38ad9" - ], - "index": "pypi", - "version": "==0.5.5" - }, - "flask-principal": { - "hashes": [ - "sha256:f5d6134b5caebfdbb86f32d56d18ee44b080876a27269560a96ea35f75c99453" - ], - "index": "pypi", - "version": "==0.4.0" - }, - "flask-restful": { - "hashes": [ - "sha256:5ea9a5991abf2cb69b4aac19793faac6c032300505b325687d7c305ffaa76915", - "sha256:d891118b951921f1cec80cabb4db98ea6058a35e6404788f9e70d5b243813ec2" - ], - "index": "pypi", - "version": "==0.3.8" - }, - "flask-restless": { - "hashes": [ - "sha256:1de47fe80abd47239c9a1804e0ba5da1d23b9f40cfc26202d16bed37f178c2b6" - ], - "index": "pypi", - "version": "==0.17.0" - }, - "flask-script": { - "hashes": [ - "sha256:6425963d91054cfcc185807141c7314a9c5ad46325911bd24dcb489bd0161c65" - ], - "index": "pypi", - "version": "==2.0.6" - }, - "flask-sqlalchemy": { - "hashes": [ - "sha256:0078d8663330dc05a74bc72b3b6ddc441b9a744e2f56fe60af1a5bfc81334327", - "sha256:6974785d913666587949f7c2946f7001e4fa2cb2d19f4e69ead02e4b8f50b33d" - ], - "index": "pypi", - "version": "==2.4.1" - }, - "flask-sslify": { - "hashes": [ - "sha256:d33e1d3c09cd95154176aa8a7319418e52129fc482dd56d8a8ad7c24500d543e" - ], - "index": "pypi", - "version": "==0.1.5" - }, - "flask-wtf": { - "hashes": [ - "sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2", - "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720" - ], - "index": "pypi", - "version": "==0.14.3" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" - }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", - "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" - ], - "version": "==2.11.1" - }, - "lxml": { - "hashes": [ - "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd", - "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c", - "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081", - "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f", - "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261", - "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a", - "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9", - "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a", - "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb", - "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60", - "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128", - "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a", - "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717", - "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89", - "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72", - "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8", - "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3", - "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7", - "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8", - "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77", - "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1", - "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15", - "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679", - "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012", - "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6", - "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc", - "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca" - ], - "index": "pypi", - "version": "==4.5.0" - }, - "mako": { - "hashes": [ - "sha256:2984a6733e1d472796ceef37ad48c26f4a984bb18119bb2dbc37a44d8f6e75a4" - ], - "version": "==1.1.1" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "version": "==1.1.1" - }, - "mimerender": { - "hashes": [ - "sha256:e7f1377efee18c3f562cee54907a3329223c824332889fb74b745ddfd0a9b1c6" - ], - "version": "==0.6.0" - }, - "multidict": { - "hashes": [ - "sha256:317f96bc0950d249e96d8d29ab556d01dd38888fbe68324f46fd834b430169f1", - "sha256:42f56542166040b4474c0c608ed051732033cd821126493cf25b6c276df7dd35", - "sha256:4b7df040fb5fe826d689204f9b544af469593fb3ff3a069a6ad3409f742f5928", - "sha256:544fae9261232a97102e27a926019100a9db75bec7b37feedd74b3aa82f29969", - "sha256:620b37c3fea181dab09267cd5a84b0f23fa043beb8bc50d8474dd9694de1fa6e", - "sha256:6e6fef114741c4d7ca46da8449038ec8b1e880bbe68674c01ceeb1ac8a648e78", - "sha256:7774e9f6c9af3f12f296131453f7b81dabb7ebdb948483362f5afcaac8a826f1", - "sha256:85cb26c38c96f76b7ff38b86c9d560dea10cf3459bb5f4caf72fc1bb932c7136", - "sha256:a326f4240123a2ac66bb163eeba99578e9d63a8654a59f4688a79198f9aa10f8", - "sha256:ae402f43604e3b2bc41e8ea8b8526c7fa7139ed76b0d64fc48e28125925275b2", - "sha256:aee283c49601fa4c13adc64c09c978838a7e812f85377ae130a24d7198c0331e", - "sha256:b51249fdd2923739cd3efc95a3d6c363b67bbf779208e9f37fd5e68540d1a4d4", - "sha256:bb519becc46275c594410c6c28a8a0adc66fe24fef154a9addea54c1adb006f5", - "sha256:c2c37185fb0af79d5c117b8d2764f4321eeb12ba8c141a95d0aa8c2c1d0a11dd", - "sha256:dc561313279f9d05a3d0ffa89cd15ae477528ea37aa9795c4654588a3287a9ab", - "sha256:e439c9a10a95cb32abd708bb8be83b2134fa93790a4fb0535ca36db3dda94d20", - "sha256:fc3b4adc2ee8474cb3cd2a155305d5f8eda0a9c91320f83e55748e1fcb68f8e3" - ], - "version": "==4.7.5" - }, - "opml": { - "hashes": [ - "sha256:db1eef2a251b8af33e2eabb62baf922006dbd8c66c742931090e331a0362a770" - ], - "index": "pypi", - "version": "==0.5" - }, - "psycopg2-binary": { - "hashes": [ - "sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29", - "sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03", - "sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039", - "sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881", - "sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309", - "sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed", - "sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b", - "sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3", - "sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7", - "sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b", - "sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03", - "sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103", - "sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d", - "sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35", - "sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b", - "sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49", - "sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70", - "sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e", - "sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e", - "sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e", - "sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103", - "sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6", - "sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1", - "sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9", - "sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e", - "sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f", - "sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd", - "sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8", - "sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f", - "sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4", - "sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964", - "sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08" - ], - "index": "pypi", - "version": "==2.8.4" - }, - "python-dateutil": { - "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" - ], - "index": "pypi", - "version": "==2.8.1" - }, - "python-editor": { - "hashes": [ - "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", - "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" - ], - "version": "==1.0.4" - }, - "python-http-client": { - "hashes": [ - "sha256:10bfbc7ecd25e55215680ce2827b1a3bf4e06ba4a59758039d047d6104b6f169" - ], - "version": "==3.2.5" - }, - "python-mimeparse": { - "hashes": [ - "sha256:76e4b03d700a641fd7761d3cd4fdbbdcd787eade1ebfac43f877016328334f78", - "sha256:a295f03ff20341491bfe4717a39cd0a8cc9afad619ba44b77e86b0ab8a2b8282" - ], - "version": "==1.6.0" - }, - "pytz": { - "hashes": [ - "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", - "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" - ], - "version": "==2019.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "requests-futures": { - "hashes": [ - "sha256:35547502bf1958044716a03a2f47092a89efe8f9789ab0c4c528d9c9c30bc148" - ], - "index": "pypi", - "version": "==1.0.0" - }, - "sendgrid": { - "hashes": [ - "sha256:8112a3e2edd4cef208ed46335f058d2782397b395b96a472dcfd5e2618cf621b", - "sha256:e2b8bd90522f22a22d2557aee9cb77d0c372a8c4ca35e65d062b4ecaf0b9a4bd" - ], - "index": "pypi", - "version": "==6.1.2" - }, - "six": { - "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" - ], - "version": "==1.14.0" - }, - "soupsieve": { - "hashes": [ - "sha256:e914534802d7ffd233242b785229d5ba0766a7f487385e3f714446a07bf540ae", - "sha256:fcd71e08c0aee99aca1b73f45478549ee7e7fc006d51b37bec9e9def7dc22b69" - ], - "version": "==2.0" - }, - "sqlalchemy": { - "hashes": [ - "sha256:64a7b71846db6423807e96820993fa12a03b89127d278290ca25c0b11ed7b4fb" - ], - "index": "pypi", - "version": "==1.3.13" - }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - }, - "werkzeug": { - "hashes": [ - "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096", - "sha256:6dc65cf9091cf750012f56f2cad759fa9e879f511b5ff8685e456b4e3bf90d16" - ], - "version": "==1.0.0" - }, - "wtforms": { - "hashes": [ - "sha256:0cdbac3e7f6878086c334aa25dc5a33869a3954e9d1e015130d65a69309b3b61", - "sha256:e3ee092c827582c50877cdbd49e9ce6d2c5c1f6561f849b3b068c1b8029626f1" - ], - "index": "pypi", - "version": "==2.2.1" - }, - "yarl": { - "hashes": [ - "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", - "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", - "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", - "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", - "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", - "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", - "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", - "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", - "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", - "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", - "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", - "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", - "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", - "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", - "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", - "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", - "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" - ], - "version": "==1.4.2" - } - }, - "develop": {} -} diff --git a/Procfile b/Procfile deleted file mode 100644 index 7c8bf856..00000000 --- a/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -web: python src/runserver.py -init: python src/manager.py db_create diff --git a/README.md b/README.md index 042da0bf..ce3bf0f9 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ https://todo.sr.ht/~cedric/newspipe ## Main features -* can be easily deployed on Heroku or on your server; +* easy to deploy; * multiple users can use a Newspipe instance; * a RESTful API to manage your articles (or connect your own crawler); * data liberation: export and import all your account with a JSON file; * export and import feeds with OPML files; * favorite articles; * detection of inactive feeds; -* Pinboard and reddit; +* share on Pinboard and reddit; * personal management of bookmarks (with import from Pinboard). The core technologies are [Flask](http://flask.pocoo.org), @@ -32,13 +32,6 @@ A documentation is available [here](https://newspipe.readthedocs.io) and provides different ways to [install Newspipe](https://newspipe.readthedocs.io/en/latest/deployment.html). -Test Newspipe on Heroku: - -[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://builds.sr.ht/~cedric/Stegano) - -It is important to specify an application name and the URL of your instance -(*PLATFORM_URL*) through the Heroku form. - ## Deployment @@ -51,15 +44,15 @@ $ sudo apt-get install postgresql npm ## Configure and install the application ```bash -$ git clone https://git.sr.ht/~cedric/Newspipe +$ git clone https://git.sr.ht/~cedric/newspipe $ cd newspipe/ -$ pipenv install +$ poetry install ✨🍰✨ $ npm install -$ cp src/conf/conf.cfg-sample src/conf/conf.cfg -$ pipenv shell -$ python src/manager.py db_create -$ python src/runserver.py +$ cp newspipe/conf/conf.cfg-sample newspipe/conf/conf.cfg +$ poetry shell +$ python newspipe/manager.py db_create +$ python newspipe/runserver.py * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit) ``` diff --git a/app.json b/app.json deleted file mode 100644 index 48e1b6e5..00000000 --- a/app.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "Newspipe", - "description": "A news aggregator that can be deployed on Heroku.", - "keywords": [ - "aggregator", - "news", - "RSS" - ], - "website": "https://git.sr.ht/~cedric/Newspipe", - "repository": "https://git.sr.ht/~cedric/Newspipe", - "logo": "https://git.sr.ht/~cedric/Newspipe/blob/master/src/web/static/img/newspipe.png", - "scripts": { - "postdeploy": "python src/manager.py db_create" - }, - "addons": [ - "heroku-postgresql:hobby-dev", - "scheduler:standard" - ], - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-nodejs" - }, - { - "url": "https://github.com/ejholmes/heroku-buildpack-bower" - }, - { - "url": "heroku/python" - } - ], - "env": { - "PLATFORM_URL": { - "description": "Address of your Newspipe instance (for example: https://YOUR-APPLICATION-NAME.herokuapp.com/)", - "required": true, - "value": "" - }, - "ADMIN_EMAIL": { - "description": "Administrator email address.", - "required": true, - "value": "" - }, - "ADMIN_PASSWORD": { - "description": "Your password.", - "required": true, - "value": "" - }, - "SECRET_KEY": { - "description": "A secret token in order to use sessions.", - "required": true, - "value": "iYtWRvAl!S7+Gz8kabK3@CBvr" - }, - "SELF_REGISTRATION": { - "description": "If set to True, users will be able to create new accounts.", - "required": true, - "value": "false" - }, - "SECURITY_PASSWORD_SALT": { - "description": "A secret to confirm account creation with a link in an email.", - "required": true, - "value": "^HEpK@L&cP5dMR^kiz8IIZj8q" - }, - "TOKEN_VALIDITY_PERIOD": { - "description": "Validity period (in seconds) of the account confirmation link sent by email.", - "required": true, - "value": "3600" - }, - "HEROKU": "1", - "CDN_ADDRESS": "https://cdn.cedricbonhomme.org/", - "NOTIFICATION_EMAIL": "newspipe@no-reply.com", - "SENDGRID_API_KEY": "REDACTED", - "CRAWLER_RESOLV": { - "description": "Specify if the crawler should try to resolve link of articles behind proxies.", - "value": "false" - }, - "FEED_REFRESH_INTERVAL": { - "description": "Feeds refresh interval (in minutes) for the crawler.", - "value": "120" - } - } -} diff --git a/install.sh b/install.sh index 1733c718..77cde199 100644 --- a/install.sh +++ b/install.sh @@ -9,13 +9,13 @@ sudo apt-get install npm -pipenv install +poetry install npm install -cp src/conf/conf.cfg-sample src/conf/conf.cfg +cp newspipe/conf/conf.cfg-sample newspipe/conf/conf.cfg # Delete default database configuration -sed -i '/database/d' src/conf/conf.cfg -sed -i '/database_url/d' src/conf/conf.cfg +sed -i '/database/d' newspipe/conf/conf.cfg +sed -i '/database_url/d' newspipe/conf/conf.cfg # Configuration of the database if [ "$1" == postgres ]; then @@ -30,21 +30,21 @@ if [ "$1" == postgres ]; then echo "GRANT ALL PRIVILEGES ON DATABASE aggregator TO pgsqluser;" | sudo -u postgres psql # Add configuration lines for PostgreSQL - echo '[database]' >> src/conf/conf.cfg - echo 'database_url = postgres://pgsqluser:pgsqlpwd@127.0.0.1:5433/aggregator' >> src/conf/conf.cfg + echo '[database]' >> newspipe/conf/conf.cfg + echo 'database_url = postgres://pgsqluser:pgsqlpwd@127.0.0.1:5433/aggregator' >> newspipe/conf/conf.cfg elif [ "$1" == sqlite ]; then # Add configuration lines for SQLite echo "Configuring the SQLite database..." - echo '[database]' >> src/conf/conf.cfg - echo 'database_url = sqlite:///newspipe.db' >> src/conf/conf.cfg + echo '[database]' >> newspipe/conf/conf.cfg + echo 'database_url = sqlite:///newspipe.db' >> newspipe/conf/conf.cfg fi -pipenv shell +poetry shell echo "Initialization of the database..." -python src/manager.py db_empty -python src/manager.py db_create +python newspipe/manager.py db_empty +python newspipe/manager.py db_create echo "Launching Newspipe..." -python src/runserver.py +python newspipe/runserver.py diff --git a/newspipe/bootstrap.py b/newspipe/bootstrap.py new file mode 100644 index 00000000..8e5413e0 --- /dev/null +++ b/newspipe/bootstrap.py @@ -0,0 +1,80 @@ +#! /usr/bin/env python +# -*- coding: utf-8 - + +# required imports and code execution for basic functionning + +import os +import conf +import logging +import flask_restless +from urllib.parse import urlsplit + + +def set_logging(log_path=None, log_level=logging.INFO, modules=(), + log_format='%(asctime)s %(levelname)s %(message)s'): + if not modules: + modules = ('root', 'bootstrap', 'runserver', + 'web', 'crawler.default_crawler', 'manager', 'plugins') + if conf.ON_HEROKU: + log_format = '%(levelname)s %(message)s' + if log_path: + if not os.path.exists(os.path.dirname(log_path)): + os.makedirs(os.path.dirname(log_path)) + if not os.path.exists(log_path): + open(log_path, 'w').close() + handler = logging.FileHandler(log_path) + else: + handler = logging.StreamHandler() + formater = logging.Formatter(log_format) + handler.setFormatter(formater) + for logger_name in modules: + logger = logging.getLogger(logger_name) + logger.addHandler(handler) + for handler in logger.handlers: + handler.setLevel(log_level) + logger.setLevel(log_level) + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +# Create Flask application +application = Flask('web') +if os.environ.get('Newspipe_TESTING', False) == 'true': + application.debug = logging.DEBUG + application.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + application.config['TESTING'] = True +else: + application.debug = conf.LOG_LEVEL <= logging.DEBUG + application.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + application.config['SQLALCHEMY_DATABASE_URI'] \ + = conf.SQLALCHEMY_DATABASE_URI + if 'postgres' in conf.SQLALCHEMY_DATABASE_URI: + application.config['SQLALCHEMY_POOL_SIZE'] = 15 + application.config['SQLALCHEMY_MAX_OVERFLOW'] = 0 + +scheme, domain, _, _, _ = urlsplit(conf.PLATFORM_URL) +application.config['SERVER_NAME'] = domain +application.config['PREFERRED_URL_SCHEME'] = scheme + +set_logging(conf.LOG_PATH, log_level=conf.LOG_LEVEL) + +# Create secrey key so we can use sessions +application.config['SECRET_KEY'] = getattr(conf, 'WEBSERVER_SECRET', None) +if not application.config['SECRET_KEY']: + application.config['SECRET_KEY'] = os.urandom(12) + +application.config['SECURITY_PASSWORD_SALT'] = getattr(conf, + 'SECURITY_PASSWORD_SALT', None) +if not application.config['SECURITY_PASSWORD_SALT']: + application.config['SECURITY_PASSWORD_SALT'] = os.urandom(12) + +db = SQLAlchemy(application) + +# Create the Flask-Restless API manager. +manager = flask_restless.APIManager(application, flask_sqlalchemy_db=db) + + +def populate_g(): + from flask import g + g.db = db + g.app = application diff --git a/newspipe/conf.py b/newspipe/conf.py new file mode 100644 index 00000000..ced602ca --- /dev/null +++ b/newspipe/conf.py @@ -0,0 +1,126 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +""" Program variables. + +This file contain the variables used by the application. +""" +import os +import logging + +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) +PATH = os.path.abspath(".") +API_ROOT = '/api/v2.0' + +# available languages +LANGUAGES = { + 'en': 'English', + 'fr': 'French' +} + +TIME_ZONE = { + "en": "US/Eastern", + "fr": "Europe/Paris" +} + +ON_HEROKU = int(os.environ.get('HEROKU', 0)) == 1 +DEFAULTS = {"platform_url": "https://www.newspipe.org/", + "self_registration": "false", + "cdn_address": "", + "admin_email": "info@newspipe.org", + "sendgrid_api_key": "", + "token_validity_period": "3600", + "default_max_error": "3", + "log_path": "newspipe.log", + "log_level": "info", + "secret_key": "", + "security_password_salt": "", + "enabled": "false", + "notification_email": "info@newspipe.org", + "tls": "false", + "ssl": "true", + "host": "0.0.0.0", + "port": "5000", + "crawling_method": "default", + "crawler_user_agent": "Newspipe (https://github.com/newspipe)", + "crawler_timeout": "30", + "crawler_resolv": "false", + "feed_refresh_interval": "120" + } + +if not ON_HEROKU: + import configparser as confparser + # load the configuration + config = confparser.SafeConfigParser(defaults=DEFAULTS) + config.read(os.path.join(BASE_DIR, "conf/conf.cfg")) +else: + class Config(object): + def get(self, _, name): + return os.environ.get(name.upper(), DEFAULTS.get(name)) + + def getint(self, _, name): + return int(self.get(_, name)) + + def getboolean(self, _, name): + value = self.get(_, name) + if value == 'true': + return True + elif value == 'false': + return False + return None + config = Config() + + +WEBSERVER_HOST = config.get('webserver', 'host') +WEBSERVER_PORT = config.getint('webserver', 'port') +WEBSERVER_SECRET = config.get('webserver', 'secret_key') +WEBSERVER_DEBUG = config.getboolean('webserver', 'debug') + +CDN_ADDRESS = config.get('cdn', 'cdn_address') + +try: + PLATFORM_URL = config.get('misc', 'platform_url') +except: + PLATFORM_URL = "https://www.newspipe.org/" +ADMIN_EMAIL = config.get('misc', 'admin_email') +SELF_REGISTRATION = config.getboolean('misc', 'self_registration') +SECURITY_PASSWORD_SALT = config.get('misc', 'security_password_salt') +try: + TOKEN_VALIDITY_PERIOD = config.getint('misc', 'token_validity_period') +except: + TOKEN_VALIDITY_PERIOD = int(config.get('misc', 'token_validity_period')) +if not ON_HEROKU: + LOG_PATH = os.path.abspath(config.get('misc', 'log_path')) +else: + LOG_PATH = '' +LOG_LEVEL = {'debug': logging.DEBUG, + 'info': logging.INFO, + 'warn': logging.WARN, + 'error': logging.ERROR, + 'fatal': logging.FATAL}[config.get('misc', 'log_level')] + +SQLALCHEMY_DATABASE_URI = config.get('database', 'database_url') + +CRAWLING_METHOD = config.get('crawler', 'crawling_method') +CRAWLER_USER_AGENT = config.get('crawler', 'user_agent') +DEFAULT_MAX_ERROR = config.getint('crawler', 'default_max_error') +ERROR_THRESHOLD = int(DEFAULT_MAX_ERROR / 2) +CRAWLER_TIMEOUT = config.get('crawler', 'timeout') +CRAWLER_RESOLV = config.getboolean('crawler', 'resolv') +try: + FEED_REFRESH_INTERVAL = config.getint('crawler', 'feed_refresh_interval') +except: + FEED_REFRESH_INTERVAL = int(config.get('crawler', 'feed_refresh_interval')) + +NOTIFICATION_EMAIL = config.get('notification', 'notification_email') +NOTIFICATION_HOST = config.get('notification', 'host') +NOTIFICATION_PORT = config.getint('notification', 'port') +NOTIFICATION_TLS = config.getboolean('notification', 'tls') +NOTIFICATION_SSL = config.getboolean('notification', 'ssl') +NOTIFICATION_USERNAME = config.get('notification', 'username') +NOTIFICATION_PASSWORD = config.get('notification', 'password') +SENDGRID_API_KEY = config.get('notification', 'sendgrid_api_key') +POSTMARK_API_KEY = '' + +CSRF_ENABLED = True +# slow database query threshold (in seconds) +DATABASE_QUERY_TIMEOUT = 0.5 diff --git a/newspipe/conf/conf.cfg-sample b/newspipe/conf/conf.cfg-sample new file mode 100644 index 00000000..ed14a4d9 --- /dev/null +++ b/newspipe/conf/conf.cfg-sample @@ -0,0 +1,31 @@ +[webserver] +host = 0.0.0.0 +port = 5000 +secret_key = a secret only you know +debug = true +[cdn] +cdn_address = https://cdn.cedricbonhomme.org/ +[misc] +platform_url = http://127.0.0.1:5000/ +admin_email = +security_password_salt = a secret to confirm user account +token_validity_period = 3600 +log_path = ./var/log/newspipe.log +log_level = info +[database] +database_url = sqlite:///newspipe.db +[crawler] +crawling_method = default +default_max_error = 6 +user_agent = Newspipe (https://gitlab.com/newspipe/newspipe) +timeout = 30 +resolv = false +feed_refresh_interval = 120 +[notification] +notification_email = Newspipe@no-reply.com +host = smtp.googlemail.com +port = 465 +tls = false +ssl = true +username = your-gmail-username +password = your-gmail-password diff --git a/newspipe/crawler/default_crawler.py b/newspipe/crawler/default_crawler.py new file mode 100644 index 00000000..79a746b5 --- /dev/null +++ b/newspipe/crawler/default_crawler.py @@ -0,0 +1,181 @@ +#! /usr/bin/env python +# -*- coding: utf-8 - + +# newspipe - A Web based news aggregator. +# Copyright (C) 2010-2019 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 4.2 $" +__date__ = "$Date: 2010/09/02 $" +__revision__ = "$Date: 2019/05/21 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "AGPLv3" + +import io +import asyncio +import logging +import feedparser +import dateutil.parser +from datetime import datetime, timezone, timedelta +from sqlalchemy import or_ + +import conf +from bootstrap import db +from web.models import User +from web.controllers import FeedController, ArticleController +from lib.utils import jarr_get +from lib.feed_utils import construct_feed_from, is_parsing_ok +from lib.article_utils import construct_article, extract_id, \ + get_article_content + +logger = logging.getLogger(__name__) + +sem = asyncio.Semaphore(5) + + +async def parse_feed(user, feed): + """ + Fetch a feed. + Update the feed and return the articles. + """ + parsed_feed = None + up_feed = {} + articles = [] + resp = None + #with (await sem): + try: + logger.info('Retrieving feed {}'.format(feed.link)) + resp = await jarr_get(feed.link, timeout=5) + except Exception as e: + logger.info('Problem when reading feed {}'.format(feed.link)) + return + finally: + if None is resp: + return + try: + content = io.BytesIO(resp.content) + parsed_feed = feedparser.parse(content) + except Exception as e: + up_feed['last_error'] = str(e) + up_feed['error_count'] = feed.error_count + 1 + logger.exception("error when parsing feed: " + str(e)) + finally: + up_feed['last_retrieved'] = datetime.now(dateutil.tz.tzlocal()) + if parsed_feed is None: + try: + FeedController().update({'id': feed.id}, up_feed) + except Exception as e: + logger.exception('something bad here: ' + str(e)) + return + + if not is_parsing_ok(parsed_feed): + up_feed['last_error'] = str(parsed_feed['bozo_exception']) + up_feed['error_count'] = feed.error_count + 1 + FeedController().update({'id': feed.id}, up_feed) + return + if parsed_feed['entries'] != []: + articles = parsed_feed['entries'] + + up_feed['error_count'] = 0 + up_feed['last_error'] = "" + + # Feed information + try: + construct_feed_from(feed.link, parsed_feed).update(up_feed) + except: + logger.exception('error when constructing feed: {}'.format(feed.link)) + if feed.title and 'title' in up_feed: + # do not override the title set by the user + del up_feed['title'] + FeedController().update({'id': feed.id}, up_feed) + + return articles + + +async def insert_articles(queue, nḅ_producers=1): + """Consumer coroutines. + """ + nb_producers_done = 0 + while True: + item = await queue.get() + if item is None: + nb_producers_done += 1 + if nb_producers_done == nḅ_producers: + print('All producers done.') + print('Process finished.') + break + continue + + user, feed, articles = item + + + if None is articles: + logger.info('None') + articles = [] + + logger.info('Inserting articles for {}'.format(feed.link)) + + art_contr = ArticleController(user.id) + for article in articles: + new_article = await construct_article(article, feed) + + try: + existing_article_req = art_contr.read( + user_id=user.id, + feed_id=feed.id, + entry_id=extract_id(article)) + except Exception as e: + logger.exception("existing_article_req: " + str(e)) + continue + exist = existing_article_req.count() != 0 + if exist: + continue + + # insertion of the new article + try: + art_contr.create(**new_article) + logger.info('New article added: {}'.format(new_article['link'])) + except Exception: + logger.exception('Error when inserting article in database.') + continue + + +async def retrieve_feed(queue, users, feed_id=None): + """ + Launch the processus. + """ + for user in users: + logger.info('Starting to retrieve feeds for {}'.format(user.nickname)) + filters = {} + filters['user_id'] = user.id + if feed_id is not None: + filters['id'] = feed_id + filters['enabled'] = True + filters['error_count__lt'] = conf.DEFAULT_MAX_ERROR + filters['last_retrieved__lt'] = datetime.now() - \ + timedelta(minutes=conf.FEED_REFRESH_INTERVAL) + feeds = FeedController().read(**filters).all() + + if feeds == []: + logger.info('No feed to retrieve for {}'.format(user.nickname)) + + for feed in feeds: + articles = await parse_feed(user, feed) + await queue.put((user, feed, articles)) + + await queue.put(None) diff --git a/newspipe/lib/__init__.py b/newspipe/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/newspipe/lib/article_utils.py b/newspipe/lib/article_utils.py new file mode 100644 index 00000000..9891e29f --- /dev/null +++ b/newspipe/lib/article_utils.py @@ -0,0 +1,186 @@ +import html +import logging +import re +from datetime import datetime, timezone +from enum import Enum +from urllib.parse import SplitResult, urlsplit, urlunsplit + +import dateutil.parser +from bs4 import BeautifulSoup, SoupStrainer +from requests.exceptions import MissingSchema + +import conf +from lib.utils import jarr_get + +logger = logging.getLogger(__name__) +PROCESSED_DATE_KEYS = {'published', 'created', 'updated'} + + +def extract_id(entry): + """ extract a value from an entry that will identify it among the other of + that feed""" + return entry.get('entry_id') or entry.get('id') or entry['link'] + + +async def construct_article(entry, feed, fields=None, fetch=True): + "Safe method to transform a feedparser entry into an article" + now = datetime.utcnow() + article = {} + def push_in_article(key, value): + if not fields or key in fields: + article[key] = value + push_in_article('feed_id', feed.id) + push_in_article('user_id', feed.user_id) + push_in_article('entry_id', extract_id(entry)) + push_in_article('retrieved_date', now) + if not fields or 'date' in fields: + for date_key in PROCESSED_DATE_KEYS: + if entry.get(date_key): + try: + article['date'] = dateutil.parser.parse(entry[date_key])\ + .astimezone(timezone.utc) + except Exception as e: + logger.exception(e) + else: + break + push_in_article('content', get_article_content(entry)) + if fields is None or {'link', 'title'}.intersection(fields): + link, title = await get_article_details(entry, fetch) + push_in_article('link', link) + push_in_article('title', title) + if 'content' in article: + #push_in_article('content', clean_urls(article['content'], link)) + push_in_article('content', article['content']) + push_in_article('tags', {tag.get('term').strip() + for tag in entry.get('tags', []) \ + if tag and tag.get('term', False)}) + return article + + +def get_article_content(entry): + content = '' + if entry.get('content'): + content = entry['content'][0]['value'] + elif entry.get('summary'): + content = entry['summary'] + return content + + +async def get_article_details(entry, fetch=True): + article_link = entry.get('link') + article_title = html.unescape(entry.get('title', '')) + if fetch and conf.CRAWLER_RESOLV and article_link or not article_title: + try: + # resolves URL behind proxies (like feedproxy.google.com) + response = await jarr_get(article_link, timeout=5) + except MissingSchema: + split, failed = urlsplit(article_link), False + for scheme in 'https', 'http': + new_link = urlunsplit(SplitResult(scheme, *split[1:])) + try: + response = await jarr_get(new_link, timeout=5) + except Exception as error: + failed = True + continue + failed = False + article_link = new_link + break + if failed: + return article_link, article_title or 'No title' + except Exception as error: + logger.info("Unable to get the real URL of %s. Won't fix " + "link or title. Error: %s", article_link, error) + return article_link, article_title or 'No title' + article_link = response.url + if not article_title: + bs_parsed = BeautifulSoup(response.content, 'html.parser', + parse_only=SoupStrainer('head')) + try: + article_title = bs_parsed.find_all('title')[0].text + except IndexError: # no title + pass + return article_link, article_title or 'No title' + + +class FiltersAction(Enum): + READ = 'mark as read' + LIKED = 'mark as favorite' + SKIP = 'skipped' + + +class FiltersType(Enum): + REGEX = 'regex' + MATCH = 'simple match' + EXACT_MATCH = 'exact match' + TAG_MATCH = 'tag match' + TAG_CONTAINS = 'tag contains' + + +class FiltersTrigger(Enum): + MATCH = 'match' + NO_MATCH = 'no match' + + +def process_filters(filters, article, only_actions=None): + skipped, read, liked = False, None, False + filters = filters or [] + if only_actions is None: + only_actions = set(FiltersAction) + for filter_ in filters: + match = False + try: + pattern = filter_.get('pattern', '') + filter_type = FiltersType(filter_.get('type')) + filter_action = FiltersAction(filter_.get('action')) + filter_trigger = FiltersTrigger(filter_.get('action on')) + if filter_type is not FiltersType.REGEX: + pattern = pattern.lower() + except ValueError: + continue + if filter_action not in only_actions: + logger.debug('ignoring filter %r' % filter_) + continue + if filter_action in {FiltersType.REGEX, FiltersType.MATCH, + FiltersType.EXACT_MATCH} and 'title' not in article: + continue + if filter_action in {FiltersType.TAG_MATCH, FiltersType.TAG_CONTAINS} \ + and 'tags' not in article: + continue + title = article.get('title', '').lower() + tags = [tag.lower() for tag in article.get('tags', [])] + if filter_type is FiltersType.REGEX: + match = re.match(pattern, title) + elif filter_type is FiltersType.MATCH: + match = pattern in title + elif filter_type is FiltersType.EXACT_MATCH: + match = pattern == title + elif filter_type is FiltersType.TAG_MATCH: + match = pattern in tags + elif filter_type is FiltersType.TAG_CONTAINS: + match = any(pattern in tag for tag in tags) + take_action = match and filter_trigger is FiltersTrigger.MATCH \ + or not match and filter_trigger is FiltersTrigger.NO_MATCH + + if not take_action: + continue + + if filter_action is FiltersAction.READ: + read = True + elif filter_action is FiltersAction.LIKED: + liked = True + elif filter_action is FiltersAction.SKIP: + skipped = True + + if skipped or read or liked: + logger.info("%r applied on %r", filter_action.value, + article.get('link') or article.get('title')) + return skipped, read, liked + + +def get_skip_and_ids(entry, feed): + entry_ids = construct_article(entry, feed, + {'entry_id', 'feed_id', 'user_id'}, fetch=False) + skipped, _, _ = process_filters(feed.filters, + construct_article(entry, feed, {'title', 'tags'}, fetch=False), + {FiltersAction.SKIP}) + return skipped, entry_ids diff --git a/newspipe/lib/data.py b/newspipe/lib/data.py new file mode 100644 index 00000000..067a0a04 --- /dev/null +++ b/newspipe/lib/data.py @@ -0,0 +1,210 @@ +#! /usr/bin/env python +#-*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.2 $" +__date__ = "$Date: 2016/11/17 $" +__revision__ = "$Date: 2017/05/14 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "AGPLv3" + +# +# This file contains the import/export functions of Newspipe. +# + +import json +import opml +import datetime +from flask import jsonify + +from bootstrap import db +from web.models import User, Feed, Article +from web.models.tag import BookmarkTag +from web.controllers import BookmarkController, BookmarkTagController + + +def import_opml(nickname, opml_content): + """ + Import new feeds from an OPML file. + """ + user = User.query.filter(User.nickname == nickname).first() + try: + subscriptions = opml.from_string(opml_content) + except: + logger.exception("Parsing OPML file failed:") + raise + + def read(subsubscription, nb=0): + """ + Parse recursively through the categories and sub-categories. + """ + for subscription in subsubscription: + if len(subscription) != 0: + nb = read(subscription, nb) + else: + try: + title = subscription.text + except: + title = "" + try: + description = subscription.description + except: + description = "" + try: + link = subscription.xmlUrl + except: + continue + if None != Feed.query.filter(Feed.user_id == user.id, Feed.link == link).first(): + continue + try: + site_link = subscription.htmlUrl + except: + site_link = "" + new_feed = Feed(title=title, description=description, + link=link, site_link=site_link, + enabled=True) + user.feeds.append(new_feed) + nb += 1 + return nb + nb = read(subscriptions) + db.session.commit() + return nb + + +def import_json(nickname, json_content): + """ + Import an account from a JSON file. + """ + user = User.query.filter(User.nickname == nickname).first() + json_account = json.loads(json_content.decode("utf-8")) + nb_feeds, nb_articles = 0, 0 + # Create feeds: + for feed in json_account: + if None != Feed.query.filter(Feed.user_id == user.id, + Feed.link == feed["link"]).first(): + continue + new_feed = Feed(title=feed["title"], + description="", + link=feed["link"], + site_link=feed["site_link"], + created_date=datetime.datetime. + fromtimestamp(int(feed["created_date"])), + enabled=feed["enabled"]) + user.feeds.append(new_feed) + nb_feeds += 1 + db.session.commit() + # Create articles: + for feed in json_account: + user_feed = Feed.query.filter(Feed.user_id == user.id, + Feed.link == feed["link"]).first() + if None != user_feed: + for article in feed["articles"]: + if None == Article.query.filter(Article.user_id == user.id, + Article.feed_id == user_feed.id, + Article.link == article["link"]).first(): + new_article = Article(entry_id=article["link"], + link=article["link"], + title=article["title"], + content=article["content"], + readed=article["readed"], + like=article["like"], + retrieved_date=datetime.datetime. + fromtimestamp(int(article["retrieved_date"])), + date=datetime.datetime. + fromtimestamp(int(article["date"])), + user_id=user.id, + feed_id=user_feed.id) + user_feed.articles.append(new_article) + nb_articles += 1 + db.session.commit() + return nb_feeds, nb_articles + + +def export_json(user): + """ + Export all articles of user in JSON. + """ + articles = [] + for feed in user.feeds: + articles.append({ + "title": feed.title, + "description": feed.description, + "link": feed.link, + "site_link": feed.site_link, + "enabled": feed.enabled, + "created_date": feed.created_date.strftime('%s'), + "articles": [ { + "title": article.title, + "link": article.link, + "content": article.content, + "readed": article.readed, + "like": article.like, + "date": article.date.strftime('%s'), + "retrieved_date": article.retrieved_date.strftime('%s') + } for article in feed.articles] + }) + return jsonify(articles) + + +def import_pinboard_json(user, json_content): + """Import bookmarks from a pinboard JSON export. + """ + bookmark_contr = BookmarkController(user.id) + tag_contr = BookmarkTagController(user.id) + bookmarks = json.loads(json_content.decode("utf-8")) + nb_bookmarks = 0 + for bookmark in bookmarks: + tags = [] + for tag in bookmark['tags'].split(' '): + new_tag = BookmarkTag(text=tag.strip(), user_id=user.id) + tags.append(new_tag) + bookmark_attr = { + 'href': bookmark['href'], + 'description': bookmark['extended'], + 'title': bookmark['description'], + 'shared': [bookmark['shared']=='yes' and True or False][0], + 'to_read': [bookmark['toread']=='yes' and True or False][0], + 'time': datetime.datetime.strptime(bookmark['time'], + '%Y-%m-%dT%H:%M:%SZ'), + 'tags': tags + } + new_bookmark = bookmark_contr.create(**bookmark_attr) + nb_bookmarks += 1 + return nb_bookmarks + + +def export_bookmarks(user): + """Export all bookmarks of a user (compatible with Pinboard). + """ + bookmark_contr = BookmarkController(user.id) + bookmarks = bookmark_contr.read() + export = [] + for bookmark in bookmarks: + export.append({ + 'href': bookmark.href, + 'description': bookmark.description, + 'title': bookmark.title, + 'shared': 'yes' if bookmark.shared else 'no', + 'toread': 'yes' if bookmark.to_read else 'no', + 'time': bookmark.time.isoformat(), + 'tags': ' '.join(bookmark.tags_proxy) + }) + return jsonify(export) diff --git a/newspipe/lib/feed_utils.py b/newspipe/lib/feed_utils.py new file mode 100644 index 00000000..c2d4ca6e --- /dev/null +++ b/newspipe/lib/feed_utils.py @@ -0,0 +1,125 @@ +import html +import urllib +import logging +import requests +import feedparser +from conf import CRAWLER_USER_AGENT +from bs4 import BeautifulSoup, SoupStrainer + +from lib.utils import try_keys, try_get_icon_url, rebuild_url + +logger = logging.getLogger(__name__) +logging.captureWarnings(True) +ACCEPTED_MIMETYPES = ('application/rss+xml', 'application/rdf+xml', + 'application/atom+xml', 'application/xml', 'text/xml') + + +def is_parsing_ok(parsed_feed): + return parsed_feed['entries'] or not parsed_feed['bozo'] + + +def escape_keys(*keys): + def wrapper(func): + def metawrapper(*args, **kwargs): + result = func(*args, **kwargs) + for key in keys: + if key in result: + result[key] = html.unescape(result[key] or '') + return result + return metawrapper + return wrapper + + +@escape_keys('title', 'description') +def construct_feed_from(url=None, fp_parsed=None, feed=None, query_site=True): + requests_kwargs = {'headers': {'User-Agent': CRAWLER_USER_AGENT}, + 'verify': False} + if url is None and fp_parsed is not None: + url = fp_parsed.get('url') + if url is not None and fp_parsed is None: + try: + response = requests.get(url, **requests_kwargs) + fp_parsed = feedparser.parse(response.content, + request_headers=response.headers) + except Exception: + logger.exception('failed to retrieve that url') + fp_parsed = {'bozo': True} + assert url is not None and fp_parsed is not None + feed = feed or {} + feed_split = urllib.parse.urlsplit(url) + site_split = None + if is_parsing_ok(fp_parsed): + feed['link'] = url + feed['site_link'] = try_keys(fp_parsed['feed'], 'href', 'link') + feed['title'] = fp_parsed['feed'].get('title') + feed['description'] = try_keys(fp_parsed['feed'], 'subtitle', 'title') + feed['icon_url'] = try_keys(fp_parsed['feed'], 'icon') + else: + feed['site_link'] = url + + if feed.get('site_link'): + feed['site_link'] = rebuild_url(feed['site_link'], feed_split) + site_split = urllib.parse.urlsplit(feed['site_link']) + + if feed.get('icon_url'): + feed['icon_url'] = try_get_icon_url( + feed['icon_url'], site_split, feed_split) + if feed['icon_url'] is None: + del feed['icon_url'] + + if not feed.get('site_link') or not query_site \ + or all(bool(feed.get(k)) for k in ('link', 'title', 'icon_url')): + return feed + + try: + response = requests.get(feed['site_link'], **requests_kwargs) + except requests.exceptions.InvalidSchema as e: + return feed + except: + logger.exception('failed to retrieve %r', feed['site_link']) + return feed + bs_parsed = BeautifulSoup(response.content, 'html.parser', + parse_only=SoupStrainer('head')) + + if not feed.get('title'): + try: + feed['title'] = bs_parsed.find_all('title')[0].text + except Exception: + pass + + def check_keys(**kwargs): + def wrapper(elem): + for key, vals in kwargs.items(): + if not elem.has_attr(key): + return False + if not all(val in elem.attrs[key] for val in vals): + return False + return True + return wrapper + + if not feed.get('icon_url'): + icons = bs_parsed.find_all(check_keys(rel=['icon', 'shortcut'])) + if not len(icons): + icons = bs_parsed.find_all(check_keys(rel=['icon'])) + if len(icons) >= 1: + for icon in icons: + feed['icon_url'] = try_get_icon_url(icon.attrs['href'], + site_split, feed_split) + if feed['icon_url'] is not None: + break + + if feed.get('icon_url') is None: + feed['icon_url'] = try_get_icon_url('/favicon.ico', + site_split, feed_split) + if 'icon_url' in feed and feed['icon_url'] is None: + del feed['icon_url'] + + if not feed.get('link'): + for type_ in ACCEPTED_MIMETYPES: + alternates = bs_parsed.find_all(check_keys( + rel=['alternate'], type=[type_])) + if len(alternates) >= 1: + feed['link'] = rebuild_url(alternates[0].attrs['href'], + feed_split) + break + return feed diff --git a/newspipe/lib/misc_utils.py b/newspipe/lib/misc_utils.py new file mode 100755 index 00000000..8fb2d284 --- /dev/null +++ b/newspipe/lib/misc_utils.py @@ -0,0 +1,185 @@ +#! /usr/bin/env python +#-*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 1.10 $" +__date__ = "$Date: 2010/12/07 $" +__revision__ = "$Date: 2016/11/22 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "AGPLv3" + +import re +import os +import sys +import glob +import json +import logging +import operator +import urllib +import subprocess +import sqlalchemy +try: + from urlparse import urlparse, parse_qs, urlunparse +except: + from urllib.parse import urlparse, parse_qs, urlunparse, urljoin +from collections import Counter +from contextlib import contextmanager +from flask import request + +import conf +from web.controllers import ArticleController +from lib.utils import clear_string + +logger = logging.getLogger(__name__) + +ALLOWED_EXTENSIONS = set(['xml', 'opml', 'json']) + + +def is_safe_url(target): + """ + Ensures that a redirect target will lead to the same server. + """ + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and \ + ref_url.netloc == test_url.netloc + + +def get_redirect_target(): + """ + Looks at various hints to find the redirect target. + """ + for target in request.args.get('next'), request.referrer: + if not target: + continue + if is_safe_url(target): + return target + + +def allowed_file(filename): + """ + Check if the uploaded file is allowed. + """ + return '.' in filename and \ + filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS + + +@contextmanager +def opened_w_error(filename, mode="r"): + try: + f = open(filename, mode) + except IOError as err: + yield None, err + else: + try: + yield f, None + finally: + f.close() + + +def fetch(id, feed_id=None): + """ + Fetch the feeds in a new processus. + The default crawler ("asyncio") is launched with the manager. + """ + cmd = [sys.executable, conf.BASE_DIR + '/manager.py', 'fetch_asyncio', + '--user_id='+str(id)] + if feed_id: + cmd.append('--feed_id='+str(feed_id)) + return subprocess.Popen(cmd, stdout=subprocess.PIPE) + + +def history(user_id, year=None, month=None): + """ + Sort articles by year and month. + """ + articles_counter = Counter() + articles = ArticleController(user_id).read() + if None != year: + articles = articles.filter(sqlalchemy.extract('year', 'Article.date') == year) + if None != month: + articles = articles.filter(sqlalchemy.extract('month', 'Article.date') == month) + for article in articles.all(): + if None != year: + articles_counter[article.date.month] += 1 + else: + articles_counter[article.date.year] += 1 + return articles_counter, articles + + +def clean_url(url): + """ + Remove utm_* parameters + """ + parsed_url = urlparse(url) + qd = parse_qs(parsed_url.query, keep_blank_values=True) + filtered = dict((k, v) for k, v in qd.items() + if not k.startswith('utm_')) + return urlunparse([ + parsed_url.scheme, + parsed_url.netloc, + urllib.parse.quote(urllib.parse.unquote(parsed_url.path)), + parsed_url.params, + urllib.parse.urlencode(filtered, doseq=True), + parsed_url.fragment + ]).rstrip('=') + + +def load_stop_words(): + """ + Load the stop words and return them in a list. + """ + stop_words_lists = glob.glob(os.path.join(conf.BASE_DIR, + 'web/var/stop_words/*.txt')) + stop_words = [] + + for stop_wods_list in stop_words_lists: + with opened_w_error(stop_wods_list, "r") as (stop_wods_file, err): + if err: + stop_words = [] + else: + stop_words += stop_wods_file.read().split(";") + return stop_words + + +def top_words(articles, n=10, size=5): + """ + Return the n most frequent words in a list. + """ + stop_words = load_stop_words() + words = Counter() + wordre = re.compile(r'\b\w{%s,}\b' % size, re.I) + for article in articles: + for word in [elem.lower() for elem in + wordre.findall(clear_string(article.content)) \ + if elem.lower() not in stop_words]: + words[word] += 1 + return words.most_common(n) + + +def tag_cloud(tags): + """ + Generates a tags cloud. + """ + tags.sort(key=operator.itemgetter(0)) + max_tag = max([tag[1] for tag in tags]) + return '\n'.join([('%s' % \ + (min(1 + count * 7 / max_tag, 7), word)) for (word, count) in tags]) diff --git a/newspipe/lib/utils.py b/newspipe/lib/utils.py new file mode 100644 index 00000000..d206b769 --- /dev/null +++ b/newspipe/lib/utils.py @@ -0,0 +1,89 @@ +import re +import types +import urllib +import logging +import requests +from hashlib import md5 +from flask import request, url_for + +import conf + +logger = logging.getLogger(__name__) + + +def default_handler(obj, role='admin'): + """JSON handler for default query formatting""" + if hasattr(obj, 'isoformat'): + return obj.isoformat() + if hasattr(obj, 'dump'): + return obj.dump(role=role) + if isinstance(obj, (set, frozenset, types.GeneratorType)): + return list(obj) + if isinstance(obj, BaseException): + return str(obj) + raise TypeError("Object of type %s with value of %r " + "is not JSON serializable" % (type(obj), obj)) + + +def try_keys(dico, *keys): + for key in keys: + if key in dico: + return dico[key] + return + + +def rebuild_url(url, base_split): + split = urllib.parse.urlsplit(url) + if split.scheme and split.netloc: + return url # url is fine + new_split = urllib.parse.SplitResult( + scheme=split.scheme or base_split.scheme, + netloc=split.netloc or base_split.netloc, + path=split.path, query='', fragment='') + return urllib.parse.urlunsplit(new_split) + + +def try_get_icon_url(url, *splits): + for split in splits: + if split is None: + continue + rb_url = rebuild_url(url, split) + response = None + # if html in content-type, we assume it's a fancy 404 page + try: + response = jarr_get(rb_url) + content_type = response.headers.get('content-type', '') + except Exception: + pass + else: + if response is not None and response.ok \ + and 'html' not in content_type and response.content: + return response.url + return None + + +def to_hash(text): + return md5(text.encode('utf8') if hasattr(text, 'encode') else text)\ + .hexdigest() + + +def clear_string(data): + """ + Clear a string by removing HTML tags, HTML special caracters + and consecutive white spaces (more that one). + """ + p = re.compile('<[^>]+>') # HTML tags + q = re.compile('\s') # consecutive white spaces + return p.sub('', q.sub(' ', data)) + + +def redirect_url(default='home'): + return request.args.get('next') or request.referrer or url_for(default) + + +async def jarr_get(url, **kwargs): + request_kwargs = {'verify': False, 'allow_redirects': True, + 'timeout': conf.CRAWLER_TIMEOUT, + 'headers': {'User-Agent': conf.CRAWLER_USER_AGENT}} + request_kwargs.update(kwargs) + return requests.get(url, **request_kwargs) diff --git a/newspipe/manager.py b/newspipe/manager.py new file mode 100755 index 00000000..9535ac59 --- /dev/null +++ b/newspipe/manager.py @@ -0,0 +1,86 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import logging +from datetime import datetime +from werkzeug.security import generate_password_hash +from bootstrap import application, db, conf, set_logging +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand + +import web.models +from web.controllers import UserController + +logger = logging.getLogger('manager') + +Migrate(application, db) + +manager = Manager(application) +manager.add_command('db', MigrateCommand) + + +@manager.command +def db_empty(): + "Will drop every datas stocked in db." + with application.app_context(): + web.models.db_empty(db) + + +@manager.command +def db_create(): + "Will create the database from conf parameters." + admin = {'is_admin': True, 'is_api': True, 'is_active': True, + 'nickname': 'admin', + 'pwdhash': generate_password_hash( + os.environ.get("ADMIN_PASSWORD", "password"))} + with application.app_context(): + db.create_all() + UserController(ignore_context=True).create(**admin) + +@manager.command +def create_admin(nickname, password): + "Will create an admin user." + admin = {'is_admin': True, 'is_api': True, 'is_active': True, + 'nickname': nickname, + 'pwdhash': generate_password_hash(password)} + with application.app_context(): + UserController(ignore_context=True).create(**admin) + +@manager.command +def fetch_asyncio(user_id=None, feed_id=None): + "Crawl the feeds with asyncio." + import asyncio + + with application.app_context(): + from crawler import default_crawler + filters = {} + filters['is_active'] = True + filters['automatic_crawling'] = True + if None is not user_id: + filters['id'] = user_id + users = UserController().read(**filters).all() + + try: + feed_id = int(feed_id) + except: + feed_id = None + + + loop = asyncio.get_event_loop() + queue = asyncio.Queue(maxsize=3, loop=loop) + + producer_coro = default_crawler.retrieve_feed(queue, users, feed_id) + consumer_coro = default_crawler.insert_articles(queue, 1) + + logger.info('Starting crawler.') + start = datetime.now() + loop.run_until_complete(asyncio.gather(producer_coro, consumer_coro)) + end = datetime.now() + loop.close() + logger.info('Crawler finished in {} seconds.' \ + .format((end - start).seconds)) + + +if __name__ == '__main__': + manager.run() diff --git a/newspipe/notifications/emails.py b/newspipe/notifications/emails.py new file mode 100644 index 00000000..098c29bf --- /dev/null +++ b/newspipe/notifications/emails.py @@ -0,0 +1,132 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import sendgrid +from sendgrid.helpers.mail import * + +import conf +from web.decorators import async_maker + +logger = logging.getLogger(__name__) + + +@async_maker +def send_async_email(mfrom, mto, msg): + try: + s = smtplib.SMTP(conf.NOTIFICATION_HOST) + s.login(conf.NOTIFICATION_USERNAME, conf.NOTIFICATION_PASSWORD) + except Exception: + logger.exception('send_async_email raised:') + else: + s.sendmail(mfrom, mto, msg.as_string()) + s.quit() + +def send(*args, **kwargs): + """ + This functions enables to send email through SendGrid + or a SMTP server. + """ + if conf.ON_HEROKU: + send_sendgrid(**kwargs) + else: + send_smtp(**kwargs) + +def send_smtp(to="", bcc="", subject="", plaintext="", html=""): + """ + Send an email. + """ + # Create message container - the correct MIME type is multipart/alternative. + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = conf.NOTIFICATION_EMAIL + msg['To'] = to + msg['BCC'] = bcc + + # Record the MIME types of both parts - text/plain and text/html. + part1 = MIMEText(plaintext, 'plain', 'utf-8') + part2 = MIMEText(html, 'html', 'utf-8') + + # Attach parts into message container. + # According to RFC 2046, the last part of a multipart message, in this case + # the HTML message, is best and preferred. + msg.attach(part1) + msg.attach(part2) + + try: + s = smtplib.SMTP(conf.NOTIFICATION_HOST) + s.login(conf.NOTIFICATION_USERNAME, conf.NOTIFICATION_PASSWORD) + except Exception: + logger.exception("send_smtp raised:") + else: + s.sendmail(conf.NOTIFICATION_EMAIL, msg['To'] + ", " + msg['BCC'], msg.as_string()) + s.quit() + + +def send_postmark(to="", bcc="", subject="", plaintext=""): + """ + Send an email via Postmark. Used when the application is deployed on + Heroku. + Note: The Postmark team has chosen not to continue development of the + Heroku add-on as of June 30, 2017. Newspipe is now using SendGrid when + deployed on Heroku. + """ + from postmark import PMMail + try: + message = PMMail(api_key = conf.POSTMARK_API_KEY, + subject = subject, + sender = conf.NOTIFICATION_EMAIL, + text_body = plaintext) + message.to = to + if bcc != "": + message.bcc = bcc + message.send() + except Exception as e: + logger.exception('send_postmark raised:') + raise e + + +def send_sendgrid(to="", bcc="", subject="", plaintext=""): + """ + Send an email via SendGrid. Used when the application is deployed on + Heroku. + """ + sg = sendgrid.SendGridAPIClient(apikey=conf.SENDGRID_API_KEY) + + mail = Mail() + mail.from_email = Email(conf.NOTIFICATION_EMAIL) + mail.subject = subject + mail.add_content(Content('text/plain', plaintext)) + + personalization = Personalization() + personalization.add_to(Email(to)) + if bcc != "": + personalization.add_bcc(Email(bcc)) + mail.add_personalization(personalization) + + response = sg.client.mail.send.post(request_body=mail.get()) + # print(response.status_code) + # print(response.body) + # print(response.headers) diff --git a/newspipe/notifications/notifications.py b/newspipe/notifications/notifications.py new file mode 100644 index 00000000..e775f4b9 --- /dev/null +++ b/newspipe/notifications/notifications.py @@ -0,0 +1,53 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import datetime +from flask import render_template +import conf +from notifications import emails +from web.lib.user_utils import generate_confirmation_token + + +def new_account_notification(user, email): + """ + Account creation notification. + """ + token = generate_confirmation_token(user.nickname) + expire_time = datetime.datetime.now() + \ + datetime.timedelta(seconds=conf.TOKEN_VALIDITY_PERIOD) + + plaintext = render_template('emails/account_activation.txt', + user=user, platform_url=conf.PLATFORM_URL, + token=token, + expire_time=expire_time) + + emails.send(to=email, bcc=conf.NOTIFICATION_EMAIL, + subject="[Newspipe] Account creation", plaintext=plaintext) + +def new_password_notification(user, password): + """ + New password notification. + """ + plaintext = render_template('emails/new_password.txt', + user=user, password=password) + emails.send(to=user.email, + bcc=conf.NOTIFICATION_EMAIL, + subject="[Newspipe] New password", plaintext=plaintext) diff --git a/newspipe/runserver.py b/newspipe/runserver.py new file mode 100755 index 00000000..287a52f8 --- /dev/null +++ b/newspipe/runserver.py @@ -0,0 +1,66 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import calendar +from bootstrap import conf, application, populate_g +from flask_babel import Babel, format_datetime + +if conf.ON_HEROKU: + from flask_sslify import SSLify + SSLify(application, subdomains=True) + +babel = Babel(application) + + +# Jinja filters +def month_name(month_number): + return calendar.month_name[month_number] +application.jinja_env.filters['month_name'] = month_name +application.jinja_env.filters['datetime'] = format_datetime +application.jinja_env.globals['conf'] = conf + +# Views +from flask_restful import Api +from flask import g + +with application.app_context(): + populate_g() + g.api = Api(application, prefix='/api/v2.0') + g.babel = babel + + from web import views + application.register_blueprint(views.articles_bp) + application.register_blueprint(views.article_bp) + application.register_blueprint(views.feeds_bp) + application.register_blueprint(views.feed_bp) + application.register_blueprint(views.categories_bp) + application.register_blueprint(views.category_bp) + application.register_blueprint(views.icon_bp) + application.register_blueprint(views.admin_bp) + application.register_blueprint(views.users_bp) + application.register_blueprint(views.user_bp) + application.register_blueprint(views.bookmarks_bp) + application.register_blueprint(views.bookmark_bp) + + +if __name__ == '__main__': + application.run(host=conf.WEBSERVER_HOST, + port=conf.WEBSERVER_PORT, + debug=conf.WEBSERVER_DEBUG) diff --git a/newspipe/web/__init__.py b/newspipe/web/__init__.py new file mode 100644 index 00000000..ca5ddbe1 --- /dev/null +++ b/newspipe/web/__init__.py @@ -0,0 +1,8 @@ +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 8.0 $" +__date__ = "$Date: 2016/11/14 $" +__revision__ = "$Date: 2017/05/24 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +__all__ = [__version__] diff --git a/newspipe/web/controllers/__init__.py b/newspipe/web/controllers/__init__.py new file mode 100644 index 00000000..5fbc2619 --- /dev/null +++ b/newspipe/web/controllers/__init__.py @@ -0,0 +1,12 @@ +from .feed import FeedController +from .category import CategoryController +from .article import ArticleController +from .user import UserController +from .icon import IconController +from .bookmark import BookmarkController +from .tag import BookmarkTagController + + +__all__ = ['FeedController', 'CategoryController', 'ArticleController', + 'UserController', 'IconController', 'BookmarkController', + 'BookmarkTagController'] diff --git a/newspipe/web/controllers/abstract.py b/newspipe/web/controllers/abstract.py new file mode 100644 index 00000000..764ff305 --- /dev/null +++ b/newspipe/web/controllers/abstract.py @@ -0,0 +1,161 @@ +import logging +import dateutil.parser +from bootstrap import db +from datetime import datetime +from collections import defaultdict +from sqlalchemy import or_, func +from werkzeug.exceptions import Forbidden, NotFound + +logger = logging.getLogger(__name__) + + +class AbstractController: + _db_cls = None # reference to the database class + _user_id_key = 'user_id' + + def __init__(self, user_id=None, ignore_context=False): + """User id is a right management mechanism that should be used to + filter objects in database on their denormalized "user_id" field + (or "id" field for users). + Should no user_id be provided, the Controller won't apply any filter + allowing for a kind of "super user" mode. + """ + try: + self.user_id = int(user_id) + except TypeError: + self.user_id = user_id + + def _to_filters(self, **filters): + """ + Will translate filters to sqlalchemy filter. + This method will also apply user_id restriction if available. + + each parameters of the function is treated as an equality unless the + name of the parameter ends with either "__gt", "__lt", "__ge", "__le", + "__ne", "__in" ir "__like". + """ + db_filters = set() + for key, value in filters.items(): + if key == '__or__': + db_filters.add(or_(*self._to_filters(**value))) + elif key.endswith('__gt'): + db_filters.add(getattr(self._db_cls, key[:-4]) > value) + elif key.endswith('__lt'): + db_filters.add(getattr(self._db_cls, key[:-4]) < value) + elif key.endswith('__ge'): + db_filters.add(getattr(self._db_cls, key[:-4]) >= value) + elif key.endswith('__le'): + db_filters.add(getattr(self._db_cls, key[:-4]) <= value) + elif key.endswith('__ne'): + db_filters.add(getattr(self._db_cls, key[:-4]) != value) + elif key.endswith('__in'): + db_filters.add(getattr(self._db_cls, key[:-4]).in_(value)) + elif key.endswith('__contains'): + db_filters.add(getattr(self._db_cls, key[:-10]).contains(value)) + elif key.endswith('__like'): + db_filters.add(getattr(self._db_cls, key[:-6]).like(value)) + elif key.endswith('__ilike'): + db_filters.add(getattr(self._db_cls, key[:-7]).ilike(value)) + else: + db_filters.add(getattr(self._db_cls, key) == value) + return db_filters + + def _get(self, **filters): + """ Will add the current user id if that one is not none (in which case + the decision has been made in the code that the query shouldn't be user + dependent) and the user is not an admin and the filters doesn't already + contains a filter for that user. + """ + if self._user_id_key is not None and self.user_id \ + and filters.get(self._user_id_key) != self.user_id: + filters[self._user_id_key] = self.user_id + return self._db_cls.query.filter(*self._to_filters(**filters)) + + def get(self, **filters): + """Will return one single objects corresponding to filters""" + obj = self._get(**filters).first() + + if obj and not self._has_right_on(obj): + raise Forbidden({'message': 'No authorized to access %r (%r)' + % (self._db_cls.__class__.__name__, filters)}) + if not obj: + raise NotFound({'message': 'No %r (%r)' + % (self._db_cls.__class__.__name__, filters)}) + return obj + + def create(self, **attrs): + assert attrs, "attributes to update must not be empty" + if self._user_id_key is not None and self._user_id_key not in attrs: + attrs[self._user_id_key] = self.user_id + assert self._user_id_key is None or self._user_id_key in attrs \ + or self.user_id is None, \ + "You must provide user_id one way or another" + + obj = self._db_cls(**attrs) + db.session.add(obj) + db.session.flush() + db.session.commit() + return obj + + def read(self, **filters): + return self._get(**filters) + + def update(self, filters, attrs, return_objs=False, commit=True): + assert attrs, "attributes to update must not be empty" + result = self._get(**filters).update(attrs, synchronize_session=False) + if commit: + db.session.flush() + db.session.commit() + if return_objs: + return self._get(**filters) + return result + + def delete(self, obj_id): + obj = self.get(id=obj_id) + db.session.delete(obj) + try: + db.session.commit() + except Exception as e: + db.session.rollback() + return obj + + def _has_right_on(self, obj): + # user_id == None is like being admin + if self._user_id_key is None: + return True + return self.user_id is None \ + or getattr(obj, self._user_id_key, None) == self.user_id + + def _count_by(self, elem_to_group_by, filters): + if self.user_id: + filters['user_id'] = self.user_id + return dict(db.session.query(elem_to_group_by, func.count('id')) + .filter(*self._to_filters(**filters)) + .group_by(elem_to_group_by).all()) + + @classmethod + def _get_attrs_desc(cls, role, right=None): + result = defaultdict(dict) + if role == 'admin': + columns = cls._db_cls.__table__.columns.keys() + else: + assert role in {'base', 'api'}, 'unknown role %r' % role + assert right in {'read', 'write'}, \ + "right must be 'read' or 'write' with role %r" % role + columns = getattr(cls._db_cls, 'fields_%s_%s' % (role, right))() + for column in columns: + result[column] = {} + db_col = getattr(cls._db_cls, column).property.columns[0] + try: + result[column]['type'] = db_col.type.python_type + except NotImplementedError: + if db_col.default: + result[column]['type'] = db_col.default.arg.__class__ + if column not in result: + continue + if issubclass(result[column]['type'], datetime): + result[column]['default'] = datetime.utcnow() + result[column]['type'] = lambda x: dateutil.parser.parse(x) + elif db_col.default: + result[column]['default'] = db_col.default.arg + return result diff --git a/newspipe/web/controllers/article.py b/newspipe/web/controllers/article.py new file mode 100644 index 00000000..d7058229 --- /dev/null +++ b/newspipe/web/controllers/article.py @@ -0,0 +1,87 @@ +import re +import logging +import sqlalchemy +from sqlalchemy import func +from collections import Counter + +from bootstrap import db +from .abstract import AbstractController +from lib.article_utils import process_filters +from web.controllers import CategoryController, FeedController +from web.models import Article + +logger = logging.getLogger(__name__) + + +class ArticleController(AbstractController): + _db_cls = Article + + def challenge(self, ids): + """Will return each id that wasn't found in the database.""" + for id_ in ids: + if self.read(**id_).first(): + continue + yield id_ + + def count_by_category(self, **filters): + return self._count_by(Article.category_id, filters) + + def count_by_feed(self, **filters): + return self._count_by(Article.feed_id, filters) + + def count_by_user_id(self, **filters): + return dict(db.session.query(Article.user_id, func.count(Article.id)) + .filter(*self._to_filters(**filters)) + .group_by(Article.user_id).all()) + + def create(self, **attrs): + # handling special denorm for article rights + assert 'feed_id' in attrs, "must provide feed_id when creating article" + feed = FeedController( + attrs.get('user_id', self.user_id)).get(id=attrs['feed_id']) + if 'user_id' in attrs: + assert feed.user_id == attrs['user_id'] or self.user_id is None, \ + "no right on feed %r" % feed.id + attrs['user_id'], attrs['category_id'] = feed.user_id, feed.category_id + + skipped, read, liked = process_filters(feed.filters, attrs) + if skipped: + return None + article = super().create(**attrs) + return article + + def update(self, filters, attrs): + user_id = attrs.get('user_id', self.user_id) + if 'feed_id' in attrs: + feed = FeedController().get(id=attrs['feed_id']) + assert feed.user_id == user_id, "no right on feed %r" % feed.id + attrs['category_id'] = feed.category_id + if attrs.get('category_id'): + cat = CategoryController().get(id=attrs['category_id']) + assert self.user_id is None or cat.user_id == user_id, \ + "no right on cat %r" % cat.id + return super().update(filters, attrs) + + def get_history(self, year=None, month=None): + """ + Sort articles by year and month. + """ + articles_counter = Counter() + articles = self.read() + if year is not None: + articles = articles.filter( + sqlalchemy.extract('year', Article.date) == year) + if month is not None: + articles = articles.filter( + sqlalchemy.extract('month', Article.date) == month) + for article in articles.all(): + if year is not None: + articles_counter[article.date.month] += 1 + else: + articles_counter[article.date.year] += 1 + return articles_counter, articles + + def read_light(self, **filters): + return super().read(**filters).with_entities(Article.id, Article.title, + Article.readed, Article.like, Article.feed_id, Article.date, + Article.category_id).order_by(Article.date.desc()) diff --git a/newspipe/web/controllers/bookmark.py b/newspipe/web/controllers/bookmark.py new file mode 100644 index 00000000..b5413243 --- /dev/null +++ b/newspipe/web/controllers/bookmark.py @@ -0,0 +1,32 @@ +import logging +import itertools +from datetime import datetime, timedelta + +from bootstrap import db +from web.models import Bookmark +from .abstract import AbstractController +from .tag import BookmarkTagController + +logger = logging.getLogger(__name__) + + +class BookmarkController(AbstractController): + _db_cls = Bookmark + + def count_by_href(self, **filters): + return self._count_by(Bookmark.href, filters) + + def update(self, filters, attrs): + BookmarkTagController(self.user_id) \ + .read(**{'bookmark_id': filters["id"]}) \ + .delete() + + for tag in attrs['tags']: + BookmarkTagController(self.user_id).create( + **{'text': tag.text, + 'id': tag.id, + 'bookmark_id': tag.bookmark_id, + 'user_id': tag.user_id}) + + del attrs['tags'] + return super().update(filters, attrs) diff --git a/newspipe/web/controllers/category.py b/newspipe/web/controllers/category.py new file mode 100644 index 00000000..fef5ca81 --- /dev/null +++ b/newspipe/web/controllers/category.py @@ -0,0 +1,12 @@ +from .abstract import AbstractController +from web.models import Category +from .feed import FeedController + + +class CategoryController(AbstractController): + _db_cls = Category + + def delete(self, obj_id): + FeedController(self.user_id).update({'category_id': obj_id}, + {'category_id': None}) + return super().delete(obj_id) diff --git a/newspipe/web/controllers/feed.py b/newspipe/web/controllers/feed.py new file mode 100644 index 00000000..d75cd994 --- /dev/null +++ b/newspipe/web/controllers/feed.py @@ -0,0 +1,98 @@ +import logging +import itertools +from datetime import datetime, timedelta + +import conf +from .abstract import AbstractController +from .icon import IconController +from web.models import User, Feed +from lib.utils import clear_string + +logger = logging.getLogger(__name__) +DEFAULT_LIMIT = 5 +DEFAULT_MAX_ERROR = conf.DEFAULT_MAX_ERROR + + +class FeedController(AbstractController): + _db_cls = Feed + + def list_late(self, max_last, max_error=DEFAULT_MAX_ERROR, + limit=DEFAULT_LIMIT): + return [feed for feed in self.read( + error_count__lt=max_error, enabled=True, + last_retrieved__lt=max_last) + .join(User).filter(User.is_active == True) + .order_by('last_retrieved') + .limit(limit)] + + def list_fetchable(self, max_error=DEFAULT_MAX_ERROR, limit=DEFAULT_LIMIT): + now = datetime.now() + max_last = now - timedelta(minutes=60) + feeds = self.list_late(max_last, max_error, limit) + if feeds: + self.update({'id__in': [feed.id for feed in feeds]}, + {'last_retrieved': now}) + return feeds + + def get_duplicates(self, feed_id): + """ + Compare a list of documents by pair. + Pairs of duplicates are sorted by "retrieved date". + """ + feed = self.get(id=feed_id) + duplicates = [] + for pair in itertools.combinations(feed.articles[:1000], 2): + date1, date2 = pair[0].date, pair[1].date + if clear_string(pair[0].title) == clear_string(pair[1].title) \ + and (date1 - date2) < timedelta(days=1): + if pair[0].retrieved_date < pair[1].retrieved_date: + duplicates.append((pair[0], pair[1])) + else: + duplicates.append((pair[1], pair[0])) + return feed, duplicates + + def get_inactives(self, nb_days): + today = datetime.now() + inactives = [] + for feed in self.read(): + try: + last_post = feed.articles[0].date + except IndexError: + continue + except Exception as e: + logger.exception(e) + continue + elapsed = today - last_post + if elapsed > timedelta(days=nb_days): + inactives.append((feed, elapsed)) + inactives.sort(key=lambda tup: tup[1], reverse=True) + return inactives + + def count_by_category(self, **filters): + return self._count_by(Feed.category_id, filters) + + def count_by_link(self, **filters): + return self._count_by(Feed.link, filters) + + def _ensure_icon(self, attrs): + if not attrs.get('icon_url'): + return + icon_contr = IconController() + if not icon_contr.read(url=attrs['icon_url']).count(): + icon_contr.create(**{'url': attrs['icon_url']}) + + def create(self, **attrs): + self._ensure_icon(attrs) + return super().create(**attrs) + + def update(self, filters, attrs): + from .article import ArticleController + self._ensure_icon(attrs) + if 'category_id' in attrs and attrs['category_id'] == 0: + del attrs['category_id'] + elif 'category_id' in attrs: + art_contr = ArticleController(self.user_id) + for feed in self.read(**filters): + art_contr.update({'feed_id': feed.id}, + {'category_id': attrs['category_id']}) + return super().update(filters, attrs) diff --git a/newspipe/web/controllers/icon.py b/newspipe/web/controllers/icon.py new file mode 100644 index 00000000..07c4a4ef --- /dev/null +++ b/newspipe/web/controllers/icon.py @@ -0,0 +1,23 @@ +import base64 +import requests +from web.models import Icon +from .abstract import AbstractController + + +class IconController(AbstractController): + _db_cls = Icon + _user_id_key = None + + def _build_from_url(self, attrs): + if 'url' in attrs and 'content' not in attrs: + resp = requests.get(attrs['url'], verify=False) + attrs.update({'url': resp.url, + 'mimetype': resp.headers.get('content-type', None), + 'content': base64.b64encode(resp.content).decode('utf8')}) + return attrs + + def create(self, **attrs): + return super().create(**self._build_from_url(attrs)) + + def update(self, filters, attrs): + return super().update(filters, self._build_from_url(attrs)) diff --git a/newspipe/web/controllers/tag.py b/newspipe/web/controllers/tag.py new file mode 100644 index 00000000..35fd5613 --- /dev/null +++ b/newspipe/web/controllers/tag.py @@ -0,0 +1,22 @@ +import logging +import itertools +from datetime import datetime, timedelta + +from bootstrap import db +from .abstract import AbstractController +from web.models.tag import BookmarkTag + +logger = logging.getLogger(__name__) + + +class BookmarkTagController(AbstractController): + _db_cls = BookmarkTag + + def count_by_href(self, **filters): + return self._count_by(BookmarkTag.text, filters) + + def create(self, **attrs): + return super().create(**attrs) + + def update(self, filters, attrs): + return super().update(filters, attrs) diff --git a/newspipe/web/controllers/user.py b/newspipe/web/controllers/user.py new file mode 100644 index 00000000..6ab04d44 --- /dev/null +++ b/newspipe/web/controllers/user.py @@ -0,0 +1,28 @@ +import logging +from werkzeug.security import generate_password_hash, check_password_hash +from .abstract import AbstractController +from web.models import User + +logger = logging.getLogger(__name__) + + +class UserController(AbstractController): + _db_cls = User + _user_id_key = 'id' + + def _handle_password(self, attrs): + if attrs.get('password'): + attrs['pwdhash'] = generate_password_hash(attrs.pop('password')) + elif 'password' in attrs: + del attrs['password'] + + def check_password(self, user, password): + return check_password_hash(user.pwdhash, password) + + def create(self, **attrs): + self._handle_password(attrs) + return super().create(**attrs) + + def update(self, filters, attrs): + self._handle_password(attrs) + return super().update(filters, attrs) diff --git a/newspipe/web/decorators.py b/newspipe/web/decorators.py new file mode 100644 index 00000000..3835f646 --- /dev/null +++ b/newspipe/web/decorators.py @@ -0,0 +1,27 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +from threading import Thread +from functools import wraps + +from flask_login import login_required + + +def async_maker(f): + """ + This decorator enables to launch a task (for examle sending an email or + indexing the database) in background. + This prevent the server to freeze. + """ + def wrapper(*args, **kwargs): + thr = Thread(target=f, args=args, kwargs=kwargs) + thr.start() + return wrapper + + +def pyagg_default_decorator(func): + @login_required + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper diff --git a/newspipe/web/forms.py b/newspipe/web/forms.py new file mode 100644 index 00000000..7b1893e2 --- /dev/null +++ b/newspipe/web/forms.py @@ -0,0 +1,220 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : http://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.3 $" +__date__ = "$Date: 2013/11/05 $" +__revision__ = "$Date: 2015/05/06 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +from flask import flash, url_for, redirect +from flask_wtf import FlaskForm +from flask_babel import lazy_gettext +from werkzeug.exceptions import NotFound +from wtforms import TextField, TextAreaField, PasswordField, BooleanField, \ + SubmitField, IntegerField, SelectField, validators, HiddenField +from wtforms.fields.html5 import EmailField, URLField + +from lib import misc_utils +from web.controllers import UserController +from web.models import User + + +class SignupForm(FlaskForm): + """ + Sign up form (registration to newspipe). + """ + nickname = TextField(lazy_gettext("Nickname"), + [validators.Required(lazy_gettext("Please enter your nickname."))]) + email = EmailField(lazy_gettext("Email"), + [validators.Length(min=6, max=35), + validators.Required( + lazy_gettext("Please enter your email address (only for account activation, won't be stored)."))]) + password = PasswordField(lazy_gettext("Password"), + [validators.Required(lazy_gettext("Please enter a password.")), + validators.Length(min=6, max=100)]) + submit = SubmitField(lazy_gettext("Sign up")) + + def validate(self): + ucontr = UserController() + validated = super().validate() + if ucontr.read(nickname=self.nickname.data).count(): + self.nickname.errors.append('Nickname already taken') + validated = False + if self.nickname.data != User.make_valid_nickname(self.nickname.data): + self.nickname.errors.append(lazy_gettext( + 'This nickname has invalid characters. ' + 'Please use letters, numbers, dots and underscores only.')) + validated = False + return validated + + +class RedirectForm(FlaskForm): + """ + Secure back redirects with WTForms. + """ + next = HiddenField() + + def __init__(self, *args, **kwargs): + FlaskForm.__init__(self, *args, **kwargs) + if not self.next.data: + self.next.data = misc_utils.get_redirect_target() or '' + + def redirect(self, endpoint='home', **values): + if misc_utils.is_safe_url(self.next.data): + return redirect(self.next.data) + target = misc_utils.get_redirect_target() + return redirect(target or url_for(endpoint, **values)) + + +class SigninForm(RedirectForm): + """ + Sign in form (connection to newspipe). + """ + nickmane = TextField("Nickname", + [validators.Length(min=3, max=35), + validators.Required( + lazy_gettext("Please enter your nickname."))]) + password = PasswordField(lazy_gettext('Password'), + [validators.Required(lazy_gettext("Please enter a password.")), + validators.Length(min=6, max=100)]) + submit = SubmitField(lazy_gettext("Log In")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = None + + def validate(self): + validated = super().validate() + ucontr = UserController() + try: + user = ucontr.get(nickname=self.nickmane.data) + except NotFound: + self.nickmane.errors.append( + 'Wrong nickname') + validated = False + else: + if not user.is_active: + self.nickmane.errors.append('Account not active') + validated = False + if not ucontr.check_password(user, self.password.data): + self.password.errors.append('Wrong password') + validated = False + self.user = user + return validated + + +class UserForm(FlaskForm): + """ + Create or edit a user (for the administrator). + """ + nickname = TextField(lazy_gettext("Nickname"), + [validators.Required(lazy_gettext("Please enter your nickname."))]) + password = PasswordField(lazy_gettext("Password")) + automatic_crawling = BooleanField(lazy_gettext("Automatic crawling"), + default=True) + submit = SubmitField(lazy_gettext("Save")) + + def validate(self): + validated = super(UserForm, self).validate() + if self.nickname.data != User.make_valid_nickname(self.nickname.data): + self.nickname.errors.append(lazy_gettext( + 'This nickname has invalid characters. ' + 'Please use letters, numbers, dots and underscores only.')) + validated = False + return validated + + +class ProfileForm(FlaskForm): + """ + Edit user information. + """ + nickname = TextField(lazy_gettext("Nickname"), + [validators.Required(lazy_gettext("Please enter your nickname."))]) + password = PasswordField(lazy_gettext("Password")) + password_conf = PasswordField(lazy_gettext("Password Confirmation")) + automatic_crawling = BooleanField(lazy_gettext("Automatic crawling"), + default=True) + bio = TextAreaField(lazy_gettext("Bio")) + webpage = URLField(lazy_gettext("Webpage")) + twitter = URLField(lazy_gettext("Twitter")) + is_public_profile = BooleanField(lazy_gettext("Public profile"), + default=True) + submit = SubmitField(lazy_gettext("Save")) + + def validate(self): + validated = super(ProfileForm, self).validate() + if self.password.data != self.password_conf.data: + message = lazy_gettext("Passwords aren't the same.") + self.password.errors.append(message) + self.password_conf.errors.append(message) + validated = False + if self.nickname.data != User.make_valid_nickname(self.nickname.data): + self.nickname.errors.append(lazy_gettext('This nickname has ' + 'invalid characters. Please use letters, numbers, dots and' + ' underscores only.')) + validated = False + return validated + + +class AddFeedForm(FlaskForm): + title = TextField(lazy_gettext("Title"), [validators.Optional()]) + link = TextField(lazy_gettext("Feed link"), + [validators.Required(lazy_gettext("Please enter the URL."))]) + site_link = TextField(lazy_gettext("Site link"), [validators.Optional()]) + enabled = BooleanField(lazy_gettext("Check for updates"), default=True) + submit = SubmitField(lazy_gettext("Save")) + category_id = SelectField(lazy_gettext("Category of the feed"), + [validators.Optional()]) + private = BooleanField(lazy_gettext("Private"), default=False) + + def set_category_choices(self, categories): + self.category_id.choices = [('0', 'No Category')] + self.category_id.choices += [(str(cat.id), cat.name) + for cat in categories] + + +class CategoryForm(FlaskForm): + name = TextField(lazy_gettext("Category name")) + submit = SubmitField(lazy_gettext("Save")) + + +class BookmarkForm(FlaskForm): + href = TextField(lazy_gettext("URL"), + [validators.Required( + lazy_gettext("Please enter an URL."))]) + title = TextField(lazy_gettext("Title"), + [validators.Length(min=0, max=100)]) + description = TextField(lazy_gettext("Description"), + [validators.Length(min=0, max=500)]) + tags = TextField(lazy_gettext("Tags")) + to_read = BooleanField(lazy_gettext("To read"), default=False) + shared = BooleanField(lazy_gettext("Shared"), default=False) + submit = SubmitField(lazy_gettext("Save")) + + +class InformationMessageForm(FlaskForm): + subject = TextField(lazy_gettext("Subject"), + [validators.Required(lazy_gettext("Please enter a subject."))]) + message = TextAreaField(lazy_gettext("Message"), + [validators.Required(lazy_gettext("Please enter a content."))]) + submit = SubmitField(lazy_gettext("Send")) diff --git a/newspipe/web/js/actions/MenuActions.js b/newspipe/web/js/actions/MenuActions.js new file mode 100644 index 00000000..824610d8 --- /dev/null +++ b/newspipe/web/js/actions/MenuActions.js @@ -0,0 +1,40 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var jquery = require('jquery'); + + +var MenuActions = { + // PARENT FILTERS + reload: function(setFilterFunc, id) { + jquery.getJSON('/menu', function(payload) { + JarrDispatcher.dispatch({ + type: ActionTypes.RELOAD_MENU, + feeds: payload.feeds, + categories: payload.categories, + categories_order: payload.categories_order, + is_admin: payload.is_admin, + max_error: payload.max_error, + error_threshold: payload.error_threshold, + crawling_method: payload.crawling_method, + all_unread_count: payload.all_unread_count, + }); + if(setFilterFunc && id) { + setFilterFunc(id); + } + }); + }, + setFilter: function(filter) { + JarrDispatcher.dispatch({ + type: ActionTypes.MENU_FILTER, + filter: filter, + }); + }, + toggleAllFolding: function(all_folded) { + JarrDispatcher.dispatch({ + type: ActionTypes.TOGGLE_MENU_FOLD, + all_folded: all_folded, + }); + }, +}; + +module.exports = MenuActions; diff --git a/newspipe/web/js/actions/MiddlePanelActions.js b/newspipe/web/js/actions/MiddlePanelActions.js new file mode 100644 index 00000000..700814d4 --- /dev/null +++ b/newspipe/web/js/actions/MiddlePanelActions.js @@ -0,0 +1,132 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var jquery = require('jquery'); +var MiddlePanelStore = require('../stores/MiddlePanelStore'); + +var _last_fetched_with = {}; + +var reloadAndDispatch = function(dispath_payload) { + var filters = MiddlePanelStore.getRequestFilter( + dispath_payload.display_search); + MiddlePanelStore.filter_whitelist.map(function(key) { + if(key in dispath_payload) { + filters[key] = dispath_payload[key]; + } + if(filters[key] == null) { + delete filters[key]; + } + }); + if('display_search' in filters) { + delete filters['display_search']; + } + jquery.getJSON('/middle_panel', filters, + function(payload) { + dispath_payload.articles = payload.articles; + dispath_payload.filters = filters; + JarrDispatcher.dispatch(dispath_payload); + _last_fetched_with = MiddlePanelStore.getRequestFilter(); + }); +} + + +var MiddlePanelActions = { + reload: function() { + reloadAndDispatch({ + type: ActionTypes.RELOAD_MIDDLE_PANEL, + }); + }, + search: function(search) { + reloadAndDispatch({ + type: ActionTypes.RELOAD_MIDDLE_PANEL, + display_search: true, + query: search.query, + search_title: search.title, + search_content: search.content, + }); + }, + search_off: function() { + reloadAndDispatch({ + type: ActionTypes.RELOAD_MIDDLE_PANEL, + display_search: false, + }); + }, + removeParentFilter: function() { + reloadAndDispatch({ + type: ActionTypes.PARENT_FILTER, + filter_type: null, + filter_id: null, + }); + }, + setCategoryFilter: function(category_id) { + reloadAndDispatch({ + type: ActionTypes.PARENT_FILTER, + filter_type: 'category_id', + filter_id: category_id, + }); + }, + setFeedFilter: function(feed_id) { + reloadAndDispatch({ + type: ActionTypes.PARENT_FILTER, + filter_type: 'feed_id', + filter_id: feed_id, + }); + }, + setFilter: function(filter) { + reloadAndDispatch({ + type: ActionTypes.MIDDLE_PANEL_FILTER, + filter: filter, + }); + }, + changeRead: function(category_id, feed_id, article_id, new_value){ + jquery.ajax({type: 'PUT', + contentType: 'application/json', + data: JSON.stringify({readed: new_value}), + url: "api/v2.0/article/" + article_id, + success: function () { + JarrDispatcher.dispatch({ + type: ActionTypes.CHANGE_ATTR, + attribute: 'read', + value_bool: new_value, + value_num: new_value ? -1 : 1, + articles: [{article_id: article_id, + category_id: category_id, + feed_id: feed_id}], + }); + }, + }); + }, + changeLike: function(category_id, feed_id, article_id, new_value){ + jquery.ajax({type: 'PUT', + contentType: 'application/json', + data: JSON.stringify({like: new_value}), + url: "api/v2.0/article/" + article_id, + success: function () { + JarrDispatcher.dispatch({ + type: ActionTypes.CHANGE_ATTR, + attribute: 'liked', + value_bool: new_value, + value_num: new_value ? -1 : 1, + articles: [{article_id: article_id, + category_id: category_id, + feed_id: feed_id}], + }); + }, + }); + }, + markAllAsRead: function() { + var filters = MiddlePanelStore.getRequestFilter(); + jquery.ajax({type: 'PUT', + contentType: 'application/json', + data: JSON.stringify(filters), + url: "/mark_all_as_read", + success: function (payload) { + JarrDispatcher.dispatch({ + type: ActionTypes.MARK_ALL_AS_READ, + articles: payload.articles, + }); + }, + }); + }, +}; + +module.exports = MiddlePanelActions; diff --git a/newspipe/web/js/actions/RightPanelActions.js b/newspipe/web/js/actions/RightPanelActions.js new file mode 100644 index 00000000..5d78e001 --- /dev/null +++ b/newspipe/web/js/actions/RightPanelActions.js @@ -0,0 +1,42 @@ +var jquery = require('jquery'); +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var MenuActions = require('../actions/MenuActions'); + +var RightPanelActions = { + loadArticle: function(article_id, was_read_before, to_parse) { + var suffix = ''; + if(to_parse) { + suffix = '/parse'; + } + jquery.getJSON('/getart/' + article_id + suffix, + function(payload) { + JarrDispatcher.dispatch({ + type: ActionTypes.LOAD_ARTICLE, + article: payload, + was_read_before: was_read_before, + }); + } + ); + }, + _apiReq: function(meth, id, obj_type, data, success_callback) { + var args = {type: meth, contentType: 'application/json', + url: "api/v2.0/" + obj_type + "/" + id} + if(data) {args.data = JSON.stringify(data);} + if(success_callback) {args.success = success_callback;} + jquery.ajax(args); + }, + putObj: function(id, obj_type, fields) { + this._apiReq('PUT', id, obj_type, fields, MenuActions.reload); + }, + delObj: function(id, obj_type, fields) { + this._apiReq('DELETE', id, obj_type, null, MenuActions.reload); + }, + resetErrors: function(feed_id) { + this._apiReq('PUT', feed_id, 'feed', {error_count: 0, last_error: ''}, + MenuActions.reload); + + }, +}; + +module.exports = RightPanelActions; diff --git a/newspipe/web/js/app.js b/newspipe/web/js/app.js new file mode 100644 index 00000000..7837e6ae --- /dev/null +++ b/newspipe/web/js/app.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2014, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +var React = require('react'); +var ReactDOM = require('react-dom'); + +var MainApp = require('./components/MainApp.react'); + +ReactDOM.render( + , + document.getElementById('newspipeapp') +); diff --git a/newspipe/web/js/components/MainApp.react.js b/newspipe/web/js/components/MainApp.react.js new file mode 100644 index 00000000..32bb663e --- /dev/null +++ b/newspipe/web/js/components/MainApp.react.js @@ -0,0 +1,29 @@ +var React = require('react'); +var createReactClass = require('create-react-class'); +var Col = require('react-bootstrap/lib/Col'); +var Grid = require('react-bootstrap/lib/Grid'); +var PropTypes = require('prop-types'); + +var Menu = require('./Menu.react'); +var MiddlePanel = require('./MiddlePanel.react'); +var RightPanel = require('./RightPanel.react'); + + +var MainApp = createReactClass({ + render: function() { + return (
+ + + + + + + + +
+ ); + }, +}); + +module.exports = MainApp; diff --git a/newspipe/web/js/components/Menu.react.js b/newspipe/web/js/components/Menu.react.js new file mode 100644 index 00000000..64672240 --- /dev/null +++ b/newspipe/web/js/components/Menu.react.js @@ -0,0 +1,305 @@ +var React = require('react'); +var createReactClass = require('create-react-class'); +var Col = require('react-bootstrap/lib/Col'); +var Badge = require('react-bootstrap/lib/Badge'); +var Button = require('react-bootstrap/lib/Button'); +var ButtonGroup = require('react-bootstrap/lib/ButtonGroup'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); +var PropTypes = require('prop-types'); + +var MenuStore = require('../stores/MenuStore'); +var MenuActions = require('../actions/MenuActions'); +var MiddlePanelActions = require('../actions/MiddlePanelActions'); + +var FeedItem = createReactClass({ + propTypes: {feed_id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + unread: PropTypes.number.isRequired, + error_count: PropTypes.number.isRequired, + icon_url: PropTypes.string, + active: PropTypes.bool.isRequired, + }, + render: function() { + var icon = null; + var badge_unread = null; + if(this.props.icon_url){ + icon = (); + } else { + icon = ; + } + if(this.props.unread){ + badge_unread = {this.props.unread}; + } + var classes = "nav-feed"; + if(this.props.active) { + classes += " bg-primary"; + } + if(this.props.error_count >= MenuStore._datas.max_error) { + classes += " bg-danger"; + } else if(this.props.error_count > MenuStore._datas.error_threshold) { + classes += " bg-warning"; + } + var title = {this.props.title}; + return (
  • + {icon}{title}{badge_unread} +
  • + ); + }, + handleClick: function() { + MiddlePanelActions.setFeedFilter(this.props.feed_id); + }, +}); + +var Category = createReactClass({ + propTypes: {category_id: PropTypes.number, + active_type: PropTypes.string, + active_id: PropTypes.number}, + render: function() { + var classes = "nav-cat"; + if((this.props.active_type == 'category_id' + || this.props.category_id == null) + && this.props.active_id == this.props.category_id) { + classes += " bg-primary"; + } + return (
  • + {this.props.children} +
  • + ); + }, + handleClick: function(evnt) { + // hack to avoid selection when clicking on folding icon + if(!evnt.target.classList.contains('glyphicon')) { + if(this.props.category_id != null) { + MiddlePanelActions.setCategoryFilter(this.props.category_id); + } else { + MiddlePanelActions.removeParentFilter(); + } + } + }, +}); + +var CategoryGroup = createReactClass({ + propTypes: {cat_id: PropTypes.number.isRequired, + filter: PropTypes.string.isRequired, + active_type: PropTypes.string, + active_id: PropTypes.number, + name: PropTypes.string.isRequired, + feeds: PropTypes.array.isRequired, + unread: PropTypes.number.isRequired, + folded: PropTypes.bool, + }, + getInitialState: function() { + return {folded: false}; + }, + componentWillReceiveProps: function(nextProps) { + if(nextProps.folded != null) { + this.setState({folded: nextProps.folded}); + } + }, + render: function() { + // hidden the no category if empty + if(!this.props.cat_id && !this.props.feeds.length) { + return
      ; + } + var filter = this.props.filter; + var a_type = this.props.active_type; + var a_id = this.props.active_id; + if(!this.state.folded) { + // filtering according to this.props.filter + var feeds = this.props.feeds.filter(function(feed) { + if (filter == 'unread' && feed.unread <= 0) { + return false; + } else if (filter == 'error' && feed.error_count <= MenuStore._datas.error_threshold) { + return false; + } + return true; + }).sort(function(feed_a, feed_b){ + return feed_b.unread - feed_a.unread; + }).map(function(feed) { + return ( + ); + }); + } else { + var feeds = []; + } + var unread = null; + if(this.props.unread) { + unread = {this.props.unread}; + } + var ctrl = ( + ); + + return (
        + + {ctrl}

        {this.props.name}

        {unread} +
        + {feeds} +
      + ); + }, + toggleFolding: function(evnt) { + this.setState({folded: !this.state.folded}); + evnt.stopPropagation(); + }, +}); + +var MenuFilter = createReactClass({ + propTypes: {feed_in_error: PropTypes.bool, + filter: PropTypes.string.isRequired}, + getInitialState: function() { + return {allFolded: false}; + }, + render: function() { + var error_button = null; + if (this.props.feed_in_error) { + error_button = ( + + ); + } + if(this.state.allFolded) { + var foldBtnTitle = "Unfold all categories"; + var foldBtnGlyph = "option-horizontal"; + } else { + var foldBtnTitle = "Fold all categories"; + var foldBtnGlyph = "option-vertical"; + } + return (
      + + + + {error_button} + + + + + + + +
      + ); + }, + setAllFilter: function() { + MenuActions.setFilter("all"); + }, + setUnreadFilter: function() { + MenuActions.setFilter("unread"); + }, + setErrorFilter: function() { + MenuActions.setFilter("error"); + }, + toggleFold: function() { + this.setState({allFolded: !this.state.allFolded}, function() { + MenuActions.toggleAllFolding(this.state.allFolded); + }.bind(this)); + }, +}); + +var Menu = createReactClass({ + getInitialState: function() { + return {filter: 'unread', categories: {}, feeds: {}, + all_folded: false, active_type: null, active_id: null}; + }, + render: function() { + var feed_in_error = false; + var rmPrntFilt = ( +
        + +

        All

        +
        +
      + ); + var categories = []; + for(var index in this.state.categories_order) { + var cat_id = this.state.categories_order[index]; + var feeds = []; + var unread = 0; + this.state.categories[cat_id].feeds.map(function(feed_id) { + if(this.state.feeds[feed_id].error_count > MenuStore._datas.error_threshold) { + feed_in_error = true; + } + unread += this.state.feeds[feed_id].unread; + feeds.push(this.state.feeds[feed_id]); + }.bind(this)); + categories.push(); + } + + return ( + + {rmPrntFilt} + {categories} + + ); + }, + componentDidMount: function() { + var setFilterFunc = null; + var id = null; + if(window.location.search.substring(1)) { + var args = window.location.search.substring(1).split('&'); + args.map(function(arg) { + if (arg.split('=')[0] == 'at' && arg.split('=')[1] == 'c') { + setFilterFunc = MiddlePanelActions.setCategoryFilter; + } else if (arg.split('=')[0] == 'at' && arg.split('=')[1] == 'f') { + setFilterFunc = MiddlePanelActions.setFeedFilter; + + } else if (arg.split('=')[0] == 'ai') { + id = parseInt(arg.split('=')[1]); + } + }); + } + MenuActions.reload(setFilterFunc, id); + MenuStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MenuStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + var datas = MenuStore.getAll(); + this.setState({filter: datas.filter, + feeds: datas.feeds, + categories: datas.categories, + categories_order: datas.categories_order, + active_type: datas.active_type, + active_id: datas.active_id, + all_folded: datas.all_folded}); + }, +}); + +module.exports = Menu; diff --git a/newspipe/web/js/components/MiddlePanel.react.js b/newspipe/web/js/components/MiddlePanel.react.js new file mode 100644 index 00000000..fc7c763a --- /dev/null +++ b/newspipe/web/js/components/MiddlePanel.react.js @@ -0,0 +1,267 @@ +var React = require('react'); +var createReactClass = require('create-react-class'); + +var Row = require('react-bootstrap/lib/Row'); +var Button = require('react-bootstrap/lib/Button'); +var ButtonGroup = require('react-bootstrap/lib/ButtonGroup'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); +var PropTypes = require('prop-types'); + +var MiddlePanelStore = require('../stores/MiddlePanelStore'); +var MiddlePanelActions = require('../actions/MiddlePanelActions'); +var RightPanelActions = require('../actions/RightPanelActions'); + +var JarrTime = require('./time.react'); + +var TableLine = createReactClass({ + propTypes: {article_id: PropTypes.number.isRequired, + feed_title: PropTypes.string.isRequired, + icon_url: PropTypes.string, + title: PropTypes.string.isRequired, + rel_date: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + read: PropTypes.bool.isRequired, + selected: PropTypes.bool.isRequired, + liked: PropTypes.bool.isRequired, + }, + getInitialState: function() { + return {read: this.props.read, liked: this.props.liked, + selected: false}; + }, + render: function() { + var liked = this.state.liked ? 'l' : ''; + var icon = null; + if(this.props.icon_url){ + icon = (); + } else { + icon = ; + } + var title = ( + {icon} {this.props.feed_title} + ); + var read = (); + var liked = (); + icon = ; + var clsses = "list-group-item"; + if(this.props.selected) { + clsses += " active"; + } + return (
      +
      {title}
      + +
      {read} {liked} {this.props.title}
      +
      + ); + }, + openRedirectLink: function(evnt) { + if(!this.state.read) { + this.toogleRead(evnt); + } + }, + toogleRead: function(evnt) { + this.setState({read: !this.state.read}, function() { + MiddlePanelActions.changeRead(this.props.category_id, + this.props.feed_id, this.props.article_id, this.state.read); + }.bind(this)); + evnt.stopPropagation(); + }, + toogleLike: function(evnt) { + this.setState({liked: !this.state.liked}, function() { + MiddlePanelActions.changeLike(this.props.category_id, + this.props.feed_id, this.props.article_id, this.state.liked); + }.bind(this)); + evnt.stopPropagation(); + }, + loadArticle: function() { + this.setState({selected: true, read: true}, function() { + RightPanelActions.loadArticle( + this.props.article_id, this.props.read); + }.bind(this)); + }, + stopPropagation: function(evnt) { + evnt.stopPropagation(); + }, +}); + +var MiddlePanelSearchRow = createReactClass({ + getInitialState: function() { + return {query: MiddlePanelStore._datas.query, + search_title: MiddlePanelStore._datas.search_title, + search_content: MiddlePanelStore._datas.search_content, + }; + }, + render: function() { + return ( +
      +
      + + Title + + + + Content + + + +
      +
      +
      + ); + }, + setQuery: function(evnt) { + this.setState({query: evnt.target.value}); + }, + toogleSTitle: function() { + this.setState({search_title: !this.state.search_title}, + this.launchSearch); + }, + toogleSContent: function() { + this.setState({search_content: !this.state.search_content}, + this.launchSearch); + }, + launchSearch: function(evnt) { + if(this.state.query && (this.state.search_title + || this.state.search_content)) { + MiddlePanelActions.search({query: this.state.query, + title: this.state.search_title, + content: this.state.search_content}); + } + if(evnt) { + evnt.preventDefault(); + } + }, +}); + +var MiddlePanelFilter = createReactClass({ + getInitialState: function() { + return {filter: MiddlePanelStore._datas.filter, + display_search: MiddlePanelStore._datas.display_search}; + }, + render: function() { + var search_row = null; + if(this.state.display_search) { + search_row = + } + return (
      + + + + + + + + + + + + + + {search_row} +
      + ); + }, + setAllFilter: function() { + this.setState({filter: 'all'}, function() { + MiddlePanelActions.setFilter('all'); + }.bind(this)); + }, + setUnreadFilter: function() { + this.setState({filter: 'unread'}, function() { + MiddlePanelActions.setFilter('unread'); + }.bind(this)); + }, + setLikedFilter: function() { + this.setState({filter: 'liked'}, function() { + MiddlePanelActions.setFilter('liked'); + }.bind(this)); + }, + toogleSearch: function() { + this.setState({display_search: !this.state.display_search}, + function() { + if(!this.state.display_search) { + MiddlePanelActions.search_off(); + } + }.bind(this) + ); + }, +}); + +var MiddlePanel = createReactClass({ + getInitialState: function() { + return {filter: MiddlePanelStore._datas.filter, articles: []}; + }, + render: function() { + return ( +
      + {this.state.articles.map(function(article){ + var key = "a" + article.article_id; + if(article.read) {key+="r";} + if(article.liked) {key+="l";} + if(article.selected) {key+="s";} + return ();})} +
      +
      + ); + }, + componentDidMount: function() { + MiddlePanelActions.reload(); + MiddlePanelStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MiddlePanelStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + this.setState({filter: MiddlePanelStore._datas.filter, + articles: MiddlePanelStore.getArticles()}); + }, +}); + +module.exports = {MiddlePanel: MiddlePanel, + MiddlePanelFilter: MiddlePanelFilter}; diff --git a/newspipe/web/js/components/Navbar.react.js b/newspipe/web/js/components/Navbar.react.js new file mode 100644 index 00000000..83f3c72c --- /dev/null +++ b/newspipe/web/js/components/Navbar.react.js @@ -0,0 +1,138 @@ +var React = require('react'); +var createReactClass = require('create-react-class'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); +var Nav = require('react-bootstrap/lib/Nav'); +var NavItem = require('react-bootstrap/lib/NavItem'); +var Navbar = require('react-bootstrap/lib/Navbar'); +var NavDropdown = require('react-bootstrap/lib/NavDropdown'); +var MenuItem = require('react-bootstrap/lib/MenuItem'); +var Modal = require('react-bootstrap/lib/Modal'); +var Button = require('react-bootstrap/lib/Button'); +var Input = require('react-bootstrap/lib/Input'); + +var MenuStore = require('../stores/MenuStore'); + +JarrNavBar = createReactClass({ + getInitialState: function() { + return {is_admin: MenuStore._datas.is_admin, + crawling_method: MenuStore._datas.crawling_method, + showModal: false, modalType: null}; + }, + buttonFetch: function() { + if(this.state.is_admin && this.state.crawling_method != 'http') { + return ( + Fetch + ); + } + }, + sectionAdmin: function() { + if(this.state.is_admin) { + return ( + Dashboard + ); + } + }, + getModal: function() { + var heading = null; + var action = null; + var body = null; + if(this.state.modalType == 'addFeed') { + heading = 'Add a new feed'; + action = '/feed/bookmarklet'; + placeholder = "Site or feed url"; + body = ; + } else { + heading = 'Add a new category'; + action = '/category/create'; + body = ; + } + return ( +
      + + {heading} + + + {body} + + + + +
      +
      ); + }, + close: function() { + this.setState({showModal: false, modalType: null}); + }, + openAddFeed: function() { + this.setState({showModal: true, modalType: 'addFeed'}); + }, + openAddCategory: function() { + this.setState({showModal: true, modalType: 'addCategory'}); + }, + render: function() { + return ( + {this.getModal()} + + + Newspipe + + + + + + + + ); + }, + componentDidMount: function() { + MenuStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MenuStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + var datas = MenuStore.getAll(); + this.setState({is_admin: datas.is_admin, + crawling_method: datas.crawling_method}); + }, +}); + +module.exports = JarrNavBar; diff --git a/newspipe/web/js/components/RightPanel.react.js b/newspipe/web/js/components/RightPanel.react.js new file mode 100644 index 00000000..6384cdfe --- /dev/null +++ b/newspipe/web/js/components/RightPanel.react.js @@ -0,0 +1,463 @@ +var React = require('react'); +var createReactClass = require('create-react-class'); +var Col = require('react-bootstrap/lib/Col'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); +var Button = require('react-bootstrap/lib/Button'); +var ButtonGroup = require('react-bootstrap/lib/ButtonGroup'); +var Modal = require('react-bootstrap/lib/Modal'); +var PropTypes = require('prop-types'); + +var RightPanelActions = require('../actions/RightPanelActions'); +var RightPanelStore = require('../stores/RightPanelStore'); +var MenuStore = require('../stores/MenuStore'); +var JarrTime = require('./time.react'); + +var PanelMixin = { + propTypes: {obj: PropTypes.object.isRequired}, + getInitialState: function() { + return {edit_mode: false, obj: this.props.obj, showModal: false}; + }, + getHeader: function() { + var icon = null; + if(this.props.obj.icon_url){ + icon = (); + } + var btn_grp = null; + if(this.isEditable() || this.isRemovable()) { + var edit_button = null; + if(this.isEditable()) { + edit_button = (); + } + var rem_button = null; + if(this.isRemovable()) { + rem_button = (); + } + btn_grp = ( + {edit_button} + {rem_button} + ); + } + return (
      + + + Are you sure ? + + + + + + +

      {icon}{this.getTitle()}

      + {btn_grp} +
      ); + }, + getKey: function(prefix, suffix) { + return ((this.state.edit_mode?'edit':'fix') + prefix + + '-' + this.props.obj.id + '-' + suffix); + }, + getCore: function() { + var items = []; + var key; + if(!this.state.edit_mode) { + this.fields.filter(function(field) { + return field.type != 'ignore'; + }).map(function(field) { + key = this.getKey('dt', field.key); + items.push(
      {field.title}
      ); + key = this.getKey('dd', field.key); + if(field.type == 'string') { + items.push(
      {this.props.obj[field.key]}
      ); + } else if(field.type == 'bool') { + if(this.props.obj[field.key]) { + items.push(
      ); + } else { + items.push(
      ); + } + } else if (field.type == 'list') { + items.push(
      {this.props.obj[field.key].reduce(function(previousTag, currentTag) { + return currentTag.concat(", ", previousTag) + }, "")}
      ) + } else if (field.type == 'link') { + items.push(
      + + {this.props.obj[field.key]} + +
      ); + } + }.bind(this)); + } else { + this.fields.filter(function(field) { + return field.type != 'ignore'; + }).map(function(field) { + key = this.getKey('dd', field.key); + items.push(
      {field.title}
      ); + key = this.getKey('dt', field.key); + var input = null; + if(field.type == 'string' || field.type == 'link') { + input = (); + } else if (field.type == 'bool') { + input = (); + } + items.push(
      {input}
      ); + }.bind(this)); + } + return (
      {items}
      ); + }, + getSubmit: function() { + return (
      + +
      ); + }, + render: function() { + return (
      + {this.getHeader()} + {this.getBody()} +
      + ); + }, + onClickEdit: function() { + this.setState({edit_mode: !this.state.edit_mode}); + }, + onClickRemove: function() { + this.setState({showModal: true}); + }, + closeModal: function() { + this.setState({showModal: false}); + }, + confirmDelete: function() { + this.setState({showModal: false}, function() { + RightPanelActions.delObj(this.props.obj.id, this.obj_type); + }.bind(this)); + }, + saveField: function(evnt) { + var obj = this.state.obj; + for(var i in this.fields) { + if(evnt.target.name == this.fields[i].key) { + if(this.fields[i].type == 'bool') { + obj[evnt.target.name] = evnt.target.checked; + } else { + obj[evnt.target.name] = evnt.target.value; + } + break; + } + } + this.setState({obj: obj}); + }, + saveObj: function() { + var to_push = {}; + this.fields.map(function(field) { + to_push[field.key] = this.state.obj[field.key]; + }.bind(this)); + this.setState({edit_mode: false}, function() { + RightPanelActions.putObj(this.props.obj.id, this.obj_type, to_push); + }.bind(this)); + }, +}; + +var Article = createReactClass({ + mixins: [PanelMixin], + isEditable: function() {return false;}, + isRemovable: function() {return true;}, + fields: [{'title': 'Date', 'type': 'string', 'key': 'date'}, + {'title': 'Original link', 'type': 'link', 'key': 'link'}, + {'title': 'Tags', 'type': 'list', 'key': 'tags'} + ], + obj_type: 'article', + getTitle: function() {return this.props.obj.title;}, + getBody: function() { + return (
      + {this.getCore()} +
      +
      ); + } +}); + +var Feed = createReactClass({ + mixins: [PanelMixin], + isEditable: function() {return true;}, + isRemovable: function() {return true;}, + obj_type: 'feed', + fields: [{'title': 'Feed title', 'type': 'string', 'key': 'title'}, + {'title': 'Description', 'type': 'string', 'key': 'description'}, + {'title': 'Feed link', 'type': 'link', 'key': 'link'}, + {'title': 'Site link', 'type': 'link', 'key': 'site_link'}, + {'title': 'Enabled', 'type': 'bool', 'key': 'enabled'}, + {'title': 'Private', 'type': 'bool', 'key': 'private'}, + {'title': 'Filters', 'type': 'ignore', 'key': 'filters'}, + {'title': 'Category', 'type': 'ignore', 'key': 'category_id'}, + ], + getTitle: function() {return this.props.obj.title;}, + getFilterRow: function(i, filter) { + return (
      + + + + + + + +
      ); + }, + getFilterRows: function() { + var rows = []; + if(this.state.edit_mode) { + for(var i in this.state.obj.filters) { + rows.push(this.getFilterRow(i, this.state.obj.filters[i])); + } + return (
      +
      Filters
      +
      + +
      + {rows} +
      ); + } + rows = []; + rows.push(
      Filters
      ); + for(var i in this.state.obj.filters) { + rows.push(
      + When {this.state.obj.filters[i]['action on']} + on "{this.state.obj.filters[i].pattern}" + ({this.state.obj.filters[i].type}) + "=" {this.state.obj.filters[i].action} +
      ); + } + return
      {rows}
      ; + }, + getCategorySelect: function() { + var content = null; + if(this.state.edit_mode) { + var categ_options = []; + for(var index in MenuStore._datas.categories_order) { + var cat_id = MenuStore._datas.categories_order[index]; + categ_options.push( + ); + } + content = (); + } else { + content = MenuStore._datas.categories[this.props.obj.category_id].name; + } + return (
      +
      Category
      {content}
      +
      ); + }, + getErrorFields: function() { + if(this.props.obj.error_count < MenuStore._datas.error_threshold) { + return; + } + if(this.props.obj.error_count < MenuStore._datas.max_error) { + return (
      +
      State
      +
      The download of this feed has encountered some problems. However its error counter will be reinitialized at the next successful retrieving.
      +
      Last error
      +
      {this.props.obj.last_error}
      +
      ); + } + return (
      +
      State
      +
      That feed has encountered too much consecutive errors and won't be retrieved anymore.
      + +
      Last error
      +
      {this.props.obj.last_error}
      +
      + +
      +
      ); + + }, + resetErrors: function() { + var obj = this.state.obj; + obj.error_count = 0; + this.setState({obj: obj}, function() { + RightPanelActions.resetErrors(this.props.obj.id); + }.bind(this)); + }, + getBody: function() { + return (
      +
      +
      Created on
      +
      +
      +
      Last fetched
      +
      +
      +
      + {this.getErrorFields()} + {this.getCategorySelect()} + {this.getCore()} + {this.getFilterRows()} + {this.state.edit_mode?this.getSubmit():null} +
      + ); + }, + addFilterRow: function() { + var obj = this.state.obj; + obj.filters.push({action: "mark as read", 'action on': "match", + type: "simple match", pattern: ""}); + this.setState({obj: obj}); + }, + removeFilterRow: function(evnt) { + var obj = this.state.obj; + delete obj.filters[evnt.target.getAttribute('data-index')]; + this.setState({obj: obj}); + }, + saveFilterChange: function(evnt) { + var index = evnt.target.getAttribute('data-index'); + var obj = this.state.obj; + obj.filters[index][evnt.target.name] = evnt.target.value; + this.setState({obj: obj}); + }, +}); + +var Category = createReactClass({ + mixins: [PanelMixin], + isEditable: function() { + if(this.props.obj.id != 0) {return true;} + else {return false;} + }, + isRemovable: function() {return this.isEditable();}, + obj_type: 'category', + fields: [{'title': 'Category name', 'type': 'string', 'key': 'name'}], + getTitle: function() {return this.props.obj.name;}, + getBody: function() { + return (
      + {this.getCore()} + {this.state.edit_mode?this.getSubmit():null} +
      ); + }, +}); + +var RightPanel = createReactClass({ + getInitialState: function() { + return {category: null, feed: null, article: null, current: null}; + }, + getCategoryCrum: function() { + return (
    • + {this.state.category.name} +
    • ); + }, + getFeedCrum: function() { + return (
    • + {this.state.feed.title} +
    • ); + }, + getArticleCrum: function() { + return
    • {this.state.article.title}
    • ; + }, + render: function() { + window.scrollTo(0, 0); + var brd_category = null; + var brd_feed = null; + var brd_article = null; + var breadcrum = null; + if(this.state.category) { + brd_category = (
    • + + {this.state.category.name} + +
    • ); + } + if(this.state.feed) { + brd_feed = (
    • + + {this.state.feed.title} + +
    • ); + } + if(this.state.article) { + brd_article =
    • {this.state.article.title}
    • ; + } + if(brd_category || brd_feed || brd_article) { + breadcrum = (
        + {brd_category} + {brd_feed} + {brd_article} +
      ); + } + if(this.state.current == 'article') { + var cntnt = (
      ); + } else if(this.state.current == 'feed') { + var cntnt = (); + } else if(this.state.current == 'category') { + var cntnt = (); + } + + return ( + {breadcrum} + {cntnt} + + ); + }, + selectCategory: function() { + this.setState({current: 'category'}); + }, + selectFeed: function() { + this.setState({current: 'feed'}); + }, + selectArticle: function() { + this.setState({current: 'article'}); + }, + componentDidMount: function() { + RightPanelStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + RightPanelStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + this.setState(RightPanelStore.getAll()); + }, +}); + +module.exports = RightPanel; diff --git a/newspipe/web/js/components/time.react.js b/newspipe/web/js/components/time.react.js new file mode 100644 index 00000000..07e1fbdf --- /dev/null +++ b/newspipe/web/js/components/time.react.js @@ -0,0 +1,15 @@ +var React = require('react'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); + +var JarrTime = createReactClass({ + propTypes: {stamp: PropTypes.string.isRequired, + text: PropTypes.string.isRequired}, + render: function() { + return (); + }, +}); + +module.exports = JarrTime; diff --git a/newspipe/web/js/constants/JarrConstants.js b/newspipe/web/js/constants/JarrConstants.js new file mode 100644 index 00000000..78e8bf04 --- /dev/null +++ b/newspipe/web/js/constants/JarrConstants.js @@ -0,0 +1,13 @@ +var keyMirror = require('keymirror'); + +module.exports = keyMirror({ + TOGGLE_MENU_FOLD: null, + RELOAD_MENU: null, + PARENT_FILTER: null, // set a feed or a category as filter in menu + MENU_FILTER: null, // change displayed feed in the menu + CHANGE_ATTR: null, // edit an attr on an article (like / read) + RELOAD_MIDDLE_PANEL: null, + MIDDLE_PANEL_FILTER: null, // set a filter (read/like/all) + LOAD_ARTICLE: null, // load a single article in right panel + MARK_ALL_AS_READ: null, +}); diff --git a/newspipe/web/js/dispatcher/JarrDispatcher.js b/newspipe/web/js/dispatcher/JarrDispatcher.js new file mode 100644 index 00000000..56da186f --- /dev/null +++ b/newspipe/web/js/dispatcher/JarrDispatcher.js @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2014-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * AppDispatcher + * + * A singleton that operates as the central hub for application updates. + */ + +var Dispatcher = require('flux').Dispatcher; + +module.exports = new Dispatcher(); diff --git a/newspipe/web/js/dispatcher/__tests__/AppDispatcher-test.js b/newspipe/web/js/dispatcher/__tests__/AppDispatcher-test.js new file mode 100644 index 00000000..d3a35fc5 --- /dev/null +++ b/newspipe/web/js/dispatcher/__tests__/AppDispatcher-test.js @@ -0,0 +1,72 @@ +"use strict"; + +jest.autoMockOff(); + +describe('AppDispatcher', function() { + var AppDispatcher; + + beforeEach(function() { + AppDispatcher = require('../AppDispatcher.js'); + }); + + it('sends actions to subscribers', function() { + var listener = jest.genMockFunction(); + AppDispatcher.register(listener); + + var payload = {}; + AppDispatcher.dispatch(payload); + expect(listener.mock.calls.length).toBe(1); + expect(listener.mock.calls[0][0]).toBe(payload); + }); + + it('waits with chained dependencies properly', function() { + var payload = {}; + + var listener1Done = false; + var listener1 = function(pl) { + AppDispatcher.waitFor([index2, index4]); + // Second, third, and fourth listeners should have now been called + expect(listener2Done).toBe(true); + expect(listener3Done).toBe(true); + expect(listener4Done).toBe(true); + listener1Done = true; + }; + var index1 = AppDispatcher.register(listener1); + + var listener2Done = false; + var listener2 = function(pl) { + AppDispatcher.waitFor([index3]); + expect(listener3Done).toBe(true); + listener2Done = true; + }; + var index2 = AppDispatcher.register(listener2); + + var listener3Done = false; + var listener3 = function(pl) { + listener3Done = true; + }; + var index3 = AppDispatcher.register(listener3); + + var listener4Done = false; + var listener4 = function(pl) { + AppDispatcher.waitFor([index3]); + expect(listener3Done).toBe(true); + listener4Done = true; + }; + var index4 = AppDispatcher.register(listener4); + + runs(function() { + AppDispatcher.dispatch(payload); + }); + + waitsFor(function() { + return listener1Done; + }, "Not all subscribers were properly called", 500); + + runs(function() { + expect(listener1Done).toBe(true); + expect(listener2Done).toBe(true); + expect(listener3Done).toBe(true); + }); + }); +}); diff --git a/newspipe/web/js/stores/MenuStore.js b/newspipe/web/js/stores/MenuStore.js new file mode 100644 index 00000000..770bc501 --- /dev/null +++ b/newspipe/web/js/stores/MenuStore.js @@ -0,0 +1,135 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_menu'; +var assign = require('object-assign'); + + +var MenuStore = assign({}, EventEmitter.prototype, { + _datas: {filter: 'unread', feeds: {}, categories: {}, categories_order: [], + active_type: null, active_id: null, + is_admin: false, crawling_method: 'default', + all_unread_count: 0, max_error: 0, error_threshold: 0, + all_folded: false}, + getAll: function() { + return this._datas; + }, + setFilter: function(value) { + if(this._datas.filter != value) { + this._datas.filter = value; + this._datas.all_folded = null; + this.emitChange(); + } + }, + setActive: function(type, value) { + if(this._datas.active_id != value || this._datas.active_type != type) { + this._datas.active_type = type; + this._datas.active_id = value; + this._datas.all_folded = null; + this.emitChange(); + } + }, + emitChange: function() { + this.emit(CHANGE_EVENT); + }, + addChangeListener: function(callback) { + this.on(CHANGE_EVENT, callback); + }, + removeChangeListener: function(callback) { + this.removeListener(CHANGE_EVENT, callback); + }, +}); + + +MenuStore.dispatchToken = JarrDispatcher.register(function(action) { + switch(action.type) { + case ActionTypes.RELOAD_MENU: + MenuStore._datas['feeds'] = action.feeds; + MenuStore._datas['categories'] = action.categories; + MenuStore._datas['categories_order'] = action.categories_order; + MenuStore._datas['is_admin'] = action.is_admin; + MenuStore._datas['max_error'] = action.max_error; + MenuStore._datas['error_threshold'] = action.error_threshold; + MenuStore._datas['crawling_method'] = action.crawling_method; + MenuStore._datas['all_unread_count'] = action.all_unread_count; + MenuStore._datas.all_folded = null; + MenuStore.emitChange(); + break; + case ActionTypes.PARENT_FILTER: + MenuStore.setActive(action.filter_type, action.filter_id); + if(action.filters && action.articles && !action.filters.query + && action.filters.filter == 'unread') { + var new_unread = {}; + action.articles.map(function(article) { + if(!(article.feed_id in new_unread)) { + new_unread[article.feed_id] = 0; + } + if(!article.read) { + new_unread[article.feed_id] += 1; + } + }); + var changed = false; + for(var feed_id in new_unread) { + var old_unread = MenuStore._datas.feeds[feed_id].unread; + if(old_unread == new_unread[feed_id]) { + continue; + } + changed = true; + MenuStore._datas.feeds[feed_id].unread = new_unread[feed_id]; + var cat_id = MenuStore._datas.feeds[feed_id].category_id; + MenuStore._datas.categories[cat_id].unread -= old_unread; + MenuStore._datas.categories[cat_id].unread += new_unread[feed_id]; + } + if(changed) { + MenuStore._datas.all_folded = null; + MenuStore.emitChange(); + } + } + break; + case ActionTypes.MENU_FILTER: + MenuStore.setFilter(action.filter); + break; + case ActionTypes.CHANGE_ATTR: + if(action.attribute != 'read') { + return; + } + var val = action.value_num; + action.articles.map(function(article) { + MenuStore._datas.categories[article.category_id].unread += val; + MenuStore._datas.feeds[article.feed_id].unread += val; + }); + MenuStore._datas.all_folded = null; + MenuStore.emitChange(); + break; + case ActionTypes.LOAD_ARTICLE: + if(!action.was_read_before) { + MenuStore._datas.categories[action.article.category_id].unread -= 1; + MenuStore._datas.feeds[action.article.feed_id].unread -= 1; + MenuStore._datas.all_folded = null; + MenuStore.emitChange(); + } + break; + case ActionTypes.TOGGLE_MENU_FOLD: + MenuStore._datas.all_folded = action.all_folded; + MenuStore.emitChange(); + break; + case ActionTypes.MARK_ALL_AS_READ: + action.articles.map(function(art) { + if(!art.read) { + MenuStore._datas.feeds[art.feed_id].unread -= 1; + if(art.category_id) { + MenuStore._datas.categories[art.category_id].unread -= 1; + + } + } + }); + + MenuStore._datas.all_folded = null; + MenuStore.emitChange(); + break; + default: + // do nothing + } +}); + +module.exports = MenuStore; diff --git a/newspipe/web/js/stores/MiddlePanelStore.js b/newspipe/web/js/stores/MiddlePanelStore.js new file mode 100644 index 00000000..c554f929 --- /dev/null +++ b/newspipe/web/js/stores/MiddlePanelStore.js @@ -0,0 +1,126 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_middle_panel'; +var assign = require('object-assign'); + + +var MiddlePanelStore = assign({}, EventEmitter.prototype, { + filter_whitelist: ['filter', 'filter_id', 'filter_type', 'display_search', + 'query', 'search_title', 'search_content'], + _datas: {articles: [], selected_article: null, + filter: 'unread', filter_type: null, filter_id: null, + display_search: false, query: null, + search_title: true, search_content: false}, + getAll: function() { + return this._datas; + }, + getRequestFilter: function(display_search) { + var filters = {'filter': this._datas.filter, + 'filter_type': this._datas.filter_type, + 'filter_id': this._datas.filter_id, + }; + if(display_search || (display_search == undefined && this._datas.display_search)) { + filters.query = this._datas.query; + filters.search_title = this._datas.search_title; + filters.search_content = this._datas.search_content; + }; + return filters; + }, + getArticles: function() { + var key = null; + var id = null; + if (this._datas.filter_type) { + key = this._datas.filter_type; + id = this._datas.filter_id; + } + return this._datas.articles + .map(function(article) { + if(article.article_id == this._datas.selected_article) { + article.selected = true; + } else if(article.selected) { + article.selected = false; + } + return article; + }.bind(this)) + .filter(function(article) { + return (article.selected || ((!key || article[key] == id) + && (this._datas.filter == 'all' + || (this._datas.filter == 'unread' && !article.read) + || (this._datas.filter == 'liked' && article.liked)))); + }.bind(this)); + + }, + setArticles: function(articles) { + if(articles || articles == []) { + this._datas.articles = articles; + return true; + } + return false; + }, + registerFilter: function(action) { + var changed = false; + this.filter_whitelist.map(function(key) { + if(key in action && this._datas[key] != action[key]) { + changed = true; + this._datas[key] = action[key]; + } + }.bind(this)); + return changed; + }, + emitChange: function() { + this.emit(CHANGE_EVENT); + }, + addChangeListener: function(callback) { + this.on(CHANGE_EVENT, callback); + }, + removeChangeListener: function(callback) { + this.removeListener(CHANGE_EVENT, callback); + }, +}); + + +MiddlePanelStore.dispatchToken = JarrDispatcher.register(function(action) { + var changed = false; + if (action.type == ActionTypes.RELOAD_MIDDLE_PANEL + || action.type == ActionTypes.PARENT_FILTER + || action.type == ActionTypes.MIDDLE_PANEL_FILTER) { + changed = MiddlePanelStore.registerFilter(action); + changed = MiddlePanelStore.setArticles(action.articles) || changed; + } else if (action.type == ActionTypes.MARK_ALL_AS_READ) { + changed = MiddlePanelStore.registerFilter(action); + for(var i in action.articles) { + action.articles[i].read = true; + } + changed = MiddlePanelStore.setArticles(action.articles) || changed; + } else if (action.type == ActionTypes.CHANGE_ATTR) { + var attr = action.attribute; + var val = action.value_bool; + action.articles.map(function(article) { + for (var i in MiddlePanelStore._datas.articles) { + if(MiddlePanelStore._datas.articles[i].article_id == article.article_id) { + if (MiddlePanelStore._datas.articles[i][attr] != val) { + MiddlePanelStore._datas.articles[i][attr] = val; + // avoiding redraw if not filter, display won't change anyway + if(MiddlePanelStore._datas.filter != 'all') { + changed = true; + } + } + break; + } + } + }); + } else if (action.type == ActionTypes.LOAD_ARTICLE) { + changed = true; + MiddlePanelStore._datas.selected_article = action.article.id; + for (var i in MiddlePanelStore._datas.articles) { + if(MiddlePanelStore._datas.articles[i].article_id == action.article.id) { + MiddlePanelStore._datas.articles[i].read = true; + break; + } + } + } + if(changed) {MiddlePanelStore.emitChange();} +}); + +module.exports = MiddlePanelStore; diff --git a/newspipe/web/js/stores/RightPanelStore.js b/newspipe/web/js/stores/RightPanelStore.js new file mode 100644 index 00000000..6c268dfd --- /dev/null +++ b/newspipe/web/js/stores/RightPanelStore.js @@ -0,0 +1,77 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_middle_panel'; +var assign = require('object-assign'); +var MenuStore = require('../stores/MenuStore'); + + +var RightPanelStore = assign({}, EventEmitter.prototype, { + category: null, + feed: null, + article: null, + current: null, + getAll: function() { + return {category: this.category, feed: this.feed, + article: this.article, current: this.current}; + }, + emitChange: function() { + this.emit(CHANGE_EVENT); + }, + addChangeListener: function(callback) { + this.on(CHANGE_EVENT, callback); + }, + removeChangeListener: function(callback) { + this.removeListener(CHANGE_EVENT, callback); + }, +}); + + +RightPanelStore.dispatchToken = JarrDispatcher.register(function(action) { + switch(action.type) { + case ActionTypes.PARENT_FILTER: + RightPanelStore.article = null; + if(action.filter_id == null) { + RightPanelStore.category = null; + RightPanelStore.feed = null; + RightPanelStore.current = null; + } else if(action.filter_type == 'category_id') { + RightPanelStore.category = MenuStore._datas.categories[action.filter_id]; + RightPanelStore.feed = null; + RightPanelStore.current = 'category'; + RightPanelStore.emitChange(); + } else { + + RightPanelStore.feed = MenuStore._datas.feeds[action.filter_id]; + RightPanelStore.category = MenuStore._datas.categories[RightPanelStore.feed.category_id]; + RightPanelStore.current = 'feed'; + RightPanelStore.emitChange(); + } + break; + case ActionTypes.LOAD_ARTICLE: + RightPanelStore.feed = MenuStore._datas.feeds[action.article.feed_id]; + RightPanelStore.category = MenuStore._datas.categories[action.article.category_id]; + RightPanelStore.article = action.article; + RightPanelStore.current = 'article'; + RightPanelStore.emitChange(); + break; + case ActionTypes.RELOAD_MENU: + RightPanelStore.article = null; + if(RightPanelStore.category && !(RightPanelStore.category.id.toString() in action.categories)) { + RightPanelStore.category = null; + RightPanelStore.current = null; + } + if(RightPanelStore.feed && !(RightPanelStore.feed.id.toString() in action.feeds)) { + RightPanelStore.feed = null; + RightPanelStore.current = null; + } + if(RightPanelStore.current == 'article') { + RightPanelStore.current = null; + } + RightPanelStore.emitChange(); + default: + // pass + } +}); + +module.exports = RightPanelStore; diff --git a/newspipe/web/js/stores/__tests__/TodoStore-test.js b/newspipe/web/js/stores/__tests__/TodoStore-test.js new file mode 100644 index 00000000..6da6cd3c --- /dev/null +++ b/newspipe/web/js/stores/__tests__/TodoStore-test.js @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * TodoStore-test + */ + +jest.dontMock('../../constants/TodoConstants'); +jest.dontMock('../TodoStore'); +jest.dontMock('object-assign'); + +describe('TodoStore', function() { + + var TodoConstants = require('../../constants/TodoConstants'); + var AppDispatcher; + var TodoStore; + var callback; + + // mock actions + var actionTodoCreate = { + actionType: TodoConstants.TODO_CREATE, + text: 'foo' + }; + var actionTodoDestroy = { + actionType: TodoConstants.TODO_DESTROY, + id: 'replace me in test' + }; + + beforeEach(function() { + AppDispatcher = require('../../dispatcher/AppDispatcher'); + TodoStore = require('../TodoStore'); + callback = AppDispatcher.register.mock.calls[0][0]; + }); + + it('registers a callback with the dispatcher', function() { + expect(AppDispatcher.register.mock.calls.length).toBe(1); + }); + + it('should initialize with no to-do items', function() { + var all = TodoStore.getAll(); + expect(all).toEqual({}); + }); + + it('creates a to-do item', function() { + callback(actionTodoCreate); + var all = TodoStore.getAll(); + var keys = Object.keys(all); + expect(keys.length).toBe(1); + expect(all[keys[0]].text).toEqual('foo'); + }); + + it('destroys a to-do item', function() { + callback(actionTodoCreate); + var all = TodoStore.getAll(); + var keys = Object.keys(all); + expect(keys.length).toBe(1); + actionTodoDestroy.id = keys[0]; + callback(actionTodoDestroy); + expect(all[keys[0]]).toBeUndefined(); + }); + + it('can determine whether all to-do items are complete', function() { + var i = 0; + for (; i < 3; i++) { + callback(actionTodoCreate); + } + expect(Object.keys(TodoStore.getAll()).length).toBe(3); + expect(TodoStore.areAllComplete()).toBe(false); + + var all = TodoStore.getAll(); + for (key in all) { + callback({ + actionType: TodoConstants.TODO_COMPLETE, + id: key + }); + } + expect(TodoStore.areAllComplete()).toBe(true); + + callback({ + actionType: TodoConstants.TODO_UNDO_COMPLETE, + id: key + }); + expect(TodoStore.areAllComplete()).toBe(false); + }); + +}); diff --git a/newspipe/web/lib/__init__.py b/newspipe/web/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/newspipe/web/lib/user_utils.py b/newspipe/web/lib/user_utils.py new file mode 100644 index 00000000..f78a6ed6 --- /dev/null +++ b/newspipe/web/lib/user_utils.py @@ -0,0 +1,23 @@ + + +from itsdangerous import URLSafeTimedSerializer +import conf +from bootstrap import application + + +def generate_confirmation_token(nickname): + serializer = URLSafeTimedSerializer(application.config['SECRET_KEY']) + return serializer.dumps(nickname, salt=application.config['SECURITY_PASSWORD_SALT']) + + +def confirm_token(token): + serializer = URLSafeTimedSerializer(application.config['SECRET_KEY']) + try: + nickname = serializer.loads( + token, + salt=application.config['SECURITY_PASSWORD_SALT'], + max_age=conf.TOKEN_VALIDITY_PERIOD + ) + except: + return False + return nickname diff --git a/newspipe/web/lib/view_utils.py b/newspipe/web/lib/view_utils.py new file mode 100644 index 00000000..1d8c6aed --- /dev/null +++ b/newspipe/web/lib/view_utils.py @@ -0,0 +1,26 @@ +from functools import wraps +from flask import request, Response, make_response +from lib.utils import to_hash + + +def etag_match(func): + @wraps(func) + def wrapper(*args, **kwargs): + response = func(*args, **kwargs) + if isinstance(response, Response): + etag = to_hash(response.data) + headers = response.headers + elif type(response) is str: + etag = to_hash(response) + headers = {} + else: + return response + if request.headers.get('if-none-match') == etag: + response = Response(status=304) + response.headers['Cache-Control'] \ + = headers.get('Cache-Control', 'pragma: no-cache') + elif not isinstance(response, Response): + response = make_response(response) + response.headers['etag'] = etag + return response + return wrapper diff --git a/newspipe/web/models/__init__.py b/newspipe/web/models/__init__.py new file mode 100644 index 00000000..bfb1368c --- /dev/null +++ b/newspipe/web/models/__init__.py @@ -0,0 +1,87 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.4 $" +__date__ = "$Date: 2013/11/05 $" +__revision__ = "$Date: 2014/04/12 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +from .feed import Feed +from .role import Role +from .user import User +from .article import Article +from .icon import Icon +from .category import Category +from .tag import BookmarkTag +from .tag import ArticleTag +from .bookmark import Bookmark + +__all__ = ['Feed', 'Role', 'User', 'Article', 'Icon', 'Category', + 'Bookmark', 'ArticleTag', 'BookmarkTag'] + +import os + +from sqlalchemy.engine import reflection +from sqlalchemy.schema import ( + MetaData, + Table, + DropTable, + ForeignKeyConstraint, + DropConstraint) + +def db_empty(db): + "Will drop every datas stocked in db." + # From http://www.sqlalchemy.org/trac/wiki/UsageRecipes/DropEverything + conn = db.engine.connect() + + # the transaction only applies if the DB supports + # transactional DDL, i.e. Postgresql, MS SQL Server + trans = conn.begin() + + inspector = reflection.Inspector.from_engine(db.engine) + + # gather all data first before dropping anything. + # some DBs lock after things have been dropped in + # a transaction. + metadata = MetaData() + + tbs = [] + all_fks = [] + + for table_name in inspector.get_table_names(): + fks = [] + for fk in inspector.get_foreign_keys(table_name): + if not fk['name']: + continue + fks.append(ForeignKeyConstraint((), (), name=fk['name'])) + t = Table(table_name, metadata, *fks) + tbs.append(t) + all_fks.extend(fks) + + for fkc in all_fks: + conn.execute(DropConstraint(fkc)) + + for table in tbs: + conn.execute(DropTable(table)) + + trans.commit() diff --git a/newspipe/web/models/article.py b/newspipe/web/models/article.py new file mode 100644 index 00000000..d55e59c1 --- /dev/null +++ b/newspipe/web/models/article.py @@ -0,0 +1,87 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.5 $" +__date__ = "$Date: 2013/11/05 $" +__revision__ = "$Date: 2016/10/04 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +from bootstrap import db +from datetime import datetime +from sqlalchemy import Index +from sqlalchemy.ext.associationproxy import association_proxy + +from web.models.right_mixin import RightMixin + + +class Article(db.Model, RightMixin): + "Represent an article from a feed." + id = db.Column(db.Integer(), primary_key=True) + entry_id = db.Column(db.String(), nullable=False) + link = db.Column(db.String()) + title = db.Column(db.String()) + content = db.Column(db.String()) + readed = db.Column(db.Boolean(), default=False) + like = db.Column(db.Boolean(), default=False) + date = db.Column(db.DateTime(), default=datetime.utcnow) + updated_date = db.Column(db.DateTime(), default=datetime.utcnow) + retrieved_date = db.Column(db.DateTime(), default=datetime.utcnow) + + # foreign keys + user_id = db.Column(db.Integer(), db.ForeignKey('user.id')) + feed_id = db.Column(db.Integer(), db.ForeignKey('feed.id')) + category_id = db.Column(db.Integer(), db.ForeignKey('category.id')) + + # relationships + tag_objs = db.relationship('ArticleTag', back_populates='article', + cascade='all,delete-orphan', + lazy=False, + foreign_keys='[ArticleTag.article_id]') + tags = association_proxy('tag_objs', 'text') + + # indexes + #__table_args__ = ( + # Index('user_id'), + # Index('user_id', 'category_id'), + # Index('user_id', 'feed_id'), + # Index('ix_article_uid_fid_eid', user_id, feed_id, entry_id) + #) + + # api whitelists + @staticmethod + def _fields_base_write(): + return {'readed', 'like', 'feed_id', 'category_id'} + + @staticmethod + def _fields_base_read(): + return {'id', 'entry_id', 'link', 'title', 'content', 'date', + 'retrieved_date', 'user_id', 'tags'} + + @staticmethod + def _fields_api_write(): + return {'tags'} + + def __repr__(self): + return "" % (self.id, self.entry_id, + self.title, self.date, self.retrieved_date) diff --git a/newspipe/web/models/bookmark.py b/newspipe/web/models/bookmark.py new file mode 100644 index 00000000..eb6b73e3 --- /dev/null +++ b/newspipe/web/models/bookmark.py @@ -0,0 +1,68 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.1 $" +__date__ = "$Date: 2016/12/07 $" +__revision__ = "$Date: 2016/12/07 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +from bootstrap import db +from datetime import datetime +from sqlalchemy import desc +from sqlalchemy.orm import validates +from sqlalchemy.ext.associationproxy import association_proxy + +from web.models.tag import BookmarkTag +from web.models.right_mixin import RightMixin + + +class Bookmark(db.Model, RightMixin): + """ + Represent a bookmark. + """ + id = db.Column(db.Integer(), primary_key=True) + href = db.Column(db.String(), default="") + title = db.Column(db.String(), default="") + description = db.Column(db.String(), default="") + shared = db.Column(db.Boolean(), default=False) + to_read = db.Column(db.Boolean(), default=False) + time = db.Column(db.DateTime(), default=datetime.utcnow) + user_id = db.Column(db.Integer(), db.ForeignKey('user.id')) + + # relationships + tags = db.relationship(BookmarkTag, backref='of_bookmark', lazy='dynamic', + cascade='all,delete-orphan', + order_by=desc(BookmarkTag.text)) + tags_proxy = association_proxy('tags', 'text') + + + @validates('description') + def validates_title(self, key, value): + return str(value).strip() + + @validates('extended') + def validates_description(self, key, value): + return str(value).strip() + + def __repr__(self): + return '' % (self.href) diff --git a/newspipe/web/models/category.py b/newspipe/web/models/category.py new file mode 100644 index 00000000..2da7809a --- /dev/null +++ b/newspipe/web/models/category.py @@ -0,0 +1,29 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +from bootstrap import db +from sqlalchemy import Index +from web.models.right_mixin import RightMixin + + +class Category(db.Model, RightMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String()) + + # relationships + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + feeds = db.relationship('Feed', cascade='all,delete-orphan') + articles = db.relationship('Article', + cascade='all,delete-orphan') + + # index + idx_category_uid = Index('user_id') + + # api whitelists + @staticmethod + def _fields_base_read(): + return {'id', 'user_id'} + + @staticmethod + def _fields_base_write(): + return {'name'} diff --git a/newspipe/web/models/feed.py b/newspipe/web/models/feed.py new file mode 100644 index 00000000..fc0b64cb --- /dev/null +++ b/newspipe/web/models/feed.py @@ -0,0 +1,91 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.4 $" +__date__ = "$Date: 2013/11/05 $" +__revision__ = "$Date: 2014/04/12 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +from bootstrap import db +from datetime import datetime +from sqlalchemy import desc, Index +from sqlalchemy.orm import validates +from web.models.right_mixin import RightMixin +from web.models.article import Article + + +class Feed(db.Model, RightMixin): + """ + Represent a feed. + """ + id = db.Column(db.Integer(), primary_key=True) + title = db.Column(db.String(), default="") + description = db.Column(db.String(), default="FR") + link = db.Column(db.String(), nullable=False) + site_link = db.Column(db.String(), default="") + enabled = db.Column(db.Boolean(), default=True) + created_date = db.Column(db.DateTime(), default=datetime.utcnow) + filters = db.Column(db.PickleType, default=[]) + private = db.Column(db.Boolean(), default=False) + + # cache handling + etag = db.Column(db.String(), default="") + last_modified = db.Column(db.String(), default="") + last_retrieved = db.Column(db.DateTime(), default=datetime(1970, 1, 1)) + + # error logging + last_error = db.Column(db.String(), default="") + error_count = db.Column(db.Integer(), default=0) + + # relationship + icon_url = db.Column(db.String(), db.ForeignKey('icon.url'), default=None) + user_id = db.Column(db.Integer(), db.ForeignKey('user.id')) + category_id = db.Column(db.Integer(), db.ForeignKey('category.id')) + articles = db.relationship(Article, backref='source', lazy='dynamic', + cascade='all,delete-orphan', + order_by=desc(Article.date)) + + # index + idx_feed_uid_cid = Index('user_id', 'category_id') + idx_feed_uid = Index('user_id') + + # api whitelists + @staticmethod + def _fields_base_write(): + return {'title', 'description', 'link', 'site_link', 'enabled', + 'filters', 'last_error', 'error_count', 'category_id'} + + @staticmethod + def _fields_base_read(): + return {'id', 'user_id', 'icon_url', 'last_retrieved'} + + @validates('title') + def validates_title(self, key, value): + return str(value).strip() + + @validates('description') + def validates_description(self, key, value): + return str(value).strip() + + def __repr__(self): + return '' % (self.title) diff --git a/newspipe/web/models/icon.py b/newspipe/web/models/icon.py new file mode 100644 index 00000000..adc9cf69 --- /dev/null +++ b/newspipe/web/models/icon.py @@ -0,0 +1,10 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +from bootstrap import db + + +class Icon(db.Model): + url = db.Column(db.String(), primary_key=True) + content = db.Column(db.String(), default=None) + mimetype = db.Column(db.String(), default="application/image") diff --git a/newspipe/web/models/right_mixin.py b/newspipe/web/models/right_mixin.py new file mode 100644 index 00000000..1c316f95 --- /dev/null +++ b/newspipe/web/models/right_mixin.py @@ -0,0 +1,63 @@ +from sqlalchemy.ext.associationproxy import _AssociationList + + +class RightMixin: + + @staticmethod + def _fields_base_write(): + return set() + + @staticmethod + def _fields_base_read(): + return set(['id']) + + @staticmethod + def _fields_api_write(): + return set([]) + + @staticmethod + def _fields_api_read(): + return set(['id']) + + @classmethod + def fields_base_write(cls): + return cls._fields_base_write() + + @classmethod + def fields_base_read(cls): + return cls._fields_base_write().union(cls._fields_base_read()) + + @classmethod + def fields_api_write(cls): + return cls.fields_base_write().union(cls._fields_api_write()) + + @classmethod + def fields_api_read(cls): + return cls.fields_base_read().union(cls._fields_api_read()) + + def __getitem__(self, key): + if not hasattr(self, '__dump__'): + self.__dump__ = {} + return self.__dump__.get(key) + + def __setitem__(self, key, value): + if not hasattr(self, '__dump__'): + self.__dump__ = {} + self.__dump__[key] = value + + def dump(self, role='admin'): + if role == 'admin': + dico = {k: getattr(self, k) + for k in set(self.__table__.columns.keys()) + .union(self.fields_api_read()) + .union(self.fields_base_read())} + elif role == 'api': + dico = {k: getattr(self, k) for k in self.fields_api_read()} + else: + dico = {k: getattr(self, k) for k in self.fields_base_read()} + if hasattr(self, '__dump__'): + dico.update(self.__dump__) + for key, value in dico.items(): # preventing association proxy to die + if isinstance(value, _AssociationList): + dico[key] = list(value) + return dico diff --git a/newspipe/web/models/role.py b/newspipe/web/models/role.py new file mode 100644 index 00000000..0a2ecd4a --- /dev/null +++ b/newspipe/web/models/role.py @@ -0,0 +1,39 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.4 $" +__date__ = "$Date: 2013/11/05 $" +__revision__ = "$Date: 2014/04/12 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +from bootstrap import db + + +class Role(db.Model): + """ + Represent a role. + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(), unique=True) + + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) diff --git a/newspipe/web/models/tag.py b/newspipe/web/models/tag.py new file mode 100644 index 00000000..76467c0b --- /dev/null +++ b/newspipe/web/models/tag.py @@ -0,0 +1,36 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +from bootstrap import db + + +class ArticleTag(db.Model): + text = db.Column(db.String, primary_key=True, unique=False) + + # foreign keys + article_id = db.Column(db.Integer, db.ForeignKey('article.id', ondelete='CASCADE'), + primary_key=True) + + # relationships + article = db.relationship('Article', back_populates='tag_objs', + foreign_keys=[article_id]) + + def __init__(self, text): + self.text = text + + +class BookmarkTag(db.Model): + id = db.Column(db.Integer, primary_key=True) + text = db.Column(db.String, unique=False) + + # foreign keys + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) + bookmark_id = db.Column(db.Integer, db.ForeignKey('bookmark.id', ondelete='CASCADE')) + + # relationships + bookmark = db.relationship('Bookmark', back_populates='tags', + cascade="all,delete", foreign_keys=[bookmark_id]) + + # def __init__(self, text, user_id): + # self.text = text + # self.user_id = user_id diff --git a/newspipe/web/models/user.py b/newspipe/web/models/user.py new file mode 100644 index 00000000..4d65c3c5 --- /dev/null +++ b/newspipe/web/models/user.py @@ -0,0 +1,108 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# newspipe - A Web based news aggregator. +# Copyright (C) 2010-2018 Cédric Bonhomme - https://www.cedricbonhomme.org +# +# For more information : https://gitlab.com/newspipe/newspipe +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.4 $" +__date__ = "$Date: 2013/11/05 $" +__revision__ = "$Date: 2014/04/12 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "GPLv3" + +import re +import random +import hashlib +from datetime import datetime +from werkzeug.security import check_password_hash +from flask_login import UserMixin +from sqlalchemy.orm import validates + +from bootstrap import db +from web.models.right_mixin import RightMixin +from web.models.category import Category +from web.models.feed import Feed + + +class User(db.Model, UserMixin, RightMixin): + """ + Represent a user. + """ + id = db.Column(db.Integer, primary_key=True) + nickname = db.Column(db.String(), unique=True) + pwdhash = db.Column(db.String()) + + automatic_crawling = db.Column(db.Boolean(), default=True) + + is_public_profile = db.Column(db.Boolean(), default=False) + bio = db.Column(db.String(5000), default="") + webpage = db.Column(db.String(), default="") + twitter = db.Column(db.String(), default="") + + date_created = db.Column(db.DateTime(), default=datetime.utcnow) + last_seen = db.Column(db.DateTime(), default=datetime.utcnow) + + # user rights + is_active = db.Column(db.Boolean(), default=False) + is_admin = db.Column(db.Boolean(), default=False) + is_api = db.Column(db.Boolean(), default=False) + + # relationships + categories = db.relationship('Category', backref='user', + cascade='all, delete-orphan', + foreign_keys=[Category.user_id]) + feeds = db.relationship('Feed', backref='user', + cascade='all, delete-orphan', + foreign_keys=[Feed.user_id]) + + @staticmethod + def _fields_base_write(): + return {'login', 'password'} + + @staticmethod + def _fields_base_read(): + return {'date_created', 'last_connection'} + + @staticmethod + def make_valid_nickname(nickname): + return re.sub('[^a-zA-Z0-9_\.]', '', nickname) + + @validates('bio') + def validates_bio(self, key, value): + assert len(value) <= 5000, \ + AssertionError("maximum length for bio: 5000") + return value.strip() + + def get_id(self): + """ + Return the id of the user. + """ + return self.id + + def check_password(self, password): + """ + Check the password of the user. + """ + return check_password_hash(self.pwdhash, password) + + def __eq__(self, other): + return self.id == other.id + + def __repr__(self): + return '' % (self.nickname) diff --git a/newspipe/web/static/css/bootstrap-theme.min.css b/newspipe/web/static/css/bootstrap-theme.min.css new file mode 120000 index 00000000..06469c8d --- /dev/null +++ b/newspipe/web/static/css/bootstrap-theme.min.css @@ -0,0 +1 @@ +../bower_components/bootstrap/dist/css/bootstrap-theme.min.css \ No newline at end of file diff --git a/newspipe/web/static/css/bootstrap-theme.min.css.map b/newspipe/web/static/css/bootstrap-theme.min.css.map new file mode 120000 index 00000000..0448a4a0 --- /dev/null +++ b/newspipe/web/static/css/bootstrap-theme.min.css.map @@ -0,0 +1 @@ +../bower_components/bootstrap/dist/css/bootstrap-theme.min.css.map \ No newline at end of file diff --git a/newspipe/web/static/css/bootstrap.min.css b/newspipe/web/static/css/bootstrap.min.css new file mode 120000 index 00000000..30c399cb --- /dev/null +++ b/newspipe/web/static/css/bootstrap.min.css @@ -0,0 +1 @@ +../bower_components/bootstrap/dist/css/bootstrap.min.css \ No newline at end of file diff --git a/newspipe/web/static/css/bootstrap.min.css.map b/newspipe/web/static/css/bootstrap.min.css.map new file mode 120000 index 00000000..146f88dd --- /dev/null +++ b/newspipe/web/static/css/bootstrap.min.css.map @@ -0,0 +1 @@ +../bower_components/bootstrap/dist/css/bootstrap.min.css.map \ No newline at end of file diff --git a/newspipe/web/static/css/customized-bootstrap.css b/newspipe/web/static/css/customized-bootstrap.css new file mode 100644 index 00000000..c385c908 --- /dev/null +++ b/newspipe/web/static/css/customized-bootstrap.css @@ -0,0 +1,55 @@ +body { + margin-top: 50px; +} +div.top { + position: relative; + top: -50px; + display: block; + height: 0; +} + +#newspipenav { + background-color: #205081; + border: #205081; + border-radius: 0; +} +#newspipenav>div.container { + width: 100%; +} +#newspipenav span.glyphicon { + margin-right: 5px; +} +#newspipenav button { + margin-left: 5px; +} + +#newspipenav a.navbar-brand, +#newspipenav .newspipenavitem a, +#newspipenav a.dropdown-toggle{ + color: white; +} +#newspipenav .navbar-nav > .open > a, +#newspipenav .navbar-nav > .open > a:hover, +#newspipenav .navbar-nav > li > a:hover { + background-color: #3572B0; +} +a { + color: #3572B0; +} + +.input-group-inline { + min-width: 0; + width: 200px; + display: inline; +} + +.alert-message { + position: relative; + display: block; + z-index: 1001; +} + +/*customization for Flask-Paginate*/ +.pagination-page-info { + display: inline; +} diff --git a/newspipe/web/static/css/one-page-app.css b/newspipe/web/static/css/one-page-app.css new file mode 100644 index 00000000..f8c443c3 --- /dev/null +++ b/newspipe/web/static/css/one-page-app.css @@ -0,0 +1,167 @@ +#newspipe-container { + padding-left: 0px; + padding-right: 0px; +} +#menu { + position: fixed; + top: 50px; + bottom: 0px; + left: 0px; + z-index: 1000; + display: block; + padding: 10px; + overflow-x: hidden; + overflow-y: auto; + background-color: #F5F5F5; + border-right: 1px solid #EEE; + color: #555; +} +#menu div.nav.btn-group { + margin-bottom: 10px; +} +#menu li { + padding: 2px; + cursor: pointer; + border-radius: 6px; +} +#menu li.nav-feed { + margin-left: 15px; + margin-bottom: 3px; + max-height: 22px; + overflow: hidden; +} +#menu li.nav-feed > span.badge { + top: 2px; + position: absolute; + right: 2px; +} +#menu li.nav-feed > span.title { + margin-left: 3px; +} +#menu li.bg-primary.bg-danger { + color: #fff; + background-color: orangered; +} +#menu li.bg-primary.bg-warning { + color: #fff; + background-color: gold; +} +#menu li:hover { + color: #000; + background-color: #e8e8e8; +} +#menu li.bg-primary:hover { + color: #fff; + background-color: #62a9e6; +} +#menu li.bg-warning:hover { + background-color: #f3f0da; +} +#menu li.bg-danger:hover { + background-color: #f6cab6; +} +#menu li > h4 { + padding-left: 5px; + margin: 2px; + display: inline; +} +#middle-panel { + padding-left: 20px; + padding-top: 10px; + padding-right: 20px; + position: fixed; + top: 50px; + bottom: 0px; + left: 0px; + z-index: 1000; + display: block; + overflow-x: hidden; + overflow-y: auto; + background-color: #F5F5F5; + border-right: 1px solid #EEE; +} +#middle-panel .btn-group, +#menu .btn-group { + margin-right: 10px; + margin-bottom: 10px; +} +#middle-panel .btn-group:last-child, +#menu .btn-group:last-child { + margin-right: 0px; + float: right; +} +#middle-panel .input-group { + margin-bottom: 10px; +} +#middle-panel div.list-group-item{ + padding: 5px 8px; + cursor: pointer; +} +#middle-panel div.list-group-item:hover { + background-color: #f0f0f0; +} +#middle-panel div.list-group-item.active a { + color: #eee; +} +#middle-panel div.list-group-item.active:hover { + background-color: #4d94d1; + border-color: #4d94d1; +} +#middle-panel div.list-group-item:hover { + background-color: #f0f0f0; +} +#middle-panel div.list-group-item>h5 { + margin: 0px; +} +#middle-panel div.list-group-item>div:last-child{ + width: 100%; + max-height: 22px; + overflow: hidden; +} +#middle-panel div.list-group-item>time { + position: absolute; + top: 2px; + right: 4px; +} +#right-panel { + top: 0px; +} +#rp-breadcrum{ + margin-top: 10px; + max-height: 34px; + overflow: hidden; + padding-top: 2px; +} +#rp-breadcrum>li{ + display: inline; + line-height: 30px; +} +#right-panel .panel-body img { + max-width: 100%; +} +#right-panel-heading * { + display: inline; +} +#right-panel-heading h4 img { + margin-right: 5px; +} +#right-panel-heading div.btn-group { + float: right; + margin-top: -5px; + margin-right: -8px; +} +.panel-body dd>input { + width: 100%; +} +.filter-row>select.form-control:first-child { + width: 10%; +} +.filter-row>select.form-control { + width: 15%; +} +.filter-row>select.form-control:last-child { + width: 25%; +} +.filter-row>input.form-control { + width: auto; +} diff --git a/newspipe/web/static/fonts b/newspipe/web/static/fonts new file mode 120000 index 00000000..4097ea8b --- /dev/null +++ b/newspipe/web/static/fonts @@ -0,0 +1 @@ +bower_components/bootstrap/dist/fonts/ \ No newline at end of file diff --git a/newspipe/web/static/img/favicon.ico b/newspipe/web/static/img/favicon.ico new file mode 100644 index 00000000..5b056c1e Binary files /dev/null and b/newspipe/web/static/img/favicon.ico differ diff --git a/newspipe/web/static/img/newspipe.png b/newspipe/web/static/img/newspipe.png new file mode 100644 index 00000000..c3ba5029 Binary files /dev/null and b/newspipe/web/static/img/newspipe.png differ diff --git a/newspipe/web/static/img/newspipe.svg b/newspipe/web/static/img/newspipe.svg new file mode 100644 index 00000000..be22ae42 --- /dev/null +++ b/newspipe/web/static/img/newspipe.svg @@ -0,0 +1,84 @@ + + + + +Created by potrace 1.13, written by Peter Selinger 2001-2015 + + + + + + + + + + + + + + + + + + + diff --git a/newspipe/web/static/img/pinboard.png b/newspipe/web/static/img/pinboard.png new file mode 100644 index 00000000..6dddc10b Binary files /dev/null and b/newspipe/web/static/img/pinboard.png differ diff --git a/newspipe/web/static/img/reddit.png b/newspipe/web/static/img/reddit.png new file mode 100755 index 00000000..2d615f2a Binary files /dev/null and b/newspipe/web/static/img/reddit.png differ diff --git a/newspipe/web/static/img/twitter.png b/newspipe/web/static/img/twitter.png new file mode 100644 index 00000000..fc11c4ce Binary files /dev/null and b/newspipe/web/static/img/twitter.png differ diff --git a/newspipe/web/static/js/articles.js b/newspipe/web/static/js/articles.js new file mode 100644 index 00000000..350723a4 --- /dev/null +++ b/newspipe/web/static/js/articles.js @@ -0,0 +1,191 @@ +/*! +* pyAggr3g470r - A Web based news aggregator. +* Copyright (C) 2010-2014 Cédric Bonhomme - http://cedricbonhomme.org/ +* +* For more information : https://bitbucket.org/cedricbonhomme/pyaggr3g470r/ +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . + */ + +API_ROOT = '/api/v2.0/' + +if (typeof jQuery === 'undefined') { throw new Error('Requires jQuery') } + +function change_unread_counter(feed_id, increment) { + var new_value = parseInt($("#unread-"+feed_id).text()) + increment; + $("#unread-"+feed_id).text(new_value); + $("#total-unread").text(parseInt($("#total-unread").text()) + increment); + if (new_value == 0) { + $("#unread-"+feed_id).hide(); + } else { + $("#unread-"+feed_id).show(); + } +} + ++function ($) { + + // Mark an article as read when it is opened in a new table + $('.open-article').on('click', function(e) { + var feed_id = $(this).parent().parent().attr("data-feed"); + var filter = $('#filters').attr("data-filter"); + if (filter == "unread") { + $(this).parent().parent().remove(); + change_unread_counter(feed_id, -1); + } + }); + + + + // Mark an article as read or unread. + $('.readed').on('click', function() { + var article_id = $(this).parent().parent().parent().attr("data-article"); + var feed_id = $(this).parent().parent().parent().attr("data-feed"); + var filter = $('#filters').attr("data-filter"); + + var data; + if ($(this).hasClass("glyphicon-unchecked")) { + data = JSON.stringify({ + readed: false + }) + if (filter == "read") { + $(this).parent().parent().parent().remove(); + } + else { + // here, filter == "all" + $(this).parent().parent().parent().children("td:nth-child(2)").css( "font-weight", "bold" ); + $(this).removeClass('glyphicon-unchecked').addClass('glyphicon-check'); + } + change_unread_counter(feed_id, 1); + } + else { + data = JSON.stringify({readed: true}) + if (filter == "unread") { + $(this).parent().parent().parent().remove(); + } + else { + // here, filter == "all" + $(this).parent().parent().parent().children("td:nth-child(2)").css( "font-weight", "normal" ); + $(this).removeClass('glyphicon-check').addClass('glyphicon-unchecked'); + } + change_unread_counter(feed_id, -1); + } + + // sends the updates to the server + $.ajax({ + type: 'PUT', + // Provide correct Content-Type, so that Flask will know how to process it. + contentType: 'application/json', + // Encode your data as JSON. + data: data, + // This is the type of data you're expecting back from the server. + url: API_ROOT + "article/" + article_id, + success: function (result) { + //console.log(result); + }, + error: function(XMLHttpRequest, textStatus, errorThrown){ + console.log(XMLHttpRequest.responseText); + } + }); + }); + + + + // Like or unlike an article + $('.like').on('click', function() { + var article_id = $(this).parent().parent().parent().attr("data-article"); + var data; + if ($(this).hasClass("glyphicon-star")) { + data = JSON.stringify({like: false}) + $(this).removeClass('glyphicon-star').addClass('glyphicon-star-empty'); + if(window.location.pathname.indexOf('/favorites') != -1) { + $(this).parent().parent().parent().remove(); + } + } + else { + data = JSON.stringify({like: true}) + $(this).removeClass('glyphicon-star-empty').addClass('glyphicon-star'); + } + + // sends the updates to the server + $.ajax({ + type: 'PUT', + // Provide correct Content-Type, so that Flask will know how to process it. + contentType: 'application/json', + // Encode your data as JSON. + data: data, + // This is the type of data you're expecting back from the server. + url: API_ROOT + "article/" + article_id, + success: function (result) { + //console.log(result); + }, + error: function(XMLHttpRequest, textStatus, errorThrown){ + console.log(XMLHttpRequest.responseText); + } + }); + }); + + + + // Delete an article + $('.delete').on('click', function() { + var feed_id = $(this).parent().parent().parent().attr("data-feed"); + var article_id = $(this).parent().parent().parent().attr("data-article"); + $(this).parent().parent().parent().remove(); + + // sends the updates to the server + $.ajax({ + type: 'DELETE', + url: API_ROOT + "article/" + article_id, + success: function (result) { + change_unread_counter(feed_id, -1); + }, + error: function(XMLHttpRequest, textStatus, errorThrown){ + console.log(XMLHttpRequest.responseText); + } + }); + }); + + + + // Delete all duplicate articles (used in the page /duplicates) + $('.delete-all').click(function(){ + var data = []; + + var columnNo = $(this).parent().index(); + $(this).closest("table") + .find("tr td:nth-child(" + (columnNo+1) + ")") + .each(function(line, column) { + data.push(parseInt(column.id)); + }).remove(); + + data = JSON.stringify(data); + + // sends the updates to the server + $.ajax({ + type: 'DELETE', + // Provide correct Content-Type, so that Flask will know how to process it. + contentType: 'application/json', + data: data, + url: API_ROOT + "articles", + success: function (result) { + //console.log(result); + }, + error: function(XMLHttpRequest, textStatus, errorThrown){ + console.log(XMLHttpRequest.responseText); + } + }); + + }); + +}(jQuery); diff --git a/newspipe/web/static/js/feed.js b/newspipe/web/static/js/feed.js new file mode 100644 index 00000000..ceef58fc --- /dev/null +++ b/newspipe/web/static/js/feed.js @@ -0,0 +1,22 @@ +$('.container').on('click', '#add-feed-filter-row', function() { + $('#filters-container').append( + '
      ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
      '); +}); +$('.container').on('click', '.del-feed-filter-row', function() { + $(this).parent().remove(); +}); diff --git a/newspipe/web/static/js/jquery.js b/newspipe/web/static/js/jquery.js new file mode 100644 index 00000000..e5ace116 --- /dev/null +++ b/newspipe/web/static/js/jquery.js @@ -0,0 +1,4 @@ +/*! jQuery v2.1.1 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ +!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l=a.document,m="2.1.1",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return n.each(this,a,b)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(a=arguments[h]))for(b in a)c=g[b],d=a[b],g!==d&&(j&&d&&(n.isPlainObject(d)||(e=n.isArray(d)))?(e?(e=!1,f=c&&n.isArray(c)?c:[]):f=c&&n.isPlainObject(c)?c:{},g[b]=n.extend(j,f,d)):void 0!==d&&(g[b]=d));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray,isWindow:function(a){return null!=a&&a===a.window},isNumeric:function(a){return!n.isArray(a)&&a-parseFloat(a)>=0},isPlainObject:function(a){return"object"!==n.type(a)||a.nodeType||n.isWindow(a)?!1:a.constructor&&!j.call(a.constructor.prototype,"isPrototypeOf")?!1:!0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(a){var b,c=eval;a=n.trim(a),a&&(1===a.indexOf("use strict")?(b=l.createElement("script"),b.text=a,l.head.appendChild(b).parentNode.removeChild(b)):c(a))},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=s(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){return null==b?-1:g.call(b,a,c)},merge:function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;d++)a[e++]=b[d];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=s(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(c=a[b],b=a,a=c),n.isFunction(a)?(e=d.call(arguments,2),f=function(){return a.apply(b||this,e.concat(d.call(arguments)))},f.guid=a.guid=a.guid||n.guid++,f):void 0},now:Date.now,support:k}),n.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function s(a){var b=a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+-new Date,v=a.document,w=0,x=0,y=gb(),z=gb(),A=gb(),B=function(a,b){return a===b&&(l=!0),0},C="undefined",D=1<<31,E={}.hasOwnProperty,F=[],G=F.pop,H=F.push,I=F.push,J=F.slice,K=F.indexOf||function(a){for(var b=0,c=this.length;c>b;b++)if(this[b]===a)return b;return-1},L="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",N="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=N.replace("w","w#"),P="\\["+M+"*("+N+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+O+"))|)"+M+"*\\]",Q=":("+N+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+P+")*)|.*)\\)|)",R=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),S=new RegExp("^"+M+"*,"+M+"*"),T=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),V=new RegExp(Q),W=new RegExp("^"+O+"$"),X={ID:new RegExp("^#("+N+")"),CLASS:new RegExp("^\\.("+N+")"),TAG:new RegExp("^("+N.replace("w","w*")+")"),ATTR:new RegExp("^"+P),PSEUDO:new RegExp("^"+Q),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)};try{I.apply(F=J.call(v.childNodes),v.childNodes),F[v.childNodes.length].nodeType}catch(eb){I={apply:F.length?function(a,b){H.apply(a,J.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],!a||"string"!=typeof a)return d;if(1!==(k=b.nodeType)&&9!==k)return[];if(p&&!e){if(f=_.exec(a))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return I.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName&&b.getElementsByClassName)return I.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=9===k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+qb(o[l]);w=ab.test(a)&&ob(b.parentNode)||b,x=o.join(",")}if(x)try{return I.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function gb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function hb(a){return a[u]=!0,a}function ib(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function jb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function kb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||D)-(~a.sourceIndex||D);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function lb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function nb(a){return hb(function(b){return b=+b,hb(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function ob(a){return a&&typeof a.getElementsByTagName!==C&&a}c=fb.support={},f=fb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fb.setDocument=function(a){var b,e=a?a.ownerDocument||a:v,g=e.defaultView;return e!==n&&9===e.nodeType&&e.documentElement?(n=e,o=e.documentElement,p=!f(e),g&&g!==g.top&&(g.addEventListener?g.addEventListener("unload",function(){m()},!1):g.attachEvent&&g.attachEvent("onunload",function(){m()})),c.attributes=ib(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ib(function(a){return a.appendChild(e.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(e.getElementsByClassName)&&ib(function(a){return a.innerHTML="
      ",a.firstChild.className="i",2===a.getElementsByClassName("i").length}),c.getById=ib(function(a){return o.appendChild(a).id=u,!e.getElementsByName||!e.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if(typeof b.getElementById!==C&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c=typeof a.getAttributeNode!==C&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return typeof b.getElementsByTagName!==C?b.getElementsByTagName(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return typeof b.getElementsByClassName!==C&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(e.querySelectorAll))&&(ib(function(a){a.innerHTML="",a.querySelectorAll("[msallowclip^='']").length&&q.push("[*^$]="+M+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+M+"*(?:value|"+L+")"),a.querySelectorAll(":checked").length||q.push(":checked")}),ib(function(a){var b=e.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+M+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ib(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",Q)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===e||a.ownerDocument===v&&t(v,a)?-1:b===e||b.ownerDocument===v&&t(v,b)?1:k?K.call(k,a)-K.call(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,f=a.parentNode,g=b.parentNode,h=[a],i=[b];if(!f||!g)return a===e?-1:b===e?1:f?-1:g?1:k?K.call(k,a)-K.call(k,b):0;if(f===g)return kb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?kb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},e):n},fb.matches=function(a,b){return fb(a,null,null,b)},fb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fb(b,n,null,[a]).length>0},fb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&E.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fb.selectors={cacheLength:50,createPseudo:hb,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+M+")"+a+"("+M+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||typeof a.getAttribute!==C&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?hb(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=K.call(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:hb(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?hb(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),!c.pop()}}),has:hb(function(a){return function(b){return fb(a,b).length>0}}),contains:hb(function(a){return function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:hb(function(a){return W.test(a||"")||fb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:nb(function(){return[0]}),last:nb(function(a,b){return[b-1]}),eq:nb(function(a,b,c){return[0>c?c+b:c]}),even:nb(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:nb(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:nb(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:nb(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function rb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function sb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function tb(a,b,c){for(var d=0,e=b.length;e>d;d++)fb(a,b[d],c);return c}function ub(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function vb(a,b,c,d,e,f){return d&&!d[u]&&(d=vb(d)),e&&!e[u]&&(e=vb(e,f)),hb(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||tb(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ub(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ub(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?K.call(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ub(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):I.apply(g,r)})}function wb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=rb(function(a){return a===b},h,!0),l=rb(function(a){return K.call(b,a)>-1},h,!0),m=[function(a,c,d){return!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d))}];f>i;i++)if(c=d.relative[a[i].type])m=[rb(sb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return vb(i>1&&sb(m),i>1&&qb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&wb(a.slice(i,e)),f>e&&wb(a=a.slice(e)),f>e&&qb(a))}m.push(c)}return sb(m)}function xb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=G.call(i));s=ub(s)}I.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&fb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?hb(f):f}return h=fb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xb(e,d)),f.selector=a}return f},i=fb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&ob(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qb(j),!a)return I.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&ob(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ib(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ib(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||jb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ib(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||jb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ib(function(a){return null==a.getAttribute("disabled")})||jb(L,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fb}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=n.expr.match.needsContext,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^.[^:#\[\.,]*$/;function x(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(w.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return g.call(b,a)>=0!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=this.length,d=[],e=this;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;c>b;b++)if(n.contains(e[b],this))return!0}));for(b=0;c>b;b++)n.find(a,e[b],d);return d=this.pushStack(c>1?n.unique(d):d),d.selector=this.selector?this.selector+" "+a:a,d},filter:function(a){return this.pushStack(x(this,a||[],!1))},not:function(a){return this.pushStack(x(this,a||[],!0))},is:function(a){return!!x(this,"string"==typeof a&&u.test(a)?n(a):a||[],!1).length}});var y,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=n.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||y).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:l,!0)),v.test(c[1])&&n.isPlainObject(b))for(c in b)n.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}return d=l.getElementById(c[2]),d&&d.parentNode&&(this.length=1,this[0]=d),this.context=l,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof y.ready?y.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};A.prototype=n.fn,y=n(l);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};n.extend({dir:function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),n.fn.extend({has:function(a){var b=n(a,this),c=b.length;return this.filter(function(){for(var a=0;c>a;a++)if(n.contains(this,b[a]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=u.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.unique(f):f)},index:function(a){return a?"string"==typeof a?g.call(n(a),this[0]):g.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.unique(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){while((a=a[b])&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return n.dir(a,"parentNode")},parentsUntil:function(a,b,c){return n.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return n.dir(a,"nextSibling")},prevAll:function(a){return n.dir(a,"previousSibling")},nextUntil:function(a,b,c){return n.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return n.dir(a,"previousSibling",c)},siblings:function(a){return n.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return n.sibling(a.firstChild)},contents:function(a){return a.contentDocument||n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(C[a]||n.unique(e),B.test(a)&&e.reverse()),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return n.each(a.match(E)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):n.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(b=a.memory&&l,c=!0,g=e||0,e=0,f=h.length,d=!0;h&&f>g;g++)if(h[g].apply(l[0],l[1])===!1&&a.stopOnFalse){b=!1;break}d=!1,h&&(i?i.length&&j(i.shift()):b?h=[]:k.disable())},k={add:function(){if(h){var c=h.length;!function g(b){n.each(b,function(b,c){var d=n.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&g(c)})}(arguments),d?f=h.length:b&&(e=c,j(b))}return this},remove:function(){return h&&n.each(arguments,function(a,b){var c;while((c=n.inArray(b,h,c))>-1)h.splice(c,1),d&&(f>=c&&f--,g>=c&&g--)}),this},has:function(a){return a?n.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],f=0,this},disable:function(){return h=i=b=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,b||k.disable(),this},locked:function(){return!i},fireWith:function(a,b){return!h||c&&!i||(b=b||[],b=[a,b.slice?b.slice():b],d?i.push(b):j(b)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!c}};return k},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&n.isFunction(a.promise)?e:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(H.resolveWith(l,[n]),n.fn.triggerHandler&&(n(l).triggerHandler("ready"),n(l).off("ready"))))}});function I(){l.removeEventListener("DOMContentLoaded",I,!1),a.removeEventListener("load",I,!1),n.ready()}n.ready.promise=function(b){return H||(H=n.Deferred(),"complete"===l.readyState?setTimeout(n.ready):(l.addEventListener("DOMContentLoaded",I,!1),a.addEventListener("load",I,!1))),H.promise(b)},n.ready.promise();var J=n.access=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===n.type(c)){e=!0;for(h in c)n.access(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,n.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(n(a),c)})),b))for(;i>h;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f};n.acceptData=function(a){return 1===a.nodeType||9===a.nodeType||!+a.nodeType};function K(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=n.expando+Math.random()}K.uid=1,K.accepts=n.acceptData,K.prototype={key:function(a){if(!K.accepts(a))return 0;var b={},c=a[this.expando];if(!c){c=K.uid++;try{b[this.expando]={value:c},Object.defineProperties(a,b)}catch(d){b[this.expando]=c,n.extend(a,b)}}return this.cache[c]||(this.cache[c]={}),c},set:function(a,b,c){var d,e=this.key(a),f=this.cache[e];if("string"==typeof b)f[b]=c;else if(n.isEmptyObject(f))n.extend(this.cache[e],b);else for(d in b)f[d]=b[d];return f},get:function(a,b){var c=this.cache[this.key(a)];return void 0===b?c:c[b]},access:function(a,b,c){var d;return void 0===b||b&&"string"==typeof b&&void 0===c?(d=this.get(a,b),void 0!==d?d:this.get(a,n.camelCase(b))):(this.set(a,b,c),void 0!==c?c:b)},remove:function(a,b){var c,d,e,f=this.key(a),g=this.cache[f];if(void 0===b)this.cache[f]={};else{n.isArray(b)?d=b.concat(b.map(n.camelCase)):(e=n.camelCase(b),b in g?d=[b,e]:(d=e,d=d in g?[d]:d.match(E)||[])),c=d.length;while(c--)delete g[d[c]]}},hasData:function(a){return!n.isEmptyObject(this.cache[a[this.expando]]||{})},discard:function(a){a[this.expando]&&delete this.cache[a[this.expando]]}};var L=new K,M=new K,N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){var d;if(void 0===c&&1===a.nodeType)if(d="data-"+b.replace(O,"-$1").toLowerCase(),c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}M.set(a,b,c)}else c=void 0;return c}n.extend({hasData:function(a){return M.hasData(a)||L.hasData(a)},data:function(a,b,c){return M.access(a,b,c)},removeData:function(a,b){M.remove(a,b) +},_data:function(a,b,c){return L.access(a,b,c)},_removeData:function(a,b){L.remove(a,b)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=M.get(f),1===f.nodeType&&!L.get(f,"hasDataAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));L.set(f,"hasDataAttrs",!0)}return e}return"object"==typeof a?this.each(function(){M.set(this,a)}):J(this,function(b){var c,d=n.camelCase(a);if(f&&void 0===b){if(c=M.get(f,a),void 0!==c)return c;if(c=M.get(f,d),void 0!==c)return c;if(c=P(f,d,void 0),void 0!==c)return c}else this.each(function(){var c=M.get(this,d);M.set(this,d,b),-1!==a.indexOf("-")&&void 0!==c&&M.set(this,a,b)})},null,b,arguments.length>1,null,!0)},removeData:function(a){return this.each(function(){M.remove(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=L.get(a,b),c&&(!d||n.isArray(c)?d=L.access(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return L.get(a,c)||L.access(a,c,{empty:n.Callbacks("once memory").add(function(){L.remove(a,[b+"queue",c])})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthx",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var U="undefined";k.focusinBubbles="onfocusin"in a;var V=/^key/,W=/^(?:mouse|pointer|contextmenu)|click/,X=/^(?:focusinfocus|focusoutblur)$/,Y=/^([^.]*)(?:\.(.+)|)$/;function Z(){return!0}function $(){return!1}function _(){try{return l.activeElement}catch(a){}}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.get(a);if(r){c.handler&&(f=c,c=f.handler,e=f.selector),c.guid||(c.guid=n.guid++),(i=r.events)||(i=r.events={}),(g=r.handle)||(g=r.handle=function(b){return typeof n!==U&&n.event.triggered!==b.type?n.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(E)||[""],j=b.length;while(j--)h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o&&(l=n.event.special[o]||{},o=(e?l.delegateType:l.bindType)||o,l=n.event.special[o]||{},k=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},f),(m=i[o])||(m=i[o]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,p,g)!==!1||a.addEventListener&&a.addEventListener(o,g,!1)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),n.event.global[o]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.hasData(a)&&L.get(a);if(r&&(i=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=i[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&q!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete i[o])}else for(o in i)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(i)&&(delete r.handle,L.remove(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,m,o,p=[d||l],q=j.call(b,"type")?b.type:b,r=j.call(b,"namespace")?b.namespace.split("."):[];if(g=h=d=d||l,3!==d.nodeType&&8!==d.nodeType&&!X.test(q+n.event.triggered)&&(q.indexOf(".")>=0&&(r=q.split("."),q=r.shift(),r.sort()),k=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=r.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:n.makeArray(c,[b]),o=n.event.special[q]||{},e||!o.trigger||o.trigger.apply(d,c)!==!1)){if(!e&&!o.noBubble&&!n.isWindow(d)){for(i=o.delegateType||q,X.test(i+q)||(g=g.parentNode);g;g=g.parentNode)p.push(g),h=g;h===(d.ownerDocument||l)&&p.push(h.defaultView||h.parentWindow||a)}f=0;while((g=p[f++])&&!b.isPropagationStopped())b.type=f>1?i:o.bindType||q,m=(L.get(g,"events")||{})[b.type]&&L.get(g,"handle"),m&&m.apply(g,c),m=k&&g[k],m&&m.apply&&n.acceptData(g)&&(b.result=m.apply(g,c),b.result===!1&&b.preventDefault());return b.type=q,e||b.isDefaultPrevented()||o._default&&o._default.apply(p.pop(),c)!==!1||!n.acceptData(d)||k&&n.isFunction(d[q])&&!n.isWindow(d)&&(h=d[k],h&&(d[k]=null),n.event.triggered=q,d[q](),n.event.triggered=void 0,h&&(d[k]=h)),b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(L.get(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(g.namespace))&&(a.handleObj=g,a.data=g.data,e=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==e&&(a.result=e)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!==this;i=i.parentNode||this)if(i.disabled!==!0||"click"!==a.type){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>=0:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]*)\/>/gi,bb=/<([\w:]+)/,cb=/<|&#?\w+;/,db=/<(?:script|style|link)/i,eb=/checked\s*(?:[^=]|=\s*.checked.)/i,fb=/^$|\/(?:java|ecma)script/i,gb=/^true\/(.*)/,hb=/^\s*\s*$/g,ib={option:[1,""],thead:[1,"","
      "],col:[2,"","
      "],tr:[2,"","
      "],td:[3,"","
      "],_default:[0,"",""]};ib.optgroup=ib.option,ib.tbody=ib.tfoot=ib.colgroup=ib.caption=ib.thead,ib.th=ib.td;function jb(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function kb(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function lb(a){var b=gb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function mb(a,b){for(var c=0,d=a.length;d>c;c++)L.set(a[c],"globalEval",!b||L.get(b[c],"globalEval"))}function nb(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(L.hasData(a)&&(f=L.access(a),g=L.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;d>c;c++)n.event.add(b,e,j[e][c])}M.hasData(a)&&(h=M.access(a),i=n.extend({},h),M.set(b,i))}}function ob(a,b){var c=a.getElementsByTagName?a.getElementsByTagName(b||"*"):a.querySelectorAll?a.querySelectorAll(b||"*"):[];return void 0===b||b&&n.nodeName(a,b)?n.merge([a],c):c}function pb(a,b){var c=b.nodeName.toLowerCase();"input"===c&&T.test(a.type)?b.checked=a.checked:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}n.extend({clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=n.contains(a.ownerDocument,a);if(!(k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(g=ob(h),f=ob(a),d=0,e=f.length;e>d;d++)pb(f[d],g[d]);if(b)if(c)for(f=f||ob(a),g=g||ob(h),d=0,e=f.length;e>d;d++)nb(f[d],g[d]);else nb(a,h);return g=ob(h,"script"),g.length>0&&mb(g,!i&&ob(a,"script")),h},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,k=b.createDocumentFragment(),l=[],m=0,o=a.length;o>m;m++)if(e=a[m],e||0===e)if("object"===n.type(e))n.merge(l,e.nodeType?[e]:e);else if(cb.test(e)){f=f||k.appendChild(b.createElement("div")),g=(bb.exec(e)||["",""])[1].toLowerCase(),h=ib[g]||ib._default,f.innerHTML=h[1]+e.replace(ab,"<$1>")+h[2],j=h[0];while(j--)f=f.lastChild;n.merge(l,f.childNodes),f=k.firstChild,f.textContent=""}else l.push(b.createTextNode(e));k.textContent="",m=0;while(e=l[m++])if((!d||-1===n.inArray(e,d))&&(i=n.contains(e.ownerDocument,e),f=ob(k.appendChild(e),"script"),i&&mb(f),c)){j=0;while(e=f[j++])fb.test(e.type||"")&&c.push(e)}return k},cleanData:function(a){for(var b,c,d,e,f=n.event.special,g=0;void 0!==(c=a[g]);g++){if(n.acceptData(c)&&(e=c[L.expando],e&&(b=L.cache[e]))){if(b.events)for(d in b.events)f[d]?n.event.remove(c,d):n.removeEvent(c,d,b.handle);L.cache[e]&&delete L.cache[e]}delete M.cache[c[M.expando]]}}}),n.fn.extend({text:function(a){return J(this,function(a){return void 0===a?n.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=a)})},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=jb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=jb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?n.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||n.cleanData(ob(c)),c.parentNode&&(b&&n.contains(c.ownerDocument,c)&&mb(ob(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(n.cleanData(ob(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return J(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!db.test(a)&&!ib[(bb.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(ab,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(ob(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,n.cleanData(ob(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,m=this,o=l-1,p=a[0],q=n.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&eb.test(p))return this.each(function(c){var d=m.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(c=n.buildFragment(a,this[0].ownerDocument,!1,this),d=c.firstChild,1===c.childNodes.length&&(c=d),d)){for(f=n.map(ob(c,"script"),kb),g=f.length;l>j;j++)h=c,j!==o&&(h=n.clone(h,!0,!0),g&&n.merge(f,ob(h,"script"))),b.call(this[j],h,j);if(g)for(i=f[f.length-1].ownerDocument,n.map(f,lb),j=0;g>j;j++)h=f[j],fb.test(h.type||"")&&!L.access(h,"globalEval")&&n.contains(i,h)&&(h.src?n._evalUrl&&n._evalUrl(h.src):n.globalEval(h.textContent.replace(hb,"")))}return this}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=[],e=n(a),g=e.length-1,h=0;g>=h;h++)c=h===g?this:this.clone(!0),n(e[h])[b](c),f.apply(d,c.get());return this.pushStack(d)}});var qb,rb={};function sb(b,c){var d,e=n(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:n.css(e[0],"display");return e.detach(),f}function tb(a){var b=l,c=rb[a];return c||(c=sb(a,b),"none"!==c&&c||(qb=(qb||n("