aboutsummaryrefslogtreecommitdiff
path: root/newspipe
diff options
context:
space:
mode:
authorCédric Bonhomme <cedric@cedricbonhomme.org>2020-02-26 11:27:31 +0100
committerCédric Bonhomme <cedric@cedricbonhomme.org>2020-02-26 11:27:31 +0100
commit62b3afeeedfe054345f86093e2d243e956c1e3c9 (patch)
treebbd58f5c8c07f5d87b1c1cca73fa1d5af6178f48 /newspipe
parentUpdated Python dependencies. (diff)
downloadnewspipe-62b3afeeedfe054345f86093e2d243e956c1e3c9.tar.gz
newspipe-62b3afeeedfe054345f86093e2d243e956c1e3c9.tar.bz2
newspipe-62b3afeeedfe054345f86093e2d243e956c1e3c9.zip
The project is now using Poetry.
Diffstat (limited to 'newspipe')
-rw-r--r--newspipe/bootstrap.py80
-rw-r--r--newspipe/conf.py126
-rw-r--r--newspipe/conf/conf.cfg-sample31
-rw-r--r--newspipe/crawler/default_crawler.py181
-rw-r--r--newspipe/lib/__init__.py0
-rw-r--r--newspipe/lib/article_utils.py186
-rw-r--r--newspipe/lib/data.py210
-rw-r--r--newspipe/lib/feed_utils.py125
-rwxr-xr-xnewspipe/lib/misc_utils.py185
-rw-r--r--newspipe/lib/utils.py89
-rwxr-xr-xnewspipe/manager.py86
-rw-r--r--newspipe/notifications/emails.py132
-rw-r--r--newspipe/notifications/notifications.py53
-rwxr-xr-xnewspipe/runserver.py66
-rw-r--r--newspipe/web/__init__.py8
-rw-r--r--newspipe/web/controllers/__init__.py12
-rw-r--r--newspipe/web/controllers/abstract.py161
-rw-r--r--newspipe/web/controllers/article.py87
-rw-r--r--newspipe/web/controllers/bookmark.py32
-rw-r--r--newspipe/web/controllers/category.py12
-rw-r--r--newspipe/web/controllers/feed.py98
-rw-r--r--newspipe/web/controllers/icon.py23
-rw-r--r--newspipe/web/controllers/tag.py22
-rw-r--r--newspipe/web/controllers/user.py28
-rw-r--r--newspipe/web/decorators.py27
-rw-r--r--newspipe/web/forms.py220
-rw-r--r--newspipe/web/js/actions/MenuActions.js40
-rw-r--r--newspipe/web/js/actions/MiddlePanelActions.js132
-rw-r--r--newspipe/web/js/actions/RightPanelActions.js42
-rw-r--r--newspipe/web/js/app.js18
-rw-r--r--newspipe/web/js/components/MainApp.react.js29
-rw-r--r--newspipe/web/js/components/Menu.react.js305
-rw-r--r--newspipe/web/js/components/MiddlePanel.react.js267
-rw-r--r--newspipe/web/js/components/Navbar.react.js138
-rw-r--r--newspipe/web/js/components/RightPanel.react.js463
-rw-r--r--newspipe/web/js/components/time.react.js15
-rw-r--r--newspipe/web/js/constants/JarrConstants.js13
-rw-r--r--newspipe/web/js/dispatcher/JarrDispatcher.js16
-rw-r--r--newspipe/web/js/dispatcher/__tests__/AppDispatcher-test.js72
-rw-r--r--newspipe/web/js/stores/MenuStore.js135
-rw-r--r--newspipe/web/js/stores/MiddlePanelStore.js126
-rw-r--r--newspipe/web/js/stores/RightPanelStore.js77
-rw-r--r--newspipe/web/js/stores/__tests__/TodoStore-test.js90
-rw-r--r--newspipe/web/lib/__init__.py0
-rw-r--r--newspipe/web/lib/user_utils.py23
-rw-r--r--newspipe/web/lib/view_utils.py26
-rw-r--r--newspipe/web/models/__init__.py87
-rw-r--r--newspipe/web/models/article.py87
-rw-r--r--newspipe/web/models/bookmark.py68
-rw-r--r--newspipe/web/models/category.py29
-rw-r--r--newspipe/web/models/feed.py91
-rw-r--r--newspipe/web/models/icon.py10
-rw-r--r--newspipe/web/models/right_mixin.py63
-rw-r--r--newspipe/web/models/role.py39
-rw-r--r--newspipe/web/models/tag.py36
-rw-r--r--newspipe/web/models/user.py108
l---------newspipe/web/static/css/bootstrap-theme.min.css1
l---------newspipe/web/static/css/bootstrap-theme.min.css.map1
l---------newspipe/web/static/css/bootstrap.min.css1
l---------newspipe/web/static/css/bootstrap.min.css.map1
-rw-r--r--newspipe/web/static/css/customized-bootstrap.css55
-rw-r--r--newspipe/web/static/css/one-page-app.css167
l---------newspipe/web/static/fonts1
-rw-r--r--newspipe/web/static/img/favicon.icobin0 -> 1150 bytes
-rw-r--r--newspipe/web/static/img/newspipe.pngbin0 -> 1547 bytes
-rw-r--r--newspipe/web/static/img/newspipe.svg84
-rw-r--r--newspipe/web/static/img/pinboard.pngbin0 -> 597 bytes
-rwxr-xr-xnewspipe/web/static/img/reddit.pngbin0 -> 525 bytes
-rw-r--r--newspipe/web/static/img/twitter.pngbin0 -> 640 bytes
-rw-r--r--newspipe/web/static/js/articles.js191
-rw-r--r--newspipe/web/static/js/feed.js22
-rw-r--r--newspipe/web/static/js/jquery.js4
-rw-r--r--newspipe/web/templates/about.html23
-rw-r--r--newspipe/web/templates/about_more.html12
-rw-r--r--newspipe/web/templates/admin/create_user.html26
-rw-r--r--newspipe/web/templates/admin/dashboard.html68
-rw-r--r--newspipe/web/templates/article.html35
-rw-r--r--newspipe/web/templates/article_pub.html24
-rw-r--r--newspipe/web/templates/bookmarks.html74
-rw-r--r--newspipe/web/templates/categories.html36
-rw-r--r--newspipe/web/templates/duplicates.html30
-rw-r--r--newspipe/web/templates/edit_bookmark.html84
-rw-r--r--newspipe/web/templates/edit_category.html23
-rw-r--r--newspipe/web/templates/edit_feed.html98
-rw-r--r--newspipe/web/templates/emails/account_activation.txt9
-rw-r--r--newspipe/web/templates/emails/new_password.txt8
-rw-r--r--newspipe/web/templates/errors/404.html12
-rw-r--r--newspipe/web/templates/errors/500.html12
-rw-r--r--newspipe/web/templates/feed.html76
-rw-r--r--newspipe/web/templates/feed_list.html55
-rw-r--r--newspipe/web/templates/feed_list_per_categories.html52
-rw-r--r--newspipe/web/templates/feed_list_simple.html35
-rw-r--r--newspipe/web/templates/feeds.html7
-rw-r--r--newspipe/web/templates/history.html26
-rw-r--r--newspipe/web/templates/home.html9
-rw-r--r--newspipe/web/templates/inactives.html26
-rw-r--r--newspipe/web/templates/layout.html155
-rw-r--r--newspipe/web/templates/login.html28
-rw-r--r--newspipe/web/templates/management.html72
-rw-r--r--newspipe/web/templates/opml.xml13
-rw-r--r--newspipe/web/templates/popular.html27
-rw-r--r--newspipe/web/templates/profile.html65
-rw-r--r--newspipe/web/templates/profile_public.html45
-rw-r--r--newspipe/web/templates/signup.html24
-rw-r--r--newspipe/web/templates/user_stream.html70
-rw-r--r--newspipe/web/translations/babel.cfg3
-rw-r--r--newspipe/web/translations/fr/LC_MESSAGES/messages.mobin0 -> 19672 bytes
-rw-r--r--newspipe/web/translations/fr/LC_MESSAGES/messages.po1403
-rwxr-xr-xnewspipe/web/translations/internationalization.sh4
-rw-r--r--newspipe/web/translations/messages.pot1050
-rw-r--r--newspipe/web/views/__init__.py23
-rw-r--r--newspipe/web/views/admin.py119
-rw-r--r--newspipe/web/views/api/__init__.py0
-rw-r--r--newspipe/web/views/api/v2/__init__.py3
-rw-r--r--newspipe/web/views/api/v2/article.py53
-rw-r--r--newspipe/web/views/api/v2/category.py27
-rw-r--r--newspipe/web/views/api/v2/common.py222
-rw-r--r--newspipe/web/views/api/v2/feed.py47
-rw-r--r--newspipe/web/views/api/v3/__init__.py3
-rw-r--r--newspipe/web/views/api/v3/article.py84
-rw-r--r--newspipe/web/views/api/v3/common.py109
-rw-r--r--newspipe/web/views/api/v3/feed.py58
-rw-r--r--newspipe/web/views/article.py154
-rw-r--r--newspipe/web/views/bookmark.py256
-rw-r--r--newspipe/web/views/category.py86
-rw-r--r--newspipe/web/views/common.py53
-rw-r--r--newspipe/web/views/feed.py306
-rw-r--r--newspipe/web/views/home.py172
-rw-r--r--newspipe/web/views/icon.py15
-rw-r--r--newspipe/web/views/session_mgmt.py113
-rw-r--r--newspipe/web/views/user.py203
-rw-r--r--newspipe/web/views/views.py95
132 files changed, 11473 insertions, 0 deletions
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 <http://www.gnu.org/licenses/>.
+
+__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
--- /dev/null
+++ b/newspipe/lib/__init__.py
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 <http://www.gnu.org/licenses/>.
+
+__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 <http://www.gnu.org/licenses/>.
+
+__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([('<font size=%d>%s</font>' % \
+ (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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+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 <http://www.gnu.org/licenses/>
+
+__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(
+ <MainApp />,
+ 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 (<div>
+ <Grid fluid id="newspipe-container">
+ <Menu />
+ <Col id="middle-panel" mdOffset={3} lgOffset={2}
+ xs={12} sm={4} md={4} lg={4}>
+ <MiddlePanel.MiddlePanelFilter />
+ <MiddlePanel.MiddlePanel />
+ </Col>
+ <RightPanel />
+ </Grid>
+ </div>
+ );
+ },
+});
+
+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 = (<img width="16px" src={this.props.icon_url} />);
+ } else {
+ icon = <Glyphicon glyph="ban-circle" />;
+ }
+ if(this.props.unread){
+ badge_unread = <Badge pullRight>{this.props.unread}</Badge>;
+ }
+ 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 = <span className="title">{this.props.title}</span>;
+ return (<li className={classes} onClick={this.handleClick}>
+ {icon}{title}{badge_unread}
+ </li>
+ );
+ },
+ 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 (<li className={classes} onClick={this.handleClick}>
+ {this.props.children}
+ </li>
+ );
+ },
+ 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 <ul className="hidden" />;
+ }
+ 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 (<FeedItem key={"f" + feed.id} feed_id={feed.id}
+ title={feed.title} unread={feed.unread}
+ error_count={feed.error_count}
+ active={a_type == 'feed_id' && a_id == feed.id}
+ icon_url={feed.icon_url} />
+ );
+ });
+ } else {
+ var feeds = [];
+ }
+ var unread = null;
+ if(this.props.unread) {
+ unread = <Badge pullRight>{this.props.unread}</Badge>;
+ }
+ var ctrl = (<Glyphicon onClick={this.toggleFolding}
+ glyph={this.state.folded?"menu-right":"menu-down"} />
+ );
+
+ return (<ul className="nav nav-sidebar">
+ <Category category_id={this.props.cat_id}
+ active_id={this.props.active_id}
+ active_type={this.props.active_type}>
+ {ctrl}<h4>{this.props.name}</h4>{unread}
+ </Category>
+ {feeds}
+ </ul>
+ );
+ },
+ 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 = (
+ <Button active={this.props.filter == "error"}
+ title="Some of your feeds are in error, click here to list them"
+ onClick={this.setErrorFilter}
+ bsSize="small" bsStyle="warning">
+ <Glyphicon glyph="exclamation-sign" />
+ </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 (<div>
+ <ButtonGroup className="nav nav-sidebar">
+ <Button active={this.props.filter == "all"}
+ title="Display all feeds"
+ onClick={this.setAllFilter} bsSize="small">
+ <Glyphicon glyph="menu-hamburger" />
+ </Button>
+ <Button active={this.props.filter == "unread"}
+ title="Display only feed with unread article"
+ onClick={this.setUnreadFilter}
+ bsSize="small">
+ <Glyphicon glyph="unchecked" />
+ </Button>
+ {error_button}
+ </ButtonGroup>
+ <ButtonGroup className="nav nav-sidebar">
+ <Button onClick={MenuActions.reload}
+ title="Refresh all feeds" bsSize="small">
+ <Glyphicon glyph="refresh" />
+ </Button>
+ </ButtonGroup>
+ <ButtonGroup className="nav nav-sidebar">
+ <Button title={foldBtnTitle} bsSize="small"
+ onClick={this.toggleFold}>
+ <Glyphicon glyph={foldBtnGlyph} />
+ </Button>
+ </ButtonGroup>
+ </div>
+ );
+ },
+ 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 = (
+ <ul className="nav nav-sidebar">
+ <Category category_id={null}
+ active_id={this.state.active_id}
+ active_type={this.state.active_type}>
+ <h4>All</h4>
+ </Category>
+ </ul>
+ );
+ 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(<CategoryGroup key={"c" + cat_id} feeds={feeds}
+ filter={this.state.filter}
+ active_type={this.state.active_type}
+ active_id={this.state.active_id}
+ name={this.state.categories[cat_id].name}
+ cat_id={this.state.categories[cat_id].id}
+ folded={this.state.all_folded}
+ unread={unread} />);
+ }
+
+ return (<Col xsHidden smHidden md={3} lg={2}
+ id="menu" className="sidebar">
+ <MenuFilter filter={this.state.filter}
+ feed_in_error={feed_in_error} />
+ {rmPrntFilt}
+ {categories}
+ </Col>
+ );
+ },
+ 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 = (<img width="16px" src={this.props.icon_url} />);
+ } else {
+ icon = <Glyphicon glyph="ban-circle" />;
+ }
+ var title = (<a href={'/article/redirect/' + this.props.article_id}
+ onClick={this.openRedirectLink} target="_blank"
+ title={this.props.feed_title}>
+ {icon} {this.props.feed_title}
+ </a>);
+ var read = (<Glyphicon glyph={this.state.read?"check":"unchecked"}
+ onClick={this.toogleRead} />);
+ var liked = (<Glyphicon glyph={this.state.liked?"star":"star-empty"}
+ onClick={this.toogleLike} />);
+ icon = <Glyphicon glyph={"new-window"} />;
+ var clsses = "list-group-item";
+ if(this.props.selected) {
+ clsses += " active";
+ }
+ return (<div className={clsses} onClick={this.loadArticle} title={this.props.title}>
+ <h5><strong>{title}</strong></h5>
+ <JarrTime text={this.props.date}
+ stamp={this.props.rel_date} />
+ <div>{read} {liked} {this.props.title}</div>
+ </div>
+ );
+ },
+ 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 (<Row>
+ <form onSubmit={this.launchSearch}>
+ <div className="input-group input-group-sm">
+ <span className="input-group-addon">
+ <span onClick={this.toogleSTitle}>Title</span>
+ <input id="search-title" type="checkbox"
+ onChange={this.toogleSTitle}
+ checked={this.state.search_title}
+ aria-label="Search title" />
+ </span>
+ <span className="input-group-addon">
+ <span onClick={this.toogleSContent}>Content</span>
+ <input id="search-content" type="checkbox"
+ onChange={this.toogleSContent}
+ checked={this.state.search_content}
+ aria-label="Search content" />
+ </span>
+ <input type="text" className="form-control"
+ onChange={this.setQuery}
+ placeholder="Search text" />
+ </div>
+ </form>
+ </Row>
+ );
+ },
+ 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 = <MiddlePanelSearchRow />
+ }
+ return (<div>
+ <Row className="show-grid">
+ <ButtonGroup>
+ <Button active={this.state.filter == "all"}
+ title="Display all articles"
+ onClick={this.setAllFilter} bsSize="small">
+ <Glyphicon glyph="menu-hamburger" />
+ </Button>
+ <Button active={this.state.filter == "unread"}
+ title="Display only unread article"
+ onClick={this.setUnreadFilter}
+ bsSize="small">
+ <Glyphicon glyph="unchecked" />
+ </Button>
+ <Button active={this.state.filter == "liked"}
+ title="Filter only liked articles"
+ onClick={this.setLikedFilter}
+ bsSize="small">
+ <Glyphicon glyph="star" />
+ </Button>
+ </ButtonGroup>
+ <ButtonGroup>
+ <Button onClick={this.toogleSearch}
+ title="Search through displayed articles"
+ bsSize="small">
+ <Glyphicon glyph="search" />
+ </Button>
+ </ButtonGroup>
+ <ButtonGroup>
+ <Button onClick={MiddlePanelActions.markAllAsRead}
+ title="Mark all displayed article as read"
+ bsSize="small">
+ <Glyphicon glyph="trash" />
+ </Button>
+ </ButtonGroup>
+ </Row>
+ {search_row}
+ </div>
+ );
+ },
+ 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 (<Row className="show-grid">
+ <div className="list-group">
+ {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 (<TableLine key={key}
+ title={article.title}
+ icon_url={article.icon_url}
+ read={article.read}
+ liked={article.liked}
+ rel_date={article.rel_date}
+ date={article.date}
+ selected={article.selected}
+ article_id={article.article_id}
+ feed_id={article.feed_id}
+ locales={['en']}
+ category_id={article.category_id}
+ feed_title={article.feed_title} />);})}
+ </div>
+ </Row>
+ );
+ },
+ 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 (<NavItem eventKey={2} href="/fetch">
+ <Glyphicon glyph="import" />Fetch
+ </NavItem>);
+ }
+ },
+ sectionAdmin: function() {
+ if(this.state.is_admin) {
+ return (<MenuItem href="/admin/dashboard">
+ <Glyphicon glyph="dashboard" />Dashboard
+ </MenuItem>);
+ }
+ },
+ 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 = <Input name="url" type="text" placeholder={placeholder} />;
+ } else {
+ heading = 'Add a new category';
+ action = '/category/create';
+ body = <Input name="name" type="text"
+ placeholder="Name" />;
+ }
+ return (<Modal show={this.state.showModal} onHide={this.close}>
+ <form action={action} method="POST">
+ <Modal.Header closeButton>
+ <Modal.Title>{heading}</Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ {body}
+ </Modal.Body>
+ <Modal.Footer>
+ <Button type="submit">Add</Button>
+ </Modal.Footer>
+ </form>
+ </Modal>);
+ },
+ 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 (<Navbar fixedTop inverse id="newspipenav" fluid staticTop={true}>
+ {this.getModal()}
+ <Navbar.Header>
+ <Navbar.Brand>
+ <a href="/">Newspipe</a>
+ </Navbar.Brand>
+ <Navbar.Toggle />
+ </Navbar.Header>
+ <Navbar.Collapse>
+ <Nav pullRight>
+ {this.buttonFetch()}
+ <NavItem className="newspipenavitem"
+ onClick={this.openAddFeed} href="#">
+ <Glyphicon glyph="plus-sign" />Add a new feed
+ </NavItem>
+ <NavItem className="newspipenavitem"
+ onClick={this.openAddCategory} href="#">
+ <Glyphicon glyph="plus-sign" />Add a new category
+ </NavItem>
+ <NavDropdown title="Feed" id="feed-dropdown">
+ <MenuItem href="/feeds/inactives">
+ Inactives
+ </MenuItem>
+ <MenuItem href="/articles/history">
+ History
+ </MenuItem>
+ <MenuItem href="/feeds/">
+ All
+ </MenuItem>
+ </NavDropdown>
+ <NavDropdown title={<Glyphicon glyph='user' />}
+ id="user-dropdown">
+ <MenuItem href="/user/profile">
+ <Glyphicon glyph="user" />Profile
+ </MenuItem>
+ <MenuItem href="/user/management">
+ <Glyphicon glyph="cog" />Your data
+ </MenuItem>
+ <MenuItem href="/about">
+ <Glyphicon glyph="question-sign" />About
+ </MenuItem>
+ {this.sectionAdmin()}
+ <MenuItem href="/logout">
+ <Glyphicon glyph="log-out" />Logout
+ </MenuItem>
+ </NavDropdown>
+ </Nav>
+ </Navbar.Collapse>
+ </Navbar>
+ );
+ },
+ 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 = (<img width="16px" src={this.props.obj.icon_url} />);
+ }
+ var btn_grp = null;
+ if(this.isEditable() || this.isRemovable()) {
+ var edit_button = null;
+ if(this.isEditable()) {
+ edit_button = (<Button onClick={this.onClickEdit}>
+ <Glyphicon glyph="pencil" />
+ </Button>);
+ }
+ var rem_button = null;
+ if(this.isRemovable()) {
+ rem_button = (<Button onClick={this.onClickRemove}>
+ <Glyphicon glyph="remove-sign" />
+ </Button>);
+ }
+ btn_grp = (<ButtonGroup bsSize="small">
+ {edit_button}
+ {rem_button}
+ </ButtonGroup>);
+ }
+ return (<div id="right-panel-heading" className="panel-heading">
+ <Modal show={this.state.showModal} onHide={this.closeModal}>
+ <Modal.Header closeButton>
+ <Modal.Title>Are you sure ?</Modal.Title>
+ </Modal.Header>
+ <Modal.Footer>
+ <Button onClick={this.confirmDelete}>
+ Confirm
+ </Button>
+ </Modal.Footer>
+ </Modal>
+
+ <h4>{icon}{this.getTitle()}</h4>
+ {btn_grp}
+ </div>);
+ },
+ 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(<dt key={key}>{field.title}</dt>);
+ key = this.getKey('dd', field.key);
+ if(field.type == 'string') {
+ items.push(<dd key={key}>{this.props.obj[field.key]}</dd>);
+ } else if(field.type == 'bool') {
+ if(this.props.obj[field.key]) {
+ items.push(<dd key={key}><Glyphicon glyph="check" /></dd>);
+ } else {
+ items.push(<dd key={key}><Glyphicon glyph="unchecked" /></dd>);
+ }
+ } else if (field.type == 'list') {
+ items.push(<dd key={key}>{this.props.obj[field.key].reduce(function(previousTag, currentTag) {
+ return currentTag.concat(", ", previousTag)
+ }, "")}</dd>)
+ } else if (field.type == 'link') {
+ items.push(<dd key={key}>
+ <a href={this.props.obj[field.key]}>
+ {this.props.obj[field.key]}
+ </a>
+ </dd>);
+ }
+ }.bind(this));
+ } else {
+ this.fields.filter(function(field) {
+ return field.type != 'ignore';
+ }).map(function(field) {
+ key = this.getKey('dd', field.key);
+ items.push(<dt key={key}>{field.title}</dt>);
+ key = this.getKey('dt', field.key);
+ var input = null;
+ if(field.type == 'string' || field.type == 'link') {
+ input = (<input type="text" name={field.key}
+ onChange={this.saveField}
+ defaultValue={this.props.obj[field.key]} />);
+ } else if (field.type == 'bool') {
+ input = (<input type="checkbox" name={field.key}
+ onChange={this.saveField}
+ defaultChecked={this.props.obj[field.key]} />);
+ }
+ items.push(<dd key={key}>{input}</dd>);
+ }.bind(this));
+ }
+ return (<dl className="dl-horizontal">{items}</dl>);
+ },
+ getSubmit: function() {
+ return (<dd key={this.getKey('dd', 'submit')}>
+ <button className="btn btn-default" onClick={this.saveObj}>
+ Submit
+ </button>
+ </dd>);
+ },
+ render: function() {
+ return (<div className="panel panel-default">
+ {this.getHeader()}
+ {this.getBody()}
+ </div>
+ );
+ },
+ 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 (<div className="panel-body">
+ {this.getCore()}
+ <div id="article-content" dangerouslySetInnerHTML={
+ {__html: this.props.obj.content}} />
+ </div>);
+ }
+});
+
+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 (<dd key={'d' + i + '-' + this.props.obj.id}
+ className="input-group filter-row">
+ <span className="input-group-btn">
+ <button className="btn btn-default" type="button"
+ data-index={i} onClick={this.removeFilterRow}>
+ <Glyphicon glyph='minus' />
+ </button>
+ </span>
+ <select name="action on" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter['action on']}>
+ <option value="match">match</option>
+ <option value="no match">no match</option>
+ </select>
+ <input name="pattern" type="text" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter.pattern} />
+ <select name="type" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter.type}>
+ <option value='simple match'>simple match</option>
+ <option value='regex'>regex</option>
+ </select>
+ <select name="action" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter.action}>
+ <option value="mark as read">mark as read</option>
+ <option value="mark as favorite">mark as favorite</option>
+ </select>
+ </dd>);
+ },
+ 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 (<dl className="dl-horizontal">
+ <dt>Filters</dt>
+ <dd>
+ <button className="btn btn-default"
+ type="button" onClick={this.addFilterRow}>
+ <Glyphicon glyph='plus' />
+ </button>
+ </dd>
+ {rows}
+ </dl>);
+ }
+ rows = [];
+ rows.push(<dt key={'d-title'}>Filters</dt>);
+ for(var i in this.state.obj.filters) {
+ rows.push(<dd key={'d' + i}>
+ 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}
+ </dd>);
+ }
+ return <dl className="dl-horizontal">{rows}</dl>;
+ },
+ 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(
+ <option value={cat_id}
+ key={MenuStore._datas.categories[cat_id].id}>
+ {MenuStore._datas.categories[cat_id].name}
+ </option>);
+ }
+ content = (<select name="category_id" className="form-control"
+ onChange={this.saveField}
+ defaultValue={this.props.obj.category_id}>
+ {categ_options}
+ </select>);
+ } else {
+ content = MenuStore._datas.categories[this.props.obj.category_id].name;
+ }
+ return (<dl className="dl-horizontal">
+ <dt>Category</dt><dd>{content}</dd>
+ </dl>);
+ },
+ getErrorFields: function() {
+ if(this.props.obj.error_count < MenuStore._datas.error_threshold) {
+ return;
+ }
+ if(this.props.obj.error_count < MenuStore._datas.max_error) {
+ return (<dl className="dl-horizontal">
+ <dt>State</dt>
+ <dd>The download of this feed has encountered some problems. However its error counter will be reinitialized at the next successful retrieving.</dd>
+ <dt>Last error</dt>
+ <dd>{this.props.obj.last_error}</dd>
+ </dl>);
+ }
+ return (<dl className="dl-horizontal">
+ <dt>State</dt>
+ <dd>That feed has encountered too much consecutive errors and won't be retrieved anymore.</dd>
+
+ <dt>Last error</dt>
+ <dd>{this.props.obj.last_error}</dd>
+ <dd>
+ <Button onClick={this.resetErrors}>Reset error count
+ </Button>
+ </dd>
+ </dl>);
+
+ },
+ 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 (<div className="panel-body">
+ <dl className="dl-horizontal">
+ <dt>Created on</dt>
+ <dd><JarrTime stamp={this.props.obj.created_rel}
+ text={this.props.obj.created_date} />
+ </dd>
+ <dt>Last fetched</dt>
+ <dd><JarrTime stamp={this.props.obj.last_rel}
+ text={this.props.obj.last_retrieved} />
+ </dd>
+ </dl>
+ {this.getErrorFields()}
+ {this.getCategorySelect()}
+ {this.getCore()}
+ {this.getFilterRows()}
+ {this.state.edit_mode?this.getSubmit():null}
+ </div>
+ );
+ },
+ 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 (<div className="panel-body">
+ {this.getCore()}
+ {this.state.edit_mode?this.getSubmit():null}
+ </div>);
+ },
+});
+
+var RightPanel = createReactClass({
+ getInitialState: function() {
+ return {category: null, feed: null, article: null, current: null};
+ },
+ getCategoryCrum: function() {
+ return (<li><a onClick={this.selectCategory} href="#">
+ {this.state.category.name}
+ </a></li>);
+ },
+ getFeedCrum: function() {
+ return (<li><a onClick={this.selectFeed} href="#">
+ {this.state.feed.title}
+ </a></li>);
+ },
+ getArticleCrum: function() {
+ return <li>{this.state.article.title}</li>;
+ },
+ 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 = (<li className="rp-crum">
+ <a onClick={this.selectCategory} href="#">
+ {this.state.category.name}
+ </a>
+ </li>);
+ }
+ if(this.state.feed) {
+ brd_feed = (<li className="rp-crum">
+ <a onClick={this.selectFeed} href="#">
+ {this.state.feed.title}
+ </a>
+ </li>);
+ }
+ if(this.state.article) {
+ brd_article = <li className="rp-crum">{this.state.article.title}</li>;
+ }
+ if(brd_category || brd_feed || brd_article) {
+ breadcrum = (<ol className="breadcrumb" id="rp-breadcrum">
+ {brd_category}
+ {brd_feed}
+ {brd_article}
+ </ol>);
+ }
+ if(this.state.current == 'article') {
+ var cntnt = (<Article type='article' obj={this.state.article}
+ key={this.state.article.id} />);
+ } else if(this.state.current == 'feed') {
+ var cntnt = (<Feed type='feed' obj={this.state.feed}
+ key={this.state.feed.id} />);
+ } else if(this.state.current == 'category') {
+ var cntnt = (<Category type='category' obj={this.state.category}
+ key={this.state.category.id} />);
+ }
+
+ return (<Col id="right-panel" xsHidden
+ smOffset={4} mdOffset={7} lgOffset={6}
+ sm={8} md={5} lg={6}>
+ {breadcrum}
+ {cntnt}
+ </Col>
+ );
+ },
+ 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 (<time dateTime={this.props.text} title={this.props.text}>
+ {this.props.stamp}
+ </time>);
+ },
+});
+
+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
--- /dev/null
+++ b/newspipe/web/lib/__init__.py
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 <http://www.gnu.org/licenses/>.
+
+__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 <http://www.gnu.org/licenses/>.
+
+__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 "<Article(id=%d, entry_id=%s, title=%r, " \
+ "date=%r, retrieved_date=%r)>" % (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 <http://www.gnu.org/licenses/>.
+
+__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 '<Bookmark %r>' % (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 <http://www.gnu.org/licenses/>.
+
+__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 '<Feed %r>' % (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 <http://www.gnu.org/licenses/>.
+
+__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 <http://www.gnu.org/licenses/>.
+
+__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 '<User %r>' % (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
--- /dev/null
+++ b/newspipe/web/static/img/favicon.ico
Binary files differ
diff --git a/newspipe/web/static/img/newspipe.png b/newspipe/web/static/img/newspipe.png
new file mode 100644
index 00000000..c3ba5029
--- /dev/null
+++ b/newspipe/web/static/img/newspipe.png
Binary files 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 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.13, written by Peter Selinger 2001-2015
+</metadata>
+<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M4215 3536 c-66 -13 -239 -51 -385 -84 -236 -54 -270 -60 -310 -51
+-70 16 -207 13 -285 -6 -39 -10 -72 -21 -75 -25 -3 -3 -18 -14 -35 -23 -27
+-14 -146 -17 -1170 -25 -1045 -8 -1145 -10 -1195 -26 -147 -47 -250 -133 -318
+-264 -150 -287 -162 -659 -35 -1017 87 -244 211 -388 428 -498 117 -59 195
+-79 336 -84 145 -6 235 8 383 61 51 18 493 235 982 482 812 410 895 450 954
+457 119 14 238 59 335 128 22 16 67 35 100 44 137 37 445 115 466 119 144 26
+243 92 298 199 22 43 71 225 71 265 0 19 -5 22 -38 22 -51 0 -84 36 -107 115
+-22 77 -40 112 -79 153 -73 79 -147 92 -321 58z m151 -31 c32 -9 65 -29 93
+-57 50 -49 103 -154 105 -206 l1 -36 -270 -73 c-148 -39 -275 -72 -280 -72 -6
+-1 -23 28 -39 64 -36 82 -97 151 -188 211 -48 33 -66 49 -57 54 13 7 421 99
+524 118 28 6 53 10 56 11 3 0 28 -6 55 -14z m-810 -126 c161 -50 295 -142 342
+-235 31 -62 36 -94 7 -44 -15 25 -41 60 -58 79 -70 73 -222 160 -347 197 -56
+17 -67 23 -44 23 18 1 63 -9 100 -20z m-102 -5 c127 -33 317 -142 387 -222 37
+-41 79 -127 79 -159 0 -17 -6 -23 -23 -23 -18 0 -28 11 -45 48 -74 162 -272
+308 -479 352 -87 18 -88 19 -34 19 29 1 81 -6 115 -15z m-130 -108 c81 -23
+146 -60 201 -115 53 -53 102 -146 111 -211 5 -40 3 -50 -20 -76 -28 -34 -62
+-47 -316 -114 -480 -128 -1189 -322 -1213 -332 -33 -13 -77 -63 -77 -86 0 -9
+-13 -44 -29 -78 -45 -96 -82 -119 -282 -179 -13 -4 -12 8 8 86 30 121 38 250
+24 379 -41 350 -169 584 -377 686 l-78 39 579 7 c319 4 672 9 785 11 376 7
+619 1 684 -17z m261 -36 c82 -47 141 -105 174 -172 38 -76 39 -88 4 -88 -25 0
+-34 11 -82 101 -51 94 -81 129 -141 171 -41 28 -11 20 45 -12z m-2401 -49 c33
+-12 78 -32 100 -45 62 -37 141 -124 174 -191 31 -63 85 -223 97 -287 7 -36 7
+-37 -32 -44 -21 -4 -189 -35 -373 -68 l-335 -61 -8 -50 c-9 -60 0 -178 19
+-254 59 -225 201 -331 444 -331 136 0 258 34 488 136 78 35 145 64 147 64 8 0
+-34 -100 -60 -144 -61 -104 -40 -94 -460 -207 -431 -115 -402 -113 -542 -43
+-128 64 -237 182 -304 330 -104 229 -144 585 -91 798 74 295 264 434 577 422
+67 -3 120 -11 159 -25z m3506 -24 c0 -8 -9 -49 -20 -92 -32 -127 -90 -200
+-195 -246 -42 -19 -507 -149 -532 -149 -8 0 -3 19 13 54 14 30 33 98 44 152
+12 62 25 104 38 116 12 13 107 42 283 88 145 38 278 74 294 79 45 14 75 13 75
+-2z m-973 -339 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z m-107
+-51 c0 -42 -65 -160 -113 -204 -48 -44 -359 -204 -952 -490 -235 -114 -312
+-146 -408 -172 -65 -17 -120 -31 -122 -31 -2 0 9 28 24 63 42 93 59 151 80
+277 15 94 23 118 43 135 15 13 233 83 623 199 330 98 647 194 705 211 129 40
+120 40 120 12z m-2022 -293 c19 -134 11 -267 -20 -335 -33 -73 -80 -124 -142
+-157 -46 -24 -62 -27 -152 -27 -96 0 -105 2 -170 35 -116 59 -172 156 -181
+317 -7 103 -1 128 34 141 16 6 143 32 283 57 140 25 269 49 285 54 17 5 35 8
+40 7 6 -2 16 -43 23 -92z"/>
+<path d="M4193 3430 c-29 -4 -101 -25 -160 -46 l-108 -39 72 -3 c64 -3 89 2
+205 42 90 31 123 45 101 46 -17 0 -37 2 -45 4 -7 2 -37 0 -65 -4z"/>
+<path d="M4265 3296 c-230 -66 -229 -65 -209 -87 47 -52 163 -43 328 27 l88
+37 -38 23 c-47 29 -67 29 -169 0z"/>
+<path d="M2860 3197 l-95 -10 50 -23 c90 -42 143 -41 465 10 8 1 -9 10 -39 19
+-62 19 -222 21 -381 4z"/>
+<path d="M2140 3185 c-151 -7 -299 -14 -328 -14 l-52 -1 43 -52 c23 -29 52
+-69 64 -88 32 -52 78 -198 104 -327 12 -62 27 -113 33 -113 6 0 202 37 435 82
+l424 83 -6 75 c-12 172 -92 292 -227 339 -79 27 -179 30 -490 16z"/>
+<path d="M3335 3063 c-16 -2 -108 -15 -204 -28 -95 -14 -175 -25 -178 -25 -5
+0 2 -39 18 -101 l9 -36 207 39 c319 58 313 57 313 78 0 10 -16 32 -35 49 -36
+31 -52 34 -130 24z"/>
+<path d="M812 3060 c-55 -12 -126 -47 -154 -77 l-21 -23 371 0 c205 0 372 4
+372 8 0 11 -36 49 -65 68 -19 12 -55 17 -147 19 -68 1 -166 5 -218 8 -52 3
+-114 2 -138 -3z"/>
+<path d="M1155 2803 c-263 -22 -572 -56 -579 -63 -5 -5 -11 -43 -14 -84 -4
+-69 -3 -74 14 -71 11 3 219 34 464 71 245 36 447 68 449 70 8 8 -35 59 -60 71
+-23 12 -170 15 -274 6z"/>
+<path d="M647 2406 c-37 -7 -70 -15 -73 -19 -8 -8 15 -145 26 -152 9 -6 122
+15 133 24 8 8 -3 162 -12 160 -3 -1 -36 -7 -74 -13z"/>
+<path d="M694 2067 l-61 -22 14 -41 c8 -23 29 -59 47 -80 l33 -38 64 28 c35
+16 65 30 67 31 2 2 -8 19 -22 40 -14 20 -31 52 -38 71 -14 39 -21 40 -104 11z"/>
+<path d="M946 1792 c-27 -15 -51 -32 -53 -37 -3 -11 44 -43 92 -62 53 -20 126
+-16 182 12 52 26 80 51 70 61 -10 10 -182 54 -212 54 -16 0 -52 -13 -79 -28z"/>
+<path d="M4510 3024 c-25 -7 -117 -32 -204 -55 -170 -43 -196 -55 -214 -95
+-11 -23 -17 -84 -8 -84 11 0 247 51 281 61 95 27 154 75 179 147 8 23 14 42
+13 41 -1 0 -22 -7 -47 -15z"/>
+<path d="M3355 2631 c-60 -22 -223 -80 -360 -129 -138 -49 -341 -120 -452
+-156 -173 -58 -208 -73 -246 -106 -25 -22 -52 -57 -60 -78 -15 -38 -33 -152
+-24 -152 6 0 555 255 967 448 241 113 257 122 288 166 43 58 35 59 -113 7z"/>
+<path d="M1293 2368 l-201 -40 15 -51 c8 -29 16 -53 17 -54 4 -4 151 25 219
+43 109 28 153 59 172 122 8 27 25 29 -222 -20z"/>
+<path d="M1376 2149 c-76 -26 -141 -50 -144 -53 -10 -11 55 -29 118 -33 56 -4
+70 -1 100 19 33 22 75 88 68 107 -2 5 -65 -13 -142 -40z"/>
+</g>
+</svg>
diff --git a/newspipe/web/static/img/pinboard.png b/newspipe/web/static/img/pinboard.png
new file mode 100644
index 00000000..6dddc10b
--- /dev/null
+++ b/newspipe/web/static/img/pinboard.png
Binary files differ
diff --git a/newspipe/web/static/img/reddit.png b/newspipe/web/static/img/reddit.png
new file mode 100755
index 00000000..2d615f2a
--- /dev/null
+++ b/newspipe/web/static/img/reddit.png
Binary files differ
diff --git a/newspipe/web/static/img/twitter.png b/newspipe/web/static/img/twitter.png
new file mode 100644
index 00000000..fc11c4ce
--- /dev/null
+++ b/newspipe/web/static/img/twitter.png
Binary files 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 <http://www.gnu.org/licenses/>.
+ */
+
+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(
+ '<div class="form-group">'
+ + ' <input value="-" type="button" class="form-control del-feed-filter-row" />'
+ + ' <select name="type" class="form-control">'
+ + ' <option value="simple match" selected>simple match</option>'
+ + ' <option value="regex">regex</option>'
+ + ' </select>'
+ + ' <input type="text" class="form-control" name="pattern" />'
+ + ' <select name="action_on" class="form-control">'
+ + ' <option value="match" selected>match</option>'
+ + ' <option value="no match">no match</option>'
+ + ' </select>'
+ + ' <select name="action" class="form-control">'
+ + ' <option value="mark as read" selected>mark as read</option>'
+ + ' <option value="mark as favorite">mark as favorite</option>'
+ + ' </select>'
+ + '</div>');
+});
+$('.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="<div class='a'></div><div class='a i'></div>",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="<select msallowclip=''><option selected=''></option></select>",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;++d<b;)a.push(d);return a})}},d.pseudos.nth=d.pseudos.eq;for(b in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})d.pseudos[b]=lb(b);for(b in{submit:!0,reset:!0})d.pseudos[b]=mb(b);function pb(){}pb.prototype=d.filters=d.pseudos,d.setFilters=new pb,g=fb.tokenize=function(a,b){var c,e,f,g,h,i,j,k=z[a+" "];if(k)return b?0:k.slice(0);h=a,i=[],j=d.preFilter;while(h){(!c||(e=S.exec(h)))&&(e&&(h=h.slice(e[0].length)||h),i.push(f=[])),c=!1,(e=T.exec(h))&&(c=e.shift(),f.push({value:c,type:e[0].replace(R," ")}),h=h.slice(c.length));for(g in d.filter)!(e=X[g].exec(h))||j[g]&&!(e=j[g](e))||(c=e.shift(),f.push({value:c,type:g,matches:e}),h=h.slice(c.length));if(!c)break}return b?h.length:h?fb.error(a):z(a,i).slice(0)};function qb(a){for(var b=0,c=a.length,d="";c>b;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 href='#'></a>","#"===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="<input/>",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.length<c?n.queue(this[0],a):void 0===b?this:this.each(function(){var c=n.queue(this,a,b);n._queueHooks(this,a),"fx"===a&&"inprogress"!==c[0]&&n.dequeue(this,a)})},dequeue:function(a){return this.each(function(){n.dequeue(this,a)})},clearQueue:function(a){return this.queue(a||"fx",[])},promise:function(a,b){var c,d=1,e=n.Deferred(),f=this,g=this.length,h=function(){--d||e.resolveWith(f,[f])};"string"!=typeof a&&(b=a,a=void 0),a=a||"fx";while(g--)c=L.get(f[g],a+"queueHooks"),c&&c.empty&&(d++,c.empty.add(h));return h(),e.promise(b)}});var Q=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,R=["Top","Right","Bottom","Left"],S=function(a,b){return a=b||a,"none"===n.css(a,"display")||!n.contains(a.ownerDocument,a)},T=/^(?:checkbox|radio)$/i;!function(){var a=l.createDocumentFragment(),b=a.appendChild(l.createElement("div")),c=l.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="<textarea>x</textarea>",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<b.length&&g.push({elem:this,handlers:b.slice(h)}),g},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(a,b){return null==a.which&&(a.which=null!=b.charCode?b.charCode:b.keyCode),a}},mouseHooks:{props:"button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(a,b){var c,d,e,f=b.button;return null==a.pageX&&null!=b.clientX&&(c=a.target.ownerDocument||l,d=c.documentElement,e=c.body,a.pageX=b.clientX+(d&&d.scrollLeft||e&&e.scrollLeft||0)-(d&&d.clientLeft||e&&e.clientLeft||0),a.pageY=b.clientY+(d&&d.scrollTop||e&&e.scrollTop||0)-(d&&d.clientTop||e&&e.clientTop||0)),a.which||void 0===f||(a.which=1&f?1:2&f?3:4&f?2:0),a}},fix:function(a){if(a[n.expando])return a;var b,c,d,e=a.type,f=a,g=this.fixHooks[e];g||(this.fixHooks[e]=g=W.test(e)?this.mouseHooks:V.test(e)?this.keyHooks:{}),d=g.props?this.props.concat(g.props):this.props,a=new n.Event(f),b=d.length;while(b--)c=d[b],a[c]=f[c];return a.target||(a.target=l),3===a.target.nodeType&&(a.target=a.target.parentNode),g.filter?g.filter(a,f):a},special:{load:{noBubble:!0},focus:{trigger:function(){return this!==_()&&this.focus?(this.focus(),!1):void 0},delegateType:"focusin"},blur:{trigger:function(){return this===_()&&this.blur?(this.blur(),!1):void 0},delegateType:"focusout"},click:{trigger:function(){return"checkbox"===this.type&&this.click&&n.nodeName(this,"input")?(this.click(),!1):void 0},_default:function(a){return n.nodeName(a.target,"a")}},beforeunload:{postDispatch:function(a){void 0!==a.result&&a.originalEvent&&(a.originalEvent.returnValue=a.result)}}},simulate:function(a,b,c,d){var e=n.extend(new n.Event,c,{type:a,isSimulated:!0,originalEvent:{}});d?n.event.trigger(e,null,b):n.event.dispatch.call(b,e),e.isDefaultPrevented()&&c.preventDefault()}},n.removeEvent=function(a,b,c){a.removeEventListener&&a.removeEventListener(b,c,!1)},n.Event=function(a,b){return this instanceof n.Event?(a&&a.type?(this.originalEvent=a,this.type=a.type,this.isDefaultPrevented=a.defaultPrevented||void 0===a.defaultPrevented&&a.returnValue===!1?Z:$):this.type=a,b&&n.extend(this,b),this.timeStamp=a&&a.timeStamp||n.now(),void(this[n.expando]=!0)):new n.Event(a,b)},n.Event.prototype={isDefaultPrevented:$,isPropagationStopped:$,isImmediatePropagationStopped:$,preventDefault:function(){var a=this.originalEvent;this.isDefaultPrevented=Z,a&&a.preventDefault&&a.preventDefault()},stopPropagation:function(){var a=this.originalEvent;this.isPropagationStopped=Z,a&&a.stopPropagation&&a.stopPropagation()},stopImmediatePropagation:function(){var a=this.originalEvent;this.isImmediatePropagationStopped=Z,a&&a.stopImmediatePropagation&&a.stopImmediatePropagation(),this.stopPropagation()}},n.each({mouseenter:"mouseover",mouseleave:"mouseout",pointerenter:"pointerover",pointerleave:"pointerout"},function(a,b){n.event.special[a]={delegateType:b,bindType:b,handle:function(a){var c,d=this,e=a.relatedTarget,f=a.handleObj;return(!e||e!==d&&!n.contains(d,e))&&(a.type=f.origType,c=f.handler.apply(this,arguments),a.type=b),c}}}),k.focusinBubbles||n.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){n.event.simulate(b,a.target,n.event.fix(a),!0)};n.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=L.access(d,b);e||d.addEventListener(a,c,!0),L.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=L.access(d,b)-1;e?L.access(d,b,e):(d.removeEventListener(a,c,!0),L.remove(d,b))}}}),n.fn.extend({on:function(a,b,c,d,e){var f,g;if("object"==typeof a){"string"!=typeof b&&(c=c||b,b=void 0);for(g in a)this.on(g,b,c,a[g],e);return this}if(null==c&&null==d?(d=b,c=b=void 0):null==d&&("string"==typeof b?(d=c,c=void 0):(d=c,c=b,b=void 0)),d===!1)d=$;else if(!d)return this;return 1===e&&(f=d,d=function(a){return n().off(a),f.apply(this,arguments)},d.guid=f.guid||(f.guid=n.guid++)),this.each(function(){n.event.add(this,a,d,c,b)})},one:function(a,b,c,d){return this.on(a,b,c,d,1)},off:function(a,b,c){var d,e;if(a&&a.preventDefault&&a.handleObj)return d=a.handleObj,n(a.delegateTarget).off(d.namespace?d.origType+"."+d.namespace:d.origType,d.selector,d.handler),this;if("object"==typeof a){for(e in a)this.off(e,b,a[e]);return this}return(b===!1||"function"==typeof b)&&(c=b,b=void 0),c===!1&&(c=$),this.each(function(){n.event.remove(this,a,c,b)})},trigger:function(a,b){return this.each(function(){n.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];return c?n.event.trigger(a,b,c,!0):void 0}});var ab=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/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*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,ib={option:[1,"<select multiple='multiple'>","</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_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></$2>")+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></$2>");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("<iframe frameborder='0' width='0' height='0'/>")).appendTo(b.documentElement),b=qb[0].contentDocument,b.write(),b.close(),c=sb(a,b),qb.detach()),rb[a]=c),c}var ub=/^margin/,vb=new RegExp("^("+Q+")(?!px)[a-z%]+$","i"),wb=function(a){return a.ownerDocument.defaultView.getComputedStyle(a,null)};function xb(a,b,c){var d,e,f,g,h=a.style;return c=c||wb(a),c&&(g=c.getPropertyValue(b)||c[b]),c&&(""!==g||n.contains(a.ownerDocument,a)||(g=n.style(a,b)),vb.test(g)&&ub.test(b)&&(d=h.width,e=h.minWidth,f=h.maxWidth,h.minWidth=h.maxWidth=h.width=g,g=c.width,h.width=d,h.minWidth=e,h.maxWidth=f)),void 0!==g?g+"":g}function yb(a,b){return{get:function(){return a()?void delete this.get:(this.get=b).apply(this,arguments)}}}!function(){var b,c,d=l.documentElement,e=l.createElement("div"),f=l.createElement("div");if(f.style){f.style.backgroundClip="content-box",f.cloneNode(!0).style.backgroundClip="",k.clearCloneStyle="content-box"===f.style.backgroundClip,e.style.cssText="border:0;width:0;height:0;top:0;left:-9999px;margin-top:1px;position:absolute",e.appendChild(f);function g(){f.style.cssText="-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;display:block;margin-top:1%;top:1%;border:1px;padding:1px;width:4px;position:absolute",f.innerHTML="",d.appendChild(e);var g=a.getComputedStyle(f,null);b="1%"!==g.top,c="4px"===g.width,d.removeChild(e)}a.getComputedStyle&&n.extend(k,{pixelPosition:function(){return g(),b},boxSizingReliable:function(){return null==c&&g(),c},reliableMarginRight:function(){var b,c=f.appendChild(l.createElement("div"));return c.style.cssText=f.style.cssText="-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;display:block;margin:0;border:0;padding:0",c.style.marginRight=c.style.width="0",f.style.width="1px",d.appendChild(e),b=!parseFloat(a.getComputedStyle(c,null).marginRight),d.removeChild(e),b}})}}(),n.swap=function(a,b,c,d){var e,f,g={};for(f in b)g[f]=a.style[f],a.style[f]=b[f];e=c.apply(a,d||[]);for(f in b)a.style[f]=g[f];return e};var zb=/^(none|table(?!-c[ea]).+)/,Ab=new RegExp("^("+Q+")(.*)$","i"),Bb=new RegExp("^([+-])=("+Q+")","i"),Cb={position:"absolute",visibility:"hidden",display:"block"},Db={letterSpacing:"0",fontWeight:"400"},Eb=["Webkit","O","Moz","ms"];function Fb(a,b){if(b in a)return b;var c=b[0].toUpperCase()+b.slice(1),d=b,e=Eb.length;while(e--)if(b=Eb[e]+c,b in a)return b;return d}function Gb(a,b,c){var d=Ab.exec(b);return d?Math.max(0,d[1]-(c||0))+(d[2]||"px"):b}function Hb(a,b,c,d,e){for(var f=c===(d?"border":"content")?4:"width"===b?1:0,g=0;4>f;f+=2)"margin"===c&&(g+=n.css(a,c+R[f],!0,e)),d?("content"===c&&(g-=n.css(a,"padding"+R[f],!0,e)),"margin"!==c&&(g-=n.css(a,"border"+R[f]+"Width",!0,e))):(g+=n.css(a,"padding"+R[f],!0,e),"padding"!==c&&(g+=n.css(a,"border"+R[f]+"Width",!0,e)));return g}function Ib(a,b,c){var d=!0,e="width"===b?a.offsetWidth:a.offsetHeight,f=wb(a),g="border-box"===n.css(a,"boxSizing",!1,f);if(0>=e||null==e){if(e=xb(a,b,f),(0>e||null==e)&&(e=a.style[b]),vb.test(e))return e;d=g&&(k.boxSizingReliable()||e===a.style[b]),e=parseFloat(e)||0}return e+Hb(a,b,c||(g?"border":"content"),d,f)+"px"}function Jb(a,b){for(var c,d,e,f=[],g=0,h=a.length;h>g;g++)d=a[g],d.style&&(f[g]=L.get(d,"olddisplay"),c=d.style.display,b?(f[g]||"none"!==c||(d.style.display=""),""===d.style.display&&S(d)&&(f[g]=L.access(d,"olddisplay",tb(d.nodeName)))):(e=S(d),"none"===c&&e||L.set(d,"olddisplay",e?c:n.css(d,"display"))));for(g=0;h>g;g++)d=a[g],d.style&&(b&&"none"!==d.style.display&&""!==d.style.display||(d.style.display=b?f[g]||"":"none"));return a}n.extend({cssHooks:{opacity:{get:function(a,b){if(b){var c=xb(a,"opacity");return""===c?"1":c}}}},cssNumber:{columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":"cssFloat"},style:function(a,b,c,d){if(a&&3!==a.nodeType&&8!==a.nodeType&&a.style){var e,f,g,h=n.camelCase(b),i=a.style;return b=n.cssProps[h]||(n.cssProps[h]=Fb(i,h)),g=n.cssHooks[b]||n.cssHooks[h],void 0===c?g&&"get"in g&&void 0!==(e=g.get(a,!1,d))?e:i[b]:(f=typeof c,"string"===f&&(e=Bb.exec(c))&&(c=(e[1]+1)*e[2]+parseFloat(n.css(a,b)),f="number"),null!=c&&c===c&&("number"!==f||n.cssNumber[h]||(c+="px"),k.clearCloneStyle||""!==c||0!==b.indexOf("background")||(i[b]="inherit"),g&&"set"in g&&void 0===(c=g.set(a,c,d))||(i[b]=c)),void 0)}},css:function(a,b,c,d){var e,f,g,h=n.camelCase(b);return b=n.cssProps[h]||(n.cssProps[h]=Fb(a.style,h)),g=n.cssHooks[b]||n.cssHooks[h],g&&"get"in g&&(e=g.get(a,!0,c)),void 0===e&&(e=xb(a,b,d)),"normal"===e&&b in Db&&(e=Db[b]),""===c||c?(f=parseFloat(e),c===!0||n.isNumeric(f)?f||0:e):e}}),n.each(["height","width"],function(a,b){n.cssHooks[b]={get:function(a,c,d){return c?zb.test(n.css(a,"display"))&&0===a.offsetWidth?n.swap(a,Cb,function(){return Ib(a,b,d)}):Ib(a,b,d):void 0},set:function(a,c,d){var e=d&&wb(a);return Gb(a,c,d?Hb(a,b,d,"border-box"===n.css(a,"boxSizing",!1,e),e):0)}}}),n.cssHooks.marginRight=yb(k.reliableMarginRight,function(a,b){return b?n.swap(a,{display:"inline-block"},xb,[a,"marginRight"]):void 0}),n.each({margin:"",padding:"",border:"Width"},function(a,b){n.cssHooks[a+b]={expand:function(c){for(var d=0,e={},f="string"==typeof c?c.split(" "):[c];4>d;d++)e[a+R[d]+b]=f[d]||f[d-2]||f[0];return e}},ub.test(a)||(n.cssHooks[a+b].set=Gb)}),n.fn.extend({css:function(a,b){return J(this,function(a,b,c){var d,e,f={},g=0;if(n.isArray(b)){for(d=wb(a),e=b.length;e>g;g++)f[b[g]]=n.css(a,b[g],!1,d);return f}return void 0!==c?n.style(a,b,c):n.css(a,b)},a,b,arguments.length>1)},show:function(){return Jb(this,!0)},hide:function(){return Jb(this)},toggle:function(a){return"boolean"==typeof a?a?this.show():this.hide():this.each(function(){S(this)?n(this).show():n(this).hide()})}});function Kb(a,b,c,d,e){return new Kb.prototype.init(a,b,c,d,e)}n.Tween=Kb,Kb.prototype={constructor:Kb,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||"swing",this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(n.cssNumber[c]?"":"px")},cur:function(){var a=Kb.propHooks[this.prop];return a&&a.get?a.get(this):Kb.propHooks._default.get(this)},run:function(a){var b,c=Kb.propHooks[this.prop];return this.pos=b=this.options.duration?n.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):Kb.propHooks._default.set(this),this}},Kb.prototype.init.prototype=Kb.prototype,Kb.propHooks={_default:{get:function(a){var b;return null==a.elem[a.prop]||a.elem.style&&null!=a.elem.style[a.prop]?(b=n.css(a.elem,a.prop,""),b&&"auto"!==b?b:0):a.elem[a.prop]},set:function(a){n.fx.step[a.prop]?n.fx.step[a.prop](a):a.elem.style&&(null!=a.elem.style[n.cssProps[a.prop]]||n.cssHooks[a.prop])?n.style(a.elem,a.prop,a.now+a.unit):a.elem[a.prop]=a.now}}},Kb.propHooks.scrollTop=Kb.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},n.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2}},n.fx=Kb.prototype.init,n.fx.step={};var Lb,Mb,Nb=/^(?:toggle|show|hide)$/,Ob=new RegExp("^(?:([+-])=|)("+Q+")([a-z%]*)$","i"),Pb=/queueHooks$/,Qb=[Vb],Rb={"*":[function(a,b){var c=this.createTween(a,b),d=c.cur(),e=Ob.exec(b),f=e&&e[3]||(n.cssNumber[a]?"":"px"),g=(n.cssNumber[a]||"px"!==f&&+d)&&Ob.exec(n.css(c.elem,a)),h=1,i=20;if(g&&g[3]!==f){f=f||g[3],e=e||[],g=+d||1;do h=h||".5",g/=h,n.style(c.elem,a,g+f);while(h!==(h=c.cur()/d)&&1!==h&&--i)}return e&&(g=c.start=+g||+d||0,c.unit=f,c.end=e[1]?g+(e[1]+1)*e[2]:+e[2]),c}]};function Sb(){return setTimeout(function(){Lb=void 0}),Lb=n.now()}function Tb(a,b){var c,d=0,e={height:a};for(b=b?1:0;4>d;d+=2-b)c=R[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function Ub(a,b,c){for(var d,e=(Rb[b]||[]).concat(Rb["*"]),f=0,g=e.length;g>f;f++)if(d=e[f].call(c,b,a))return d}function Vb(a,b,c){var d,e,f,g,h,i,j,k,l=this,m={},o=a.style,p=a.nodeType&&S(a),q=L.get(a,"fxshow");c.queue||(h=n._queueHooks(a,"fx"),null==h.unqueued&&(h.unqueued=0,i=h.empty.fire,h.empty.fire=function(){h.unqueued||i()}),h.unqueued++,l.always(function(){l.always(function(){h.unqueued--,n.queue(a,"fx").length||h.empty.fire()})})),1===a.nodeType&&("height"in b||"width"in b)&&(c.overflow=[o.overflow,o.overflowX,o.overflowY],j=n.css(a,"display"),k="none"===j?L.get(a,"olddisplay")||tb(a.nodeName):j,"inline"===k&&"none"===n.css(a,"float")&&(o.display="inline-block")),c.overflow&&(o.overflow="hidden",l.always(function(){o.overflow=c.overflow[0],o.overflowX=c.overflow[1],o.overflowY=c.overflow[2]}));for(d in b)if(e=b[d],Nb.exec(e)){if(delete b[d],f=f||"toggle"===e,e===(p?"hide":"show")){if("show"!==e||!q||void 0===q[d])continue;p=!0}m[d]=q&&q[d]||n.style(a,d)}else j=void 0;if(n.isEmptyObject(m))"inline"===("none"===j?tb(a.nodeName):j)&&(o.display=j);else{q?"hidden"in q&&(p=q.hidden):q=L.access(a,"fxshow",{}),f&&(q.hidden=!p),p?n(a).show():l.done(function(){n(a).hide()}),l.done(function(){var b;L.remove(a,"fxshow");for(b in m)n.style(a,b,m[b])});for(d in m)g=Ub(p?q[d]:0,d,l),d in q||(q[d]=g.start,p&&(g.end=g.start,g.start="width"===d||"height"===d?1:0))}}function Wb(a,b){var c,d,e,f,g;for(c in a)if(d=n.camelCase(c),e=b[d],f=a[c],n.isArray(f)&&(e=f[1],f=a[c]=f[0]),c!==d&&(a[d]=f,delete a[c]),g=n.cssHooks[d],g&&"expand"in g){f=g.expand(f),delete a[d];for(c in f)c in a||(a[c]=f[c],b[c]=e)}else b[d]=e}function Xb(a,b,c){var d,e,f=0,g=Qb.length,h=n.Deferred().always(function(){delete i.elem}),i=function(){if(e)return!1;for(var b=Lb||Sb(),c=Math.max(0,j.startTime+j.duration-b),d=c/j.duration||0,f=1-d,g=0,i=j.tweens.length;i>g;g++)j.tweens[g].run(f);return h.notifyWith(a,[j,f,c]),1>f&&i?c:(h.resolveWith(a,[j]),!1)},j=h.promise({elem:a,props:n.extend({},b),opts:n.extend(!0,{specialEasing:{}},c),originalProperties:b,originalOptions:c,startTime:Lb||Sb(),duration:c.duration,tweens:[],createTween:function(b,c){var d=n.Tween(a,j.opts,b,c,j.opts.specialEasing[b]||j.opts.easing);return j.tweens.push(d),d},stop:function(b){var c=0,d=b?j.tweens.length:0;if(e)return this;for(e=!0;d>c;c++)j.tweens[c].run(1);return b?h.resolveWith(a,[j,b]):h.rejectWith(a,[j,b]),this}}),k=j.props;for(Wb(k,j.opts.specialEasing);g>f;f++)if(d=Qb[f].call(j,a,k,j.opts))return d;return n.map(k,Ub,j),n.isFunction(j.opts.start)&&j.opts.start.call(a,j),n.fx.timer(n.extend(i,{elem:a,anim:j,queue:j.opts.queue})),j.progress(j.opts.progress).done(j.opts.done,j.opts.complete).fail(j.opts.fail).always(j.opts.always)}n.Animation=n.extend(Xb,{tweener:function(a,b){n.isFunction(a)?(b=a,a=["*"]):a=a.split(" ");for(var c,d=0,e=a.length;e>d;d++)c=a[d],Rb[c]=Rb[c]||[],Rb[c].unshift(b)},prefilter:function(a,b){b?Qb.unshift(a):Qb.push(a)}}),n.speed=function(a,b,c){var d=a&&"object"==typeof a?n.extend({},a):{complete:c||!c&&b||n.isFunction(a)&&a,duration:a,easing:c&&b||b&&!n.isFunction(b)&&b};return d.duration=n.fx.off?0:"number"==typeof d.duration?d.duration:d.duration in n.fx.speeds?n.fx.speeds[d.duration]:n.fx.speeds._default,(null==d.queue||d.queue===!0)&&(d.queue="fx"),d.old=d.complete,d.complete=function(){n.isFunction(d.old)&&d.old.call(this),d.queue&&n.dequeue(this,d.queue)},d},n.fn.extend({fadeTo:function(a,b,c,d){return this.filter(S).css("opacity",0).show().end().animate({opacity:b},a,c,d)},animate:function(a,b,c,d){var e=n.isEmptyObject(a),f=n.speed(b,c,d),g=function(){var b=Xb(this,n.extend({},a),f);(e||L.get(this,"finish"))&&b.stop(!0)};return g.finish=g,e||f.queue===!1?this.each(g):this.queue(f.queue,g)},stop:function(a,b,c){var d=function(a){var b=a.stop;delete a.stop,b(c)};return"string"!=typeof a&&(c=b,b=a,a=void 0),b&&a!==!1&&this.queue(a||"fx",[]),this.each(function(){var b=!0,e=null!=a&&a+"queueHooks",f=n.timers,g=L.get(this);if(e)g[e]&&g[e].stop&&d(g[e]);else for(e in g)g[e]&&g[e].stop&&Pb.test(e)&&d(g[e]);for(e=f.length;e--;)f[e].elem!==this||null!=a&&f[e].queue!==a||(f[e].anim.stop(c),b=!1,f.splice(e,1));(b||!c)&&n.dequeue(this,a)})},finish:function(a){return a!==!1&&(a=a||"fx"),this.each(function(){var b,c=L.get(this),d=c[a+"queue"],e=c[a+"queueHooks"],f=n.timers,g=d?d.length:0;for(c.finish=!0,n.queue(this,a,[]),e&&e.stop&&e.stop.call(this,!0),b=f.length;b--;)f[b].elem===this&&f[b].queue===a&&(f[b].anim.stop(!0),f.splice(b,1));for(b=0;g>b;b++)d[b]&&d[b].finish&&d[b].finish.call(this);delete c.finish})}}),n.each(["toggle","show","hide"],function(a,b){var c=n.fn[b];n.fn[b]=function(a,d,e){return null==a||"boolean"==typeof a?c.apply(this,arguments):this.animate(Tb(b,!0),a,d,e)}}),n.each({slideDown:Tb("show"),slideUp:Tb("hide"),slideToggle:Tb("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(a,b){n.fn[a]=function(a,c,d){return this.animate(b,a,c,d)}}),n.timers=[],n.fx.tick=function(){var a,b=0,c=n.timers;for(Lb=n.now();b<c.length;b++)a=c[b],a()||c[b]!==a||c.splice(b--,1);c.length||n.fx.stop(),Lb=void 0},n.fx.timer=function(a){n.timers.push(a),a()?n.fx.start():n.timers.pop()},n.fx.interval=13,n.fx.start=function(){Mb||(Mb=setInterval(n.fx.tick,n.fx.interval))},n.fx.stop=function(){clearInterval(Mb),Mb=null},n.fx.speeds={slow:600,fast:200,_default:400},n.fn.delay=function(a,b){return a=n.fx?n.fx.speeds[a]||a:a,b=b||"fx",this.queue(b,function(b,c){var d=setTimeout(b,a);c.stop=function(){clearTimeout(d)}})},function(){var a=l.createElement("input"),b=l.createElement("select"),c=b.appendChild(l.createElement("option"));a.type="checkbox",k.checkOn=""!==a.value,k.optSelected=c.selected,b.disabled=!0,k.optDisabled=!c.disabled,a=l.createElement("input"),a.value="t",a.type="radio",k.radioValue="t"===a.value}();var Yb,Zb,$b=n.expr.attrHandle;n.fn.extend({attr:function(a,b){return J(this,n.attr,a,b,arguments.length>1)},removeAttr:function(a){return this.each(function(){n.removeAttr(this,a)})}}),n.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(a&&3!==f&&8!==f&&2!==f)return typeof a.getAttribute===U?n.prop(a,b,c):(1===f&&n.isXMLDoc(a)||(b=b.toLowerCase(),d=n.attrHooks[b]||(n.expr.match.bool.test(b)?Zb:Yb)),void 0===c?d&&"get"in d&&null!==(e=d.get(a,b))?e:(e=n.find.attr(a,b),null==e?void 0:e):null!==c?d&&"set"in d&&void 0!==(e=d.set(a,c,b))?e:(a.setAttribute(b,c+""),c):void n.removeAttr(a,b))
+},removeAttr:function(a,b){var c,d,e=0,f=b&&b.match(E);if(f&&1===a.nodeType)while(c=f[e++])d=n.propFix[c]||c,n.expr.match.bool.test(c)&&(a[d]=!1),a.removeAttribute(c)},attrHooks:{type:{set:function(a,b){if(!k.radioValue&&"radio"===b&&n.nodeName(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}}}),Zb={set:function(a,b,c){return b===!1?n.removeAttr(a,c):a.setAttribute(c,c),c}},n.each(n.expr.match.bool.source.match(/\w+/g),function(a,b){var c=$b[b]||n.find.attr;$b[b]=function(a,b,d){var e,f;return d||(f=$b[b],$b[b]=e,e=null!=c(a,b,d)?b.toLowerCase():null,$b[b]=f),e}});var _b=/^(?:input|select|textarea|button)$/i;n.fn.extend({prop:function(a,b){return J(this,n.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[n.propFix[a]||a]})}}),n.extend({propFix:{"for":"htmlFor","class":"className"},prop:function(a,b,c){var d,e,f,g=a.nodeType;if(a&&3!==g&&8!==g&&2!==g)return f=1!==g||!n.isXMLDoc(a),f&&(b=n.propFix[b]||b,e=n.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){return a.hasAttribute("tabindex")||_b.test(a.nodeName)||a.href?a.tabIndex:-1}}}}),k.optSelected||(n.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null}}),n.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){n.propFix[this.toLowerCase()]=this});var ac=/[\t\r\n\f]/g;n.fn.extend({addClass:function(a){var b,c,d,e,f,g,h="string"==typeof a&&a,i=0,j=this.length;if(n.isFunction(a))return this.each(function(b){n(this).addClass(a.call(this,b,this.className))});if(h)for(b=(a||"").match(E)||[];j>i;i++)if(c=this[i],d=1===c.nodeType&&(c.className?(" "+c.className+" ").replace(ac," "):" ")){f=0;while(e=b[f++])d.indexOf(" "+e+" ")<0&&(d+=e+" ");g=n.trim(d),c.className!==g&&(c.className=g)}return this},removeClass:function(a){var b,c,d,e,f,g,h=0===arguments.length||"string"==typeof a&&a,i=0,j=this.length;if(n.isFunction(a))return this.each(function(b){n(this).removeClass(a.call(this,b,this.className))});if(h)for(b=(a||"").match(E)||[];j>i;i++)if(c=this[i],d=1===c.nodeType&&(c.className?(" "+c.className+" ").replace(ac," "):"")){f=0;while(e=b[f++])while(d.indexOf(" "+e+" ")>=0)d=d.replace(" "+e+" "," ");g=a?n.trim(d):"",c.className!==g&&(c.className=g)}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):this.each(n.isFunction(a)?function(c){n(this).toggleClass(a.call(this,c,this.className,b),b)}:function(){if("string"===c){var b,d=0,e=n(this),f=a.match(E)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else(c===U||"boolean"===c)&&(this.className&&L.set(this,"__className__",this.className),this.className=this.className||a===!1?"":L.get(this,"__className__")||"")})},hasClass:function(a){for(var b=" "+a+" ",c=0,d=this.length;d>c;c++)if(1===this[c].nodeType&&(" "+this[c].className+" ").replace(ac," ").indexOf(b)>=0)return!0;return!1}});var bc=/\r/g;n.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=n.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,n(this).val()):a,null==e?e="":"number"==typeof e?e+="":n.isArray(e)&&(e=n.map(e,function(a){return null==a?"":a+""})),b=n.valHooks[this.type]||n.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=n.valHooks[e.type]||n.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(bc,""):null==c?"":c)}}}),n.extend({valHooks:{option:{get:function(a){var b=n.find.attr(a,"value");return null!=b?b:n.trim(n.text(a))}},select:{get:function(a){for(var b,c,d=a.options,e=a.selectedIndex,f="select-one"===a.type||0>e,g=f?null:[],h=f?e+1:d.length,i=0>e?h:f?e:0;h>i;i++)if(c=d[i],!(!c.selected&&i!==e||(k.optDisabled?c.disabled:null!==c.getAttribute("disabled"))||c.parentNode.disabled&&n.nodeName(c.parentNode,"optgroup"))){if(b=n(c).val(),f)return b;g.push(b)}return g},set:function(a,b){var c,d,e=a.options,f=n.makeArray(b),g=e.length;while(g--)d=e[g],(d.selected=n.inArray(d.value,f)>=0)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),n.each(["radio","checkbox"],function(){n.valHooks[this]={set:function(a,b){return n.isArray(b)?a.checked=n.inArray(n(a).val(),b)>=0:void 0}},k.checkOn||(n.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})}),n.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error contextmenu".split(" "),function(a,b){n.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),n.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)},bind:function(a,b,c){return this.on(a,null,b,c)},unbind:function(a,b){return this.off(a,null,b)},delegate:function(a,b,c,d){return this.on(b,a,c,d)},undelegate:function(a,b,c){return 1===arguments.length?this.off(a,"**"):this.off(b,a||"**",c)}});var cc=n.now(),dc=/\?/;n.parseJSON=function(a){return JSON.parse(a+"")},n.parseXML=function(a){var b,c;if(!a||"string"!=typeof a)return null;try{c=new DOMParser,b=c.parseFromString(a,"text/xml")}catch(d){b=void 0}return(!b||b.getElementsByTagName("parsererror").length)&&n.error("Invalid XML: "+a),b};var ec,fc,gc=/#.*$/,hc=/([?&])_=[^&]*/,ic=/^(.*?):[ \t]*([^\r\n]*)$/gm,jc=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,kc=/^(?:GET|HEAD)$/,lc=/^\/\//,mc=/^([\w.+-]+:)(?:\/\/(?:[^\/?#]*@|)([^\/?#:]*)(?::(\d+)|)|)/,nc={},oc={},pc="*/".concat("*");try{fc=location.href}catch(qc){fc=l.createElement("a"),fc.href="",fc=fc.href}ec=mc.exec(fc.toLowerCase())||[];function rc(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(E)||[];if(n.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function sc(a,b,c,d){var e={},f=a===oc;function g(h){var i;return e[h]=!0,n.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function tc(a,b){var c,d,e=n.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&n.extend(!0,a,d),a}function uc(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}return f?(f!==i[0]&&i.unshift(f),c[f]):void 0}function vc(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}n.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:fc,type:"GET",isLocal:jc.test(ec[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":pc,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":n.parseJSON,"text xml":n.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?tc(tc(a,n.ajaxSettings),b):tc(n.ajaxSettings,a)},ajaxPrefilter:rc(nc),ajaxTransport:rc(oc),ajax:function(a,b){"object"==typeof a&&(b=a,a=void 0),b=b||{};var c,d,e,f,g,h,i,j,k=n.ajaxSetup({},b),l=k.context||k,m=k.context&&(l.nodeType||l.jquery)?n(l):n.event,o=n.Deferred(),p=n.Callbacks("once memory"),q=k.statusCode||{},r={},s={},t=0,u="canceled",v={readyState:0,getResponseHeader:function(a){var b;if(2===t){if(!f){f={};while(b=ic.exec(e))f[b[1].toLowerCase()]=b[2]}b=f[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return 2===t?e:null},setRequestHeader:function(a,b){var c=a.toLowerCase();return t||(a=s[c]=s[c]||a,r[a]=b),this},overrideMimeType:function(a){return t||(k.mimeType=a),this},statusCode:function(a){var b;if(a)if(2>t)for(b in a)q[b]=[q[b],a[b]];else v.always(a[v.status]);return this},abort:function(a){var b=a||u;return c&&c.abort(b),x(0,b),this}};if(o.promise(v).complete=p.add,v.success=v.done,v.error=v.fail,k.url=((a||k.url||fc)+"").replace(gc,"").replace(lc,ec[1]+"//"),k.type=b.method||b.type||k.method||k.type,k.dataTypes=n.trim(k.dataType||"*").toLowerCase().match(E)||[""],null==k.crossDomain&&(h=mc.exec(k.url.toLowerCase()),k.crossDomain=!(!h||h[1]===ec[1]&&h[2]===ec[2]&&(h[3]||("http:"===h[1]?"80":"443"))===(ec[3]||("http:"===ec[1]?"80":"443")))),k.data&&k.processData&&"string"!=typeof k.data&&(k.data=n.param(k.data,k.traditional)),sc(nc,k,b,v),2===t)return v;i=k.global,i&&0===n.active++&&n.event.trigger("ajaxStart"),k.type=k.type.toUpperCase(),k.hasContent=!kc.test(k.type),d=k.url,k.hasContent||(k.data&&(d=k.url+=(dc.test(d)?"&":"?")+k.data,delete k.data),k.cache===!1&&(k.url=hc.test(d)?d.replace(hc,"$1_="+cc++):d+(dc.test(d)?"&":"?")+"_="+cc++)),k.ifModified&&(n.lastModified[d]&&v.setRequestHeader("If-Modified-Since",n.lastModified[d]),n.etag[d]&&v.setRequestHeader("If-None-Match",n.etag[d])),(k.data&&k.hasContent&&k.contentType!==!1||b.contentType)&&v.setRequestHeader("Content-Type",k.contentType),v.setRequestHeader("Accept",k.dataTypes[0]&&k.accepts[k.dataTypes[0]]?k.accepts[k.dataTypes[0]]+("*"!==k.dataTypes[0]?", "+pc+"; q=0.01":""):k.accepts["*"]);for(j in k.headers)v.setRequestHeader(j,k.headers[j]);if(k.beforeSend&&(k.beforeSend.call(l,v,k)===!1||2===t))return v.abort();u="abort";for(j in{success:1,error:1,complete:1})v[j](k[j]);if(c=sc(oc,k,b,v)){v.readyState=1,i&&m.trigger("ajaxSend",[v,k]),k.async&&k.timeout>0&&(g=setTimeout(function(){v.abort("timeout")},k.timeout));try{t=1,c.send(r,x)}catch(w){if(!(2>t))throw w;x(-1,w)}}else x(-1,"No Transport");function x(a,b,f,h){var j,r,s,u,w,x=b;2!==t&&(t=2,g&&clearTimeout(g),c=void 0,e=h||"",v.readyState=a>0?4:0,j=a>=200&&300>a||304===a,f&&(u=uc(k,v,f)),u=vc(k,u,v,j),j?(k.ifModified&&(w=v.getResponseHeader("Last-Modified"),w&&(n.lastModified[d]=w),w=v.getResponseHeader("etag"),w&&(n.etag[d]=w)),204===a||"HEAD"===k.type?x="nocontent":304===a?x="notmodified":(x=u.state,r=u.data,s=u.error,j=!s)):(s=x,(a||!x)&&(x="error",0>a&&(a=0))),v.status=a,v.statusText=(b||x)+"",j?o.resolveWith(l,[r,x,v]):o.rejectWith(l,[v,x,s]),v.statusCode(q),q=void 0,i&&m.trigger(j?"ajaxSuccess":"ajaxError",[v,k,j?r:s]),p.fireWith(l,[v,x]),i&&(m.trigger("ajaxComplete",[v,k]),--n.active||n.event.trigger("ajaxStop")))}return v},getJSON:function(a,b,c){return n.get(a,b,c,"json")},getScript:function(a,b){return n.get(a,void 0,b,"script")}}),n.each(["get","post"],function(a,b){n[b]=function(a,c,d,e){return n.isFunction(c)&&(e=e||d,d=c,c=void 0),n.ajax({url:a,type:b,dataType:e,data:c,success:d})}}),n.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(a,b){n.fn[b]=function(a){return this.on(b,a)}}),n._evalUrl=function(a){return n.ajax({url:a,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0})},n.fn.extend({wrapAll:function(a){var b;return n.isFunction(a)?this.each(function(b){n(this).wrapAll(a.call(this,b))}):(this[0]&&(b=n(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this)},wrapInner:function(a){return this.each(n.isFunction(a)?function(b){n(this).wrapInner(a.call(this,b))}:function(){var b=n(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=n.isFunction(a);return this.each(function(c){n(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(){return this.parent().each(function(){n.nodeName(this,"body")||n(this).replaceWith(this.childNodes)}).end()}}),n.expr.filters.hidden=function(a){return a.offsetWidth<=0&&a.offsetHeight<=0},n.expr.filters.visible=function(a){return!n.expr.filters.hidden(a)};var wc=/%20/g,xc=/\[\]$/,yc=/\r?\n/g,zc=/^(?:submit|button|image|reset|file)$/i,Ac=/^(?:input|select|textarea|keygen)/i;function Bc(a,b,c,d){var e;if(n.isArray(b))n.each(b,function(b,e){c||xc.test(a)?d(a,e):Bc(a+"["+("object"==typeof e?b:"")+"]",e,c,d)});else if(c||"object"!==n.type(b))d(a,b);else for(e in b)Bc(a+"["+e+"]",b[e],c,d)}n.param=function(a,b){var c,d=[],e=function(a,b){b=n.isFunction(b)?b():null==b?"":b,d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(b)};if(void 0===b&&(b=n.ajaxSettings&&n.ajaxSettings.traditional),n.isArray(a)||a.jquery&&!n.isPlainObject(a))n.each(a,function(){e(this.name,this.value)});else for(c in a)Bc(c,a[c],b,e);return d.join("&").replace(wc,"+")},n.fn.extend({serialize:function(){return n.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=n.prop(this,"elements");return a?n.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!n(this).is(":disabled")&&Ac.test(this.nodeName)&&!zc.test(a)&&(this.checked||!T.test(a))}).map(function(a,b){var c=n(this).val();return null==c?null:n.isArray(c)?n.map(c,function(a){return{name:b.name,value:a.replace(yc,"\r\n")}}):{name:b.name,value:c.replace(yc,"\r\n")}}).get()}}),n.ajaxSettings.xhr=function(){try{return new XMLHttpRequest}catch(a){}};var Cc=0,Dc={},Ec={0:200,1223:204},Fc=n.ajaxSettings.xhr();a.ActiveXObject&&n(a).on("unload",function(){for(var a in Dc)Dc[a]()}),k.cors=!!Fc&&"withCredentials"in Fc,k.ajax=Fc=!!Fc,n.ajaxTransport(function(a){var b;return k.cors||Fc&&!a.crossDomain?{send:function(c,d){var e,f=a.xhr(),g=++Cc;if(f.open(a.type,a.url,a.async,a.username,a.password),a.xhrFields)for(e in a.xhrFields)f[e]=a.xhrFields[e];a.mimeType&&f.overrideMimeType&&f.overrideMimeType(a.mimeType),a.crossDomain||c["X-Requested-With"]||(c["X-Requested-With"]="XMLHttpRequest");for(e in c)f.setRequestHeader(e,c[e]);b=function(a){return function(){b&&(delete Dc[g],b=f.onload=f.onerror=null,"abort"===a?f.abort():"error"===a?d(f.status,f.statusText):d(Ec[f.status]||f.status,f.statusText,"string"==typeof f.responseText?{text:f.responseText}:void 0,f.getAllResponseHeaders()))}},f.onload=b(),f.onerror=b("error"),b=Dc[g]=b("abort");try{f.send(a.hasContent&&a.data||null)}catch(h){if(b)throw h}},abort:function(){b&&b()}}:void 0}),n.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(a){return n.globalEval(a),a}}}),n.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),n.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(d,e){b=n("<script>").prop({async:!0,charset:a.scriptCharset,src:a.url}).on("load error",c=function(a){b.remove(),c=null,a&&e("error"===a.type?404:200,a.type)}),l.head.appendChild(b[0])},abort:function(){c&&c()}}}});var Gc=[],Hc=/(=)\?(?=&|$)|\?\?/;n.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var a=Gc.pop()||n.expando+"_"+cc++;return this[a]=!0,a}}),n.ajaxPrefilter("json jsonp",function(b,c,d){var e,f,g,h=b.jsonp!==!1&&(Hc.test(b.url)?"url":"string"==typeof b.data&&!(b.contentType||"").indexOf("application/x-www-form-urlencoded")&&Hc.test(b.data)&&"data");return h||"jsonp"===b.dataTypes[0]?(e=b.jsonpCallback=n.isFunction(b.jsonpCallback)?b.jsonpCallback():b.jsonpCallback,h?b[h]=b[h].replace(Hc,"$1"+e):b.jsonp!==!1&&(b.url+=(dc.test(b.url)?"&":"?")+b.jsonp+"="+e),b.converters["script json"]=function(){return g||n.error(e+" was not called"),g[0]},b.dataTypes[0]="json",f=a[e],a[e]=function(){g=arguments},d.always(function(){a[e]=f,b[e]&&(b.jsonpCallback=c.jsonpCallback,Gc.push(e)),g&&n.isFunction(f)&&f(g[0]),g=f=void 0}),"script"):void 0}),n.parseHTML=function(a,b,c){if(!a||"string"!=typeof a)return null;"boolean"==typeof b&&(c=b,b=!1),b=b||l;var d=v.exec(a),e=!c&&[];return d?[b.createElement(d[1])]:(d=n.buildFragment([a],b,e),e&&e.length&&n(e).remove(),n.merge([],d.childNodes))};var Ic=n.fn.load;n.fn.load=function(a,b,c){if("string"!=typeof a&&Ic)return Ic.apply(this,arguments);var d,e,f,g=this,h=a.indexOf(" ");return h>=0&&(d=n.trim(a.slice(h)),a=a.slice(0,h)),n.isFunction(b)?(c=b,b=void 0):b&&"object"==typeof b&&(e="POST"),g.length>0&&n.ajax({url:a,type:e,dataType:"html",data:b}).done(function(a){f=arguments,g.html(d?n("<div>").append(n.parseHTML(a)).find(d):a)}).complete(c&&function(a,b){g.each(c,f||[a.responseText,b,a])}),this},n.expr.filters.animated=function(a){return n.grep(n.timers,function(b){return a===b.elem}).length};var Jc=a.document.documentElement;function Kc(a){return n.isWindow(a)?a:9===a.nodeType&&a.defaultView}n.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=n.css(a,"position"),l=n(a),m={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=n.css(a,"top"),i=n.css(a,"left"),j=("absolute"===k||"fixed"===k)&&(f+i).indexOf("auto")>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),n.isFunction(b)&&(b=b.call(a,c,h)),null!=b.top&&(m.top=b.top-h.top+g),null!=b.left&&(m.left=b.left-h.left+e),"using"in b?b.using.call(a,m):l.css(m)}},n.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){n.offset.setOffset(this,a,b)});var b,c,d=this[0],e={top:0,left:0},f=d&&d.ownerDocument;if(f)return b=f.documentElement,n.contains(b,d)?(typeof d.getBoundingClientRect!==U&&(e=d.getBoundingClientRect()),c=Kc(f),{top:e.top+c.pageYOffset-b.clientTop,left:e.left+c.pageXOffset-b.clientLeft}):e},position:function(){if(this[0]){var a,b,c=this[0],d={top:0,left:0};return"fixed"===n.css(c,"position")?b=c.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),n.nodeName(a[0],"html")||(d=a.offset()),d.top+=n.css(a[0],"borderTopWidth",!0),d.left+=n.css(a[0],"borderLeftWidth",!0)),{top:b.top-d.top-n.css(c,"marginTop",!0),left:b.left-d.left-n.css(c,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent||Jc;while(a&&!n.nodeName(a,"html")&&"static"===n.css(a,"position"))a=a.offsetParent;return a||Jc})}}),n.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(b,c){var d="pageYOffset"===c;n.fn[b]=function(e){return J(this,function(b,e,f){var g=Kc(b);return void 0===f?g?g[c]:b[e]:void(g?g.scrollTo(d?a.pageXOffset:f,d?f:a.pageYOffset):b[e]=f)},b,e,arguments.length,null)}}),n.each(["top","left"],function(a,b){n.cssHooks[b]=yb(k.pixelPosition,function(a,c){return c?(c=xb(a,b),vb.test(c)?n(a).position()[b]+"px":c):void 0})}),n.each({Height:"height",Width:"width"},function(a,b){n.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){n.fn[d]=function(d,e){var f=arguments.length&&(c||"boolean"!=typeof d),g=c||(d===!0||e===!0?"margin":"border");return J(this,function(b,c,d){var e;return n.isWindow(b)?b.document.documentElement["client"+a]:9===b.nodeType?(e=b.documentElement,Math.max(b.body["scroll"+a],e["scroll"+a],b.body["offset"+a],e["offset"+a],e["client"+a])):void 0===d?n.css(b,c,g):n.style(b,c,d,g)},b,f?d:void 0,f,null)}})}),n.fn.size=function(){return this.length},n.fn.andSelf=n.fn.addBack,"function"==typeof define&&define.amd&&define("jquery",[],function(){return n});var Lc=a.jQuery,Mc=a.$;return n.noConflict=function(b){return a.$===n&&(a.$=Mc),b&&a.jQuery===n&&(a.jQuery=Lc),n},typeof b===U&&(a.jQuery=a.$=n),n});
diff --git a/newspipe/web/templates/about.html b/newspipe/web/templates/about.html
new file mode 100644
index 00000000..4cfafb79
--- /dev/null
+++ b/newspipe/web/templates/about.html
@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h1>{{ _('About') }}</h1>
+ <p>
+ {{ _('Newspipe is a news aggregator platform.') }}
+ {{ _('You can easily <a href="https://newspipe.readthedocs.io/en/latest/deployment.html">install Newspipe on your server</a>.') }}
+ <p>{{ _('This software is under AGPLv3 license. You are welcome to copy, modify or
+ redistribute the <a href="https://git.sr.ht/~cedric/Newspipe">source code</a>
+ according to the <a href="https://www.gnu.org/licenses/agpl-3.0.html">Affero GPL</a> license.') }}</p>
+ <p>{{ _('Found a bug? Report it <a href="https://todo.sr.ht/~cedric/newspipe">here</a>.') }}</p>
+ <p><a href="{{ url_for('about_more') }}">{{ _('More information') }}</a> {{ _('about this instance.') }}</p>
+ </div>
+ <div class="well">
+ <h1>{{ _('Help') }}</h1>
+ <p>{{ _('The documentation of the API is <a href="https://newspipe.readthedocs.io/en/latest/web-services-v3.html">here</a>.') }}</p>
+ <p>{{ _('Contact')}}: <a href="mailto:{{ contact }}">{{ contact }}</a></p>
+ <p>{{ _('You can subscribe to new feeds with a bookmarklet. Drag the following button to your browser bookmarks.') }}</p>
+ {{ _('<a class="btn btn-default" href="%(bookmarklet)s" rel="bookmark">Subscribe to this feed using Newspipe</a>', bookmarklet='javascript:window.location="%s?url="+encodeURIComponent(document.location)' % url_for('feed.bookmarklet', _external=True)) }}
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/about_more.html b/newspipe/web/templates/about_more.html
new file mode 100644
index 00000000..d4353a15
--- /dev/null
+++ b/newspipe/web/templates/about_more.html
@@ -0,0 +1,12 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <ul class="list-group">
+ <li class="list-group-item">{{ _('Newspipe version') }}: <a href="https://git.sr.ht/~cedric/Newspipe/refs/{{newspipe_version}}">{{newspipe_version}}</a></li>
+ <li class="list-group-item">{{ _('Running on Heroku') }}: {{on_heroku}}</li>
+ <li class="list-group-item">{{ _('Registration') }}: {{registration}}</li>
+ <li class="list-group-item">{{ _('Python version') }}: {{python_version}}</li>
+ <li class="list-group-item">{{ _('Number of users') }}: {{nb_users}}</li>
+ </ul>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/admin/create_user.html b/newspipe/web/templates/admin/create_user.html
new file mode 100644
index 00000000..40aad468
--- /dev/null
+++ b/newspipe/web/templates/admin/create_user.html
@@ -0,0 +1,26 @@
+{% extends "layout.html" %}
+{% block head%}
+{{super()}}
+{% endblock %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h2>{{ message | safe }}</h2>
+ <form action="" method="post" name="saveprofileform" id="profileform">
+ {{ form.hidden_tag() }}
+
+ {{ form.nickname.label }}
+ {{ form.nickname(class_="form-control") }} {% for error in form.nickname.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.password.label }}
+ {{ form.password(class_="form-control") }} {% for error in form.password.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.automatic_crawling.label }}
+ {{ form.automatic_crawling(class_="form-control") }} {% for error in form.automatic_crawling.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ <br />
+ {{ form.submit(class_="btn btn-default") }}
+ </form>
+ </div>
+</div>
+{% endblock %}
diff --git a/newspipe/web/templates/admin/dashboard.html b/newspipe/web/templates/admin/dashboard.html
new file mode 100644
index 00000000..350a2e47
--- /dev/null
+++ b/newspipe/web/templates/admin/dashboard.html
@@ -0,0 +1,68 @@
+{% extends "layout.html" %}
+{% block head%}
+{{super()}}
+{% endblock %}
+{% block content %}
+<div class="container">
+<h1>{{ _('Registered users') }}</h1>
+<table id="table-users" class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>{{ _('Nickname') }}</th>
+ <th>{{ _('Member since') }}</th>
+ <th>{{ _('Last seen') }}</th>
+ <th>{{ _('Actions') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for user in users %}
+ <tr {% if not user.is_active %}class="warning"{% endif %}>
+ <td>{{ loop.index }}</td>
+ <td>
+ {% if user.is_public_profile %}
+ <a href="{{ url_for("user.profile_public", nickname=user.nickname) }}">{{ user.nickname }}</a>
+ {% else %}
+ {{ user.nickname }}
+ {% endif %}
+ {% if user.id == current_user.id %}&nbsp;(It's you!){% endif %}
+ </td>
+ <td class="date">{{ user.date_created | datetime }}</td>
+ <td class="date">{{ user.last_seen | datetime }}</td>
+ <td>
+ <a href="{{ url_for("admin.user_form", user_id=user.id) }}"><i class="glyphicon glyphicon-edit" title="{{ _('Edit this user') }}"></i></a>
+ {% if user.id != current_user.id %}
+ <a href="{{ url_for("admin.toggle_user", user_id=user.id) }}">
+ {% if user.is_active %}
+ <i class="glyphicon glyphicon-ban-circle" title="{{ _("Disable this account") }}"></i>
+ {% else %}
+ <i class="glyphicon glyphicon-ok-circle" title="{{ _("Enable this account") }}"></i>
+ {% endif %}
+ </a>
+ <a href="{{ url_for("admin.delete_user", user_id=user.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this user') }}" onclick="return confirm('{{ _('You are going to delete this account.') }}');"></i></a>
+ {% endif %}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+</table>
+<a href="{{ url_for("admin.user_form") }}" class="btn btn-default">{{ _('Add a new user') }}</a>
+</div>
+<script>
+$(document).ready(function() {
+ $('#table-users').DataTable( {
+ responsive: true,
+ columnDefs: [
+ {
+ targets: [0, 4],
+ "searchable": false
+ },
+ {
+ targets: [3],
+ "orderSequence": ["desc"]
+ }
+ ]
+ });
+});
+</script>
+{% endblock %}
diff --git a/newspipe/web/templates/article.html b/newspipe/web/templates/article.html
new file mode 100644
index 00000000..a95d86d9
--- /dev/null
+++ b/newspipe/web/templates/article.html
@@ -0,0 +1,35 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container" data-article="{{ article.id }}">
+ <div class="well">
+ <h2><a href="{{ article.link }}" target="_blank">{{ article.title|safe }}</a></h2>
+ <h3>{{ _('from') }} <a href="/feed/{{ article.source.id }}">{{ article.source.title }}</a></h3>
+ <a href="{{ url_for("article.delete", article_id=article.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a>
+ {% if article.like %}
+ <a href="#"><i class="glyphicon glyphicon-star like" title="{{ _('One of your favorites') }}"></i></a>
+ {% else %}
+ <a href="#"><i class="glyphicon glyphicon-star-empty like" title="{{ _('Click if you like this article') }}"></i></a>
+ {% endif %}
+ {% if article.readed %}
+ <a href="#"><i class="glyphicon glyphicon-unchecked readed" title="{{ _('Mark this article as unread') }}"></i></a>
+ {% else %}
+ <a href="#"><i class="glyphicon glyphicon-check readed" title="{{ _('Mark this article as read') }}"></i></a>
+ {% endif %}
+ <h6>{{ article.date | datetime }}</h6>
+ </div>
+ <div class="well">
+ {{ article.content | safe }}
+ </div>
+ <div class="well">
+ <a href="https://api.pinboard.in/v1/posts/add?url={{ article.link }}&description={{ article.title }}" rel="noreferrer" target="_blank">
+ <img src="{{ url_for('static', filename='img/pinboard.png') }}" title="{{ _('Share on') }} Pinboard" />
+ </a>
+ <a href="https://reddit.com/submit?url={{ article.link }}&title={{ article.title }}" rel="noreferrer" target="_blank">
+ <img src="{{ url_for('static', filename='img/reddit.png') }}" title="{{ _('Share on') }} reddit" />
+ </a>
+ <a href="https://twitter.com/intent/tweet?url={{ article.link }}&text={{ article.title }}" rel="noreferrer" target="_blank">
+ <img src="{{ url_for('static', filename='img/twitter.png') }}" title="{{ _('Share on') }} twitter" >
+ </a>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/article_pub.html b/newspipe/web/templates/article_pub.html
new file mode 100644
index 00000000..e810d18f
--- /dev/null
+++ b/newspipe/web/templates/article_pub.html
@@ -0,0 +1,24 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container" data-article="{{ article.id }}">
+ <div class="well">
+ <h2><a href="{{ article.link }}" target="_blank">{{ article.title|safe }}</a></h2>
+ <h3>{{ _('from') }} <a href="{{ url_for('feed.feed_pub', feed_id=article.source.id) }}">{{ article.source.title }}</a></h3>
+ <h6>{{ article.date | datetime }}</h6>
+ </div>
+ <div class="well">
+ {{ article.content | safe }}
+ </div>
+ <div class="well">
+ <a href="https://api.pinboard.in/v1/posts/add?url={{ article.link }}&description={{ article.title }}" rel="noreferrer" target="_blank">
+ <img src="{{ url_for('static', filename='img/pinboard.png') }}" title="{{ _('Share on') }} Pinboard" />
+ </a>
+ <a href="https://reddit.com/submit?url={{ article.link }}&title={{ article.title }}" rel="noreferrer" target="_blank">
+ <img src="{{ url_for('static', filename='img/reddit.png') }}" title="{{ _('Share on') }} reddit" />
+ </a>
+ <a href="https://twitter.com/intent/tweet?url={{ article.link }}&text={{ article.title }}" rel="noreferrer" target="_blank">
+ <img src="{{ url_for('static', filename='img/twitter.png') }}" title="{{ _('Share on') }} twitter" >
+ </a>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/bookmarks.html b/newspipe/web/templates/bookmarks.html
new file mode 100644
index 00000000..a3ec4580
--- /dev/null
+++ b/newspipe/web/templates/bookmarks.html
@@ -0,0 +1,74 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="row">
+ <div class="col-md-6">
+ {{ pagination.info }}
+ </div>
+ <div class="col-md-6 text-right">
+ {% if current_user.is_authenticated %}
+ <a class="text-muted" href="{{ url_for('bookmarks.list_') }}">all</a>&nbsp;⸱&nbsp;
+ <a class="text-muted" href="{{ url_for('bookmarks.list_') + 'private' }}">private</a>&nbsp;⸱&nbsp;
+ <a class="text-muted" href="{{ url_for('bookmarks.list_') + 'public' }}">public</a>&nbsp;⸱&nbsp;
+ <a class="text-muted" href="{{ url_for('bookmarks.list_') + 'unread' }}">unread</a>
+ {% endif %}
+ </div>
+ </div>
+ <br />
+ <div class="row">
+ <div class="col-md-6">
+ {% if tag %}
+ <span class="glyphicon glyphicon-tags" aria-hidden="true"></span>&nbsp;&nbsp;{{ tag }}
+ {% endif %}
+ {% if query %}
+ <span class="glyphicon glyphicon-search" aria-hidden="true"></span>&nbsp;&nbsp;{{ query }}
+ {% endif %}
+ </div>
+ <div class="col-md-6 text-right">
+ <form method="GET">
+ <div class="form-inline">
+ <input type="text" name="query" class="form-control" />
+ <button type="submit" class="btn btn-default">Search</button>
+ </div>
+ </form>
+ </div>
+ </div>
+ <br />
+ <div class="row">
+ <div class="col-md-8">
+ {{ pagination.links }}
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-8">
+ <ul class="list-group">
+ {% for bookmark in bookmarks %}
+ <li class="list-group-item">
+ <a href="#">
+ <h4 class="list-group-item-heading">
+ <a href="{{ bookmark.href }}">{{ bookmark.title }}</a>
+ </h4>
+ <p class="list-group-item-text">
+ <div class="text-muted">{{ bookmark.description }}</div>
+ <div>{% for tag in bookmark.tags %}<a href="{{ url_for('bookmarks.list_', tag=tag.text) }}">{{ tag.text }}&nbsp;</a>{% endfor %}</div>
+ {{ bookmark.time | datetime }}
+ {% if current_user.is_authenticated %}
+ <a class="text-muted" href="{{ url_for('bookmark.form', bookmark_id=bookmark.id) }}">edit</a>
+ <a class="text-muted" href="{{ url_for('bookmark.delete', bookmark_id=bookmark.id) }}">delete</a>
+ {% endif %}
+ </p>
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+ </div>
+ </div>
+ <br />
+ <div class="row">
+ <div class="col-md-8">
+ {{ pagination.links }}
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/categories.html b/newspipe/web/templates/categories.html
new file mode 100644
index 00000000..4985e0ca
--- /dev/null
+++ b/newspipe/web/templates/categories.html
@@ -0,0 +1,36 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <h1>{{ _("You have %(categories)d categories &middot; Add a %(start_link)scategory%(end_link)s", categories=categories|count, start_link=("<a href='%s'>" % url_for("category.form"))|safe, end_link="</a>"|safe) }}</h1>
+ {% if categories|count == 0 %}
+ <h1>{{_("No category")}}</h1>
+ {% else %}
+ <div class="table-responsive">
+ <table class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>{{ _('Name') }}</th>
+ <th>{{ _('Feeds') }}</th>
+ <th>{{ _('Articles') }}</th>
+ <th>{{ _('Actions') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for category in categories %}
+ <tr>
+ <td>{{ loop.index }}</td>
+ <td>{{ category.name }}</td>
+ <td>{{ feeds_count.get(category.id, 0) }}</td>
+ <td>( {{ unread_article_count.get(category.id, 0) }} ) {{ article_count.get(category.id, 0) }}</td>
+ <td>
+ <a href="{{ url_for("category.form", category_id=category.id) }}"><i class="glyphicon glyphicon-edit" title='{{ _("Edit this category") }}'></i></a>
+ <a href="{{ url_for("category.delete", category_id=category.id) }}"><i class="glyphicon glyphicon-remove" title='{{ _("Delete this category") }}' onclick="return confirm('{{ _('You are going to delete this category.') }}');"></i></a>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ {% endif %}
+{% endblock %}
diff --git a/newspipe/web/templates/duplicates.html b/newspipe/web/templates/duplicates.html
new file mode 100644
index 00000000..d944a1a5
--- /dev/null
+++ b/newspipe/web/templates/duplicates.html
@@ -0,0 +1,30 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <p><h1>{{ _('Duplicates in the feed') }} <a href="/feed/{{ feed.id }}">{{ feed.title }}</a>.</h1><p>
+ <div class="table-responsive">
+ <table class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th align="center">
+ <span class="delete-all btn btn-default">{{ _('Delete all in this column') }}</span>
+ </th>
+ <th align="center">
+ <span class="delete-all btn btn-default">{{ _('Delete all in this column') }}</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for pair in duplicates %}
+ <tr>
+ <td>{{ loop.index }}</td>
+ <td id="{{ pair[0].id }}"><a href="{{ url_for("article.delete", article_id=pair[0].id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a>&nbsp;<a href="/article/{{ pair[0].id }}">{{ pair[0].title }}</a> ({{ pair[0].retrieved_date }})</td>
+ <td id="{{ pair[1].id }}"><a href="{{ url_for("article.delete", article_id=pair[1].id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a>&nbsp;<a href="/article/{{ pair[1].id }}">{{ pair[1].title }}</a> ({{ pair[1].retrieved_date }})</td>
+ </tr>
+ {% endfor %}
+ </tobdy>
+ </table>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/edit_bookmark.html b/newspipe/web/templates/edit_bookmark.html
new file mode 100644
index 00000000..efd9d775
--- /dev/null
+++ b/newspipe/web/templates/edit_bookmark.html
@@ -0,0 +1,84 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h3>{{ action }}</h3>
+ <form action="" method="post" name="save" class="form-horizontal">
+ {{ form.hidden_tag() }}
+ <div class="form-group">
+ <label for="{{ form.href.id }}" class="col-sm-3 control-label">{{ form.href.label }}</label>
+ <div class="col-sm-9">
+ {{ form.href(class_="form-control", size="100%") }}
+ </div>
+ {% for error in form.href.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.title.id }}" class="col-sm-3 control-label">{{ form.title.label }}</label>
+ <div class="col-sm-9">
+ {{ form.title(class_="form-control", size="100%") }}
+ </div>
+ {% for error in form.title.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.description.id }}" class="col-sm-3 control-label">{{ form.description.label }}</label>
+ <div class="col-sm-9">
+ {{ form.description(class_="form-control", size="100%") }}
+ </div>
+ {% for error in form.description.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.tags.id }}" class="col-sm-3 control-label">{{ form.tags.label }}</label>
+ <div class="col-sm-9">
+ {{ form.tags(class_="form-control", size="100%") }}
+ </div>
+ {% for error in form.tags.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.shared.id }}" class="col-sm-3 control-label">{{ form.shared.label }}</label>
+ <div class="col-sm-9">
+ <div class="checkbox">
+ {{ form.shared(class_="checkbox", style="margin-left: 0px;") }}
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.to_read.id }}" class="col-sm-3 control-label">{{ form.to_read.label }}</label>
+ <div class="col-sm-9">
+ <div class="checkbox">
+ {{ form.to_read(class_="checkbox", style="margin-left: 0px;") }}
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="col-sm-offset-3 col-sm-9">
+ {{ form.submit(class_="btn btn-default") }}
+ </div>
+ </div>
+ </form>
+ </div>
+ {% if action == _('Add a new bookmark') %}
+ <div class="row">
+ <div class="col-md-6 pull-right">
+ <p>{{ _('You can add a bookmark with a bookmarklet. Drag the following button to your browser bookmarks.') }}</p>
+ {{ _('<a class="btn btn-default" href="%(bookmarklet)s" rel="bookmark">Bookmark this page using Newspipe</a>', bookmarklet='javascript:window.location="%s?href="+encodeURIComponent(document.location)+"&title="+document.title' % url_for('bookmark.bookmarklet', _external=True)) }}
+ </div>
+ <div class="col-md-6">
+ <form action="{{ url_for('bookmark.import_pinboard') }}" method="post" id="formImportPinboard" enctype="multipart/form-data">
+ <p>{{ _('Import bookmarks from Pinboard') }} (*.json)</p>
+ <span>
+ <input type="file" name="jsonfile" />
+ <br />
+ <button class="btn btn-default btn-default" type="submit">OK</button>
+ </span>
+ </form>
+ </div>
+ </div>
+ {% endif %}
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/edit_category.html b/newspipe/web/templates/edit_category.html
new file mode 100644
index 00000000..93c952d6
--- /dev/null
+++ b/newspipe/web/templates/edit_category.html
@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h3>{{ action }}</h3>
+ <form action="" method="post" name="save" class="form-horizontal">
+ {{ form.hidden_tag() }}
+ <div class="form-group">
+ <label for="{{ form.name.id }}" class="col-sm-3 control-label">{{ form.name.label }}</label>
+ <div class="col-sm-9">
+ {{ form.name(class_="form-control", size="100%") }}
+ </div>
+ {% for error in form.name.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+ <div class="form-group">
+ <div class="col-sm-offset-3 col-sm-9">
+ {{ form.submit(class_="btn btn-default") }}
+ </div>
+ </div>
+ </form>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/edit_feed.html b/newspipe/web/templates/edit_feed.html
new file mode 100644
index 00000000..9c773204
--- /dev/null
+++ b/newspipe/web/templates/edit_feed.html
@@ -0,0 +1,98 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h3>{{ action }}</h3>
+ <form action="" method="post" name="save" class="form-horizontal">
+ {{ form.hidden_tag() }}
+ <div class="form-group">
+ <label for="{{ form.link.id }}" class="col-sm-3 control-label">{{ form.link.label }}</label>
+ <div class="col-sm-9">
+ {{ form.link(class_="form-control", size="100%") }}
+ </div>
+ {% for error in form.link.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.title.id }}" class="col-sm-3 control-label">{{ form.title.label }}</label>
+ <div class="col-sm-9">
+ {{ form.title(class_="form-control", size="100%", placeholder=_('Optional')) }}
+ </div>
+ {% for error in form.title.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.site_link.id }}" class="col-sm-3 control-label">{{ form.site_link.label }}</label>
+ <div class="col-sm-9">
+ {{ form.site_link(class_="form-control", size="100%", placeholder=_('Optional')) }}
+ </div>
+ {% for error in form.site_link.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.category_id.id }}" class="col-sm-3 control-label">{{ form.category_id.label }}</label>
+ <div class="col-sm-9">
+ {{ form.category_id(class_="form-control", placeholder=_('Optional')) }}
+ </div>
+ {% for error in form.category_id.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.enabled.id }}" class="col-sm-3 control-label">{{ form.enabled.label }}</label>
+ <div class="col-sm-9">
+ <div class="checkbox">
+ {{ form.enabled(class_="checkbox", style="margin-left: 0px;") }}
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="{{ form.private.id }}" class="col-sm-3 control-label">{{ form.private.label }}</label>
+ <div class="col-sm-9">
+ <div class="checkbox">
+ {{ form.private(class_="checkbox", style="margin-left: 0px;") }}
+ </div>
+ <span class="text-muted">{{ _("If checked, articles of this feed won't be available to others and the feed won't be listed on <a href='%(url)s'>your profile page</a>.", url=url_for('user.profile_public', nickname=current_user.nickname) ) }}</span>
+ <span class="text-muted">{{ _("Check this box if there is a private token in the link of the feed.") }}</span>
+ </div>
+
+ </div>
+
+ <div class="form-group">
+ <label class="col-sm-3 control-label">{{ _("Filters") }}</label>
+ <div class="col-sm-1">
+ <input value="+" type="button" class="form-control" id="add-feed-filter-row" />
+ </div>
+ </div>
+ <div class="form-inline col-sm-offset-4 col-sm-8" id="filters-container">
+ {% if feed %}
+ {% for filter_ in feed.filters or [] %}
+ <div class="form-group">
+ <input value="-" type="button" class="form-control del-feed-filter-row" />
+ <select name="type" class="form-control" >
+ <option value="simple match" {% if filter_.get("type") == "simple match" %}selected{% endif %}>{{ _("simple match") }}</option>
+ <option value="regex" {% if filter_.get("type") == "regex" %}selected{% endif %}>{{ _("regex") }}</option>
+ </select>
+ <input type="text" class="form-control" value="{{ filter_.get("pattern") }}" name="pattern" />
+ <select name="action_on" class="form-control">
+ <option value="match" {% if filter_.get("action on") == "match" %}selected{% endif %}>{{ _("match") }}</option>
+ <option value="no match" {% if filter_.get("action on") == "no match" %}selected{% endif %}>{{ _("no match") }}</option>
+ </select>
+ <select name="action" class="form-control">
+ <option value="mark as read" {% if filter_.get("action") == "mark as read" %}selected{% endif %}>{{ _("mark as read") }}</option>
+ <option value="mark as favorite" {% if filter_.get("action") == "mark as favorite" %}selected{% endif %}>{{ _("mark as favorite") }}</option>
+ </select>
+ </div>
+ {% endfor %}
+ {% endif %}
+ </div>
+
+ <div class="form-group">
+ <div class="col-sm-offset-3 col-sm-9">
+ {{ form.submit(class_="btn btn-default") }}
+ </div>
+ </div>
+ </form>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/emails/account_activation.txt b/newspipe/web/templates/emails/account_activation.txt
new file mode 100644
index 00000000..c7d9c52e
--- /dev/null
+++ b/newspipe/web/templates/emails/account_activation.txt
@@ -0,0 +1,9 @@
+Hello {{ user.nickname }},
+
+Your account has been created.
+Click on the following link in order to confirm it:
+{{ platform_url }}user/confirm_account/{{ token }}
+
+The link expires at {{ expire_time.strftime('%Y-%m-%d %H:%M') }}.
+
+See you,
diff --git a/newspipe/web/templates/emails/new_password.txt b/newspipe/web/templates/emails/new_password.txt
new file mode 100644
index 00000000..1a04a36d
--- /dev/null
+++ b/newspipe/web/templates/emails/new_password.txt
@@ -0,0 +1,8 @@
+Hello {{ user.nickname }},
+
+A new password has been generated at your request:
+{{ password }}
+
+It is advised to replace it as soon as connected to Newspipe.
+
+See you,
diff --git a/newspipe/web/templates/errors/404.html b/newspipe/web/templates/errors/404.html
new file mode 100644
index 00000000..c64a2be8
--- /dev/null
+++ b/newspipe/web/templates/errors/404.html
@@ -0,0 +1,12 @@
+{% extends "layout.html" %}
+{% block head %}
+{{ super() }}
+{% endblock %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h1>Page Not Found</h1>
+ <p>What you were looking for is just not there, go to the <a href="{{ url_for('home') }}">home page</a>.</p>
+ </div>
+</div>
+{% endblock %}
diff --git a/newspipe/web/templates/errors/500.html b/newspipe/web/templates/errors/500.html
new file mode 100644
index 00000000..417fc0c7
--- /dev/null
+++ b/newspipe/web/templates/errors/500.html
@@ -0,0 +1,12 @@
+{% extends "layout.html" %}
+{% block head %}
+{{ super() }}
+{% endblock %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h1>Internal Server Error</h1>
+ <p>Something bad just happened! Go to the <a href="{{ url_for('home') }}">home page</a>.</p>
+ </div>
+</div>
+{% endblock %}
diff --git a/newspipe/web/templates/feed.html b/newspipe/web/templates/feed.html
new file mode 100644
index 00000000..31db94a5
--- /dev/null
+++ b/newspipe/web/templates/feed.html
@@ -0,0 +1,76 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h2>{{ feed.title }}</h2>
+ {% if feed.description %} <p>{{ feed.description }}</p> {% endif %}
+ {% if current_user.is_authenticated %}
+ <a href="{{ url_for("feed.delete", feed_id=feed.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this feed') }}" onclick="return confirm('{{ _('You are going to delete this feed.') }}');"></i></a>
+ <a href="{{ url_for("feed.form", feed_id=feed.id) }}"><i class="glyphicon glyphicon-edit" title="{{ _('Edit this feed') }}"></i></a>
+ {% endif %}
+ </div>
+ <div class="well">
+ <p>
+ {{ _('This feed contains') }} {{ feed.articles.all()|count }} {{ _('articles') }}.<br />
+ {% if category %}
+ {{ _('This feed is part of category %(category_name)s', category_name=category.name) }}<br />
+ {% endif %}
+ {{ _('Address of the feed') }}: <a href="{{ feed.link }}" target="_blank">{{ feed.link }}</a><br />
+ {% if feed.site_link != "" %}
+ {{ _('Address of the site') }}: <a href="{{ feed.site_link }}" target="_blank">{{ feed.site_link }}</a><br />
+ {% endif %}
+
+ <br />
+
+ {% if feed.last_retrieved %}
+ {{ _("Last download:") }} {{ feed.last_retrieved | datetime }}<br />
+ {% endif %}
+
+ {% if feed.error_count >= conf.DEFAULT_MAX_ERROR %}
+ <b>{{ _("That feed has encountered too much consecutive errors and won't be retrieved anymore.") }}</b><br />
+ {{ _("You can click <a href='%(reset_error_url)s'>here</a> to reset the error count and reactivate the feed.", reset_error_url=url_for("feed.reset_errors", feed_id=feed.id)) }}
+ {% elif feed.error_count > 0 %}
+ {{ _("The download of this feed has encountered some problems. However its error counter will be reinitialized at the next successful retrieving.") }}<br />
+ {% endif %}
+
+ {% if feed.last_error %}
+ {{ _("Here's the last error encountered while retrieving this feed:") }} <pre>{{ feed.last_error }}</pre><br />
+ {% endif %}
+
+ {% if feed.articles.all()|count != 0 %}
+ {{ _('The last article was posted') }} {{ elapsed.days }} {{ _('day(s) ago.') }}<br />
+ {{ _('Daily average') }}: {{ average }}, {{ _('between the') }} {{ first_post_date | datetime }} {{ _('and the') }} {{ end_post_date | datetime }}.
+ {% endif %}
+ </p>
+ </div>
+
+ <div class="row">
+ <div class="col-md-12">
+ <div class="table-responsive">
+ <table id="table-articles" class="table table-striped">
+ <thead>
+ <tr>
+ <th>{{ _('Article') }}</th>
+ <th>{{ _('Date') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for article in articles %}
+ <tr>
+ <td><a href="{{ url_for("article.article_pub", article_id=article.id) }}">{{ article.title }}</a></td>
+ <td>{{ article.date | datetime }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-8 offset-md-1">
+ {{ pagination.links }}
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/feed_list.html b/newspipe/web/templates/feed_list.html
new file mode 100644
index 00000000..8c258c3c
--- /dev/null
+++ b/newspipe/web/templates/feed_list.html
@@ -0,0 +1,55 @@
+{% if feeds.count() != 0 %}
+<div class="table-responsive">
+ <table id="table-feeds" class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>{{ _('Status') }}</th>
+ <th>{{ _('Title') }}</th>
+ <th>{{ _('Site') }}</th>
+ <th>{{ _('Articles') }}</th>
+ <th>{{ _('Actions') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for feed in feeds %}
+ <tr {% if not feed.enabled %}class="warning"{% endif %}>
+ <td>{{ loop.index }}</td>
+ <td>
+ {% if feed.enabled %}
+ <i class="glyphicon glyphicon-eye-open" title="{{ _('Feed enabled') }}"></i>
+ {% else %}
+ <i class="glyphicon glyphicon-eye-close" title="{{ _('Feed disabled') }}"></i>
+ {% endif %}
+ {% if feed.error_count >= conf.DEFAULT_MAX_ERROR %}
+ <i class="glyphicon glyphicon-exclamation-sign" title="{{ _('Feed encountered too much errors.') }}"></i>
+ {% endif %}
+ </td>
+ <td>{% if feed.icon_url %}<img src="{{ url_for('icon.icon', url=feed.icon_url) }}" width="16px" />&nbsp;{% endif %}{{ feed.title }}</td>
+ <td><a href="{{ feed.site_link }}">{{ feed.site_link }}</a></td>
+ <td>( {{ unread_article_count.get(feed.id, 0) }} ) {{ article_count.get(feed.id, 0) }}</td>
+ <td>
+ <a href="{{ url_for("feed.feed", feed_id=feed.id) }}"><i class="glyphicon glyphicon-info-sign" title="{{ _('Information') }}"></i></a>
+ <a href="{{ url_for("feed.form", feed_id=feed.id) }}"><i class="glyphicon glyphicon-edit" title="{{ _('Edit this feed') }}"></i></a>
+ <a href="{{ url_for("feed.duplicates", feed_id=feed.id) }}"><i class="glyphicon glyphicon-book" title="{{ _('Duplicate articles') }}"></i></a>
+ <a href="{{ url_for("feed.delete", feed_id=feed.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this feed') }}" onclick="return confirm('{{ _('You are going to delete this feed.') }}');"></i></a>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+</div>
+<script>
+$(document).ready(function() {
+ $('#table-feeds').DataTable( {
+ responsive: true,
+ columnDefs: [
+ {
+ bSortable: false,
+ targets: [0, 1, 4, 5]
+ }
+ ]
+ });
+});
+</script>
+{% endif %}
diff --git a/newspipe/web/templates/feed_list_per_categories.html b/newspipe/web/templates/feed_list_per_categories.html
new file mode 100644
index 00000000..34d10ddd
--- /dev/null
+++ b/newspipe/web/templates/feed_list_per_categories.html
@@ -0,0 +1,52 @@
+<div class="row">
+ <div class="col-md-8">
+ <form class="form-inline">
+ <div class="form-group">
+ <label>Filter per category</label>
+ <select class="form-control" id="category-select" name="category_id">
+ <option value="0">All</option>
+ {% for category in user.categories %}
+ <option value="{{category.id}}" {% if category.id==selected_category_id %}selected{% endif %}>{{ category.name }}</option>
+ {% endfor %}
+ </select>
+ <button type="submit" class="btn btn-primary mb-2">OK</button>
+ </div>
+ </form>
+ </div>
+</div>
+
+<br />
+
+<div class="table-responsive">
+ <table id="table-feeds" class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>{{ _('Title') }}</th>
+ <th>{{ _('Site') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for feed in feeds %}
+ <tr>
+ <td>{{ loop.index }}</td>
+ <td>{% if feed.icon_url %}<img src="{{ url_for('icon.icon', url=feed.icon_url) }}" width="16px" />&nbsp;{% endif %} <a href="{{ url_for('feed.feed_pub', feed_id=feed.id) }}">{{ feed.title }}</a></td>
+ <td><a href="{{ feed.site_link }}">{{ feed.site_link }}</a></td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+</div>
+<script>
+$(document).ready(function() {
+ $('#table-feeds').DataTable( {
+ responsive: true,
+ columnDefs: [
+ {
+ bSortable: false,
+ targets: [0]
+ }
+ ]
+ });
+});
+</script>
diff --git a/newspipe/web/templates/feed_list_simple.html b/newspipe/web/templates/feed_list_simple.html
new file mode 100644
index 00000000..5f692a53
--- /dev/null
+++ b/newspipe/web/templates/feed_list_simple.html
@@ -0,0 +1,35 @@
+{% if feeds | length != 0 %}
+<div class="table-responsive">
+ <table id="table-feeds" class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>{{ _('Title') }}</th>
+ <th>{{ _('Site') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for feed in feeds %}
+ <tr>
+ <td>{{ loop.index }}</td>
+ <td>{% if feed.icon_url %}<img src="{{ url_for('icon.icon', url=feed.icon_url) }}" width="16px" />&nbsp;{% endif %} <a href="{{ url_for('feed.feed_pub', feed_id=feed.id) }}">{{ feed.title }}</a></td>
+ <td><a href="{{ feed.site_link }}">{{ feed.site_link }}</a></td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+</div>
+<script>
+$(document).ready(function() {
+ $('#table-feeds').DataTable( {
+ responsive: true,
+ columnDefs: [
+ {
+ bSortable: false,
+ targets: [0]
+ }
+ ]
+ });
+});
+</script>
+{% endif %}
diff --git a/newspipe/web/templates/feeds.html b/newspipe/web/templates/feeds.html
new file mode 100644
index 00000000..805e1b74
--- /dev/null
+++ b/newspipe/web/templates/feeds.html
@@ -0,0 +1,7 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <h1>{{ _('You are subscribed to %(feed_count)d feeds.', feed_count=feeds.count()) }} <a href="{{ url_for("feed.form") }}">{{ _('Add') }}</a> {{ _('a feed') }}.</h1>
+ {% include "feed_list.html" %}
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/history.html b/newspipe/web/templates/history.html
new file mode 100644
index 00000000..d9ee9538
--- /dev/null
+++ b/newspipe/web/templates/history.html
@@ -0,0 +1,26 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <h1>{{ _('History') }}</h1>
+ {% if month != None %}
+ <h2><a href="{{ url_for("articles.history", year=year) }}"><span class="glyphicon glyphicon-chevron-left"></span> {{ year }}</a></h2>
+ <h3>{{ month | month_name }}</h3>
+ {% elif year != None %}
+ <h2><a href="{{ url_for("articles.history") }}"><span class="glyphicon glyphicon-chevron-left"></span>&nbsp{{ _('all years') }}</a></h2>
+ <h3>{{ year }}</h3>
+ {% endif %}
+ <ul class="list-group">
+ {% for article in articles_counter | sort(reverse = True) %}
+ {% if year == None %}
+ <li class="list-group-item"><a href="{{ url_for("articles.history", year=article) }}">{{ article }}</a> : {{ articles_counter[article] }} articles</li>
+ {% elif month == None %}
+ <li class="list-group-item"><a href="{{ url_for("articles.history", year=year, month=article) }}">{{ article | month_name }}</a> : {{ articles_counter[article] }} articles</li>
+ {% else %}
+ {% for article in articles %}
+ <li class="list-group-item">{{ article.date | datetime }} - <a href="/article/{{ article.id }}">{{ article.title | safe }}</a></li>
+ {% endfor %}
+ {% endif %}
+ {% endfor %}
+ </ul>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/home.html b/newspipe/web/templates/home.html
new file mode 100644
index 00000000..bc38d189
--- /dev/null
+++ b/newspipe/web/templates/home.html
@@ -0,0 +1,9 @@
+{% extends "layout.html" %}
+{% block head %}
+ {{ super() }}
+ <link href="{{ url_for("static", filename="css/one-page-app.css") }}" rel="stylesheet" media="screen" />
+{% endblock %}
+{% block content %}
+ <section id="newspipeapp"></section>
+ <script type="text/javascript" src="{% if cdn != '' %}{{ cdn }}bundle.min.js{% else %}{{ url_for('static', filename = 'js/bundle.min.js') }}{% endif %}"></script>
+{% endblock %}
diff --git a/newspipe/web/templates/inactives.html b/newspipe/web/templates/inactives.html
new file mode 100644
index 00000000..e89a5fe1
--- /dev/null
+++ b/newspipe/web/templates/inactives.html
@@ -0,0 +1,26 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <form method=get action="{{ url_for("feeds.inactives") }}">
+ <p>{{ _('Days of inactivity') }}:</p>
+ <input type="number" name="nb_days" class="form-control" value="{{ nb_days }}" min="0" max="1000000" step="1" size="4" style="text-align: center" />
+ </form>
+ <br />
+ {% if inactives != [] %}
+ <ul class="list-group">
+ {% for feed, delta in inactives %}
+ <li class="list-group-item">
+ <a href="{{ url_for('feed.feed', feed_id=feed.id) }}">
+ {% if feed.icon %}<img src="{{ url_for('feed.icon', feed_id=feed.id) }}" width="16px" />{% endif %}
+ {{ feed.title }}
+ </a> - {{ delta.days }} {{ _('days') }}
+ </li>
+ {% endfor %}
+ </ul>
+ {% else %}
+ <p>{{ _('No inactive feeds.') }}<p>
+ {% endif %}
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/layout.html b/newspipe/web/templates/layout.html
new file mode 100644
index 00000000..29d4470f
--- /dev/null
+++ b/newspipe/web/templates/layout.html
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ {% block head %}
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="description" content="Newspipe is a web news aggregator and reader." />
+ <meta name="author" content="" />
+ <title>Newspipe{% if head_titles %} - {{ ' - '.join(head_titles) }}{% endif %}</title>
+ <link rel="shortcut icon" href="{{ url_for("static", filename="img/favicon.ico") }}" />
+ <!-- Bootstrap core CSS -->
+ <link href="{{ url_for('static', filename='bower_components/bootstrap/dist/css/bootstrap.min.css') }}" rel="stylesheet" media="screen" />
+ <!-- Add custom CSS here -->
+ <link href="{{ url_for("static", filename="css/customized-bootstrap.css") }}" rel="stylesheet" media="screen" />
+ <!-- jquery, bootstrap, datatables -->
+ <script type="text/javascript" src="{{ url_for('static', filename = 'js/jquery.js') }}"></script>
+ <script type="text/javascript" src="{{ url_for('static', filename = 'bower_components/bootstrap/dist/js/bootstrap.min.js') }}"></script>
+ <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='bower_components/datatables.net-bs/css/dataTables.bootstrap.min.css') }}">
+ <script type="text/javascript" src="{{ url_for('static', filename='bower_components/datatables.net/js/jquery.dataTables.min.js') }}"></script>
+ <script type="text/javascript" src="{{ url_for('static', filename='bower_components/datatables.net-bs/js/dataTables.bootstrap.min.js') }}"></script>
+ {% endblock %}
+ </head>
+ <body>
+ {% block menu %}
+ <nav id="newspipenav" class="navbar navbar-inverse navbar-fixed-top" role="navigation">
+ <div class="container-fluid">
+ <div class="navbar-header">
+ <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
+ <span class="sr-only">Toggle navigation</span>
+ <span class="icon-bar"></span>
+ <span class="icon-bar"></span>
+ <span class="icon-bar"></span>
+ </button>
+ {% if current_user.is_authenticated %}
+ <a class="navbar-brand" href="{{ url_for("feeds.feeds") }}">🗞&nbsp;</a>
+ {% else %}
+ <a class="navbar-brand" href="{{ url_for("home") }}">🗞&nbsp;</a>
+ {% endif %}
+ <a class="navbar-brand" href="{{ url_for("home") }}">Newspipe</a>
+ {% if head_titles %}
+ <p class="navbar-text" style="max-height: 20px; overflow: hidden">
+ {{ " - ".join(head_titles) }}
+ </p>
+ {% endif %}
+ </div>
+ <!-- Collect the nav links, forms, and other content for toggling -->
+ <div id="navbar" class="navbar-collapse collapse">
+ <ul class="nav navbar-nav navbar-right">
+ {% if current_user.is_authenticated %}
+ {% if conf.CRAWLING_METHOD == "default" and (not conf.ON_HEROKU or current_user.is_admin) %}
+ <li><a href="/fetch"><span class="glyphicon glyphicon-import"></span> {{ _('Fetch') }}</a></li>
+ {% endif %}
+ <li class="dropdown">
+ <a href="#" class="dropdown-toggle" data-toggle="dropdown">
+ <div><span class="glyphicon glyphicon-bookmark"></span></div>
+ </a>
+ <ul class="dropdown-menu">
+ <li><a href="{{ url_for('bookmarks.list_') }}">{{ _('Your bookmarks') }}</a></li>
+ <li><a href="{{ url_for('bookmark.form') }}">{{ _('Add a new bookmark') }}</a></li>
+ </ul>
+ </li>
+ <li class="dropdown">
+ <a href="#" class="dropdown-toggle" data-toggle="dropdown">
+ <div><span class="glyphicon glyphicon-plus-sign"></span>&nbsp;{{ _('Add a new feed') }}</div>
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <form action="{{ url_for('feed.bookmarklet') }}" class="navbar-form navbar-left" method="GET" name="save">
+ <div class="input-group input-group-inline">
+ <input class="form-control" name="url" type="url" placeholder="{{_("Site or feed url")}}" required="required"/>
+ <span class="input-group-btn">
+ <button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-plus"></span></button>
+ </span>
+ </div><!-- /input-group -->
+ </form>
+ </li>
+ </ul>
+ </li>
+ <li class="dropdown">
+ <a href="#" class="dropdown-toggle" data-toggle="dropdown">
+ <div><span class="glyphicon glyphicon-plus-sign"></span>&nbsp;{{ _('Add a new category') }}</div>
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <form action="{{ url_for('category.form') }}" class="navbar-form navbar-left" method="POST" name="category">
+ <div class="input-group input-group-inline">
+ <input class="form-control" name="name" type="text" placeholder="{{_("Category name")}}" required="required"/>
+ <span class="input-group-btn">
+ <button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-plus"></span></button>
+ </span>
+ </div><!-- /input-group -->
+ </form>
+ </li>
+ </ul>
+ </li>
+ <li class="dropdown">
+ <a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ _('Feed') }} <b class="caret"></b></a>
+ <ul class="dropdown-menu">
+ <li><a href="{{ url_for("feeds.inactives") }}">{{ _('Inactive') }}</a></li>
+ <li><a href="{{ url_for("articles.history") }}">{{ _('History') }}</a></li>
+ <li><a href="{{ url_for("feeds.feeds") }}">{{ _('All') }}</a></li>
+ <li role="presentation" class="divider"></li>
+ <li><a href="{{ url_for("popular") }}">{{ _('Popular') }}</a></li>
+ </ul>
+ </li>
+ <li class="dropdown">
+ <a href="#" class="dropdown-toggle" data-toggle="dropdown">
+ <div><span class="glyphicon glyphicon-user"></span>&nbsp;<b class="caret"></b></div>
+ </a>
+ <ul class="dropdown-menu">
+ <li><a href="{{ url_for("user.profile") }}"><span class="glyphicon glyphicon-user"></span> {{ _('Profile') }}</a></li>
+ <li><a href="{{ url_for("user.management") }}"><span class="glyphicon glyphicon-hdd"></span> {{ _('Your data') }}</a></li>
+ <li><a href="{{ url_for("about") }}"><span class="glyphicon glyphicon-question-sign"></span> {{ _('About') }}</a></li>
+ {% if current_user.is_admin %}
+ <li role="presentation" class="divider"></li>
+ <li><a href="{{ url_for("admin.dashboard") }}"><span class="glyphicon glyphicon-dashboard"></span> {{ _('Dashboard') }}</a></li>
+ <li role="presentation" class="divider"></li>
+ {% endif %}
+ <li><a href="{{ url_for("logout") }}"><span class="glyphicon glyphicon-log-out"></span> {{ _('Logout') }}</a></li>
+ </ul>
+ </li>
+ {% else %}
+ <li><a href="{{ url_for("bookmarks.list_") }}"><span class="glyphicon glyphicon-bookmark"></span>&nbsp;{{ _('Recent bookmarks') }}</a></li>
+ <li><a href="{{ url_for("popular") }}"><span class="glyphicon glyphicon-list"></span>&nbsp;{{ _('Popular feeds') }}</a></li>
+ <li><a href="{{ url_for("about") }}"><span class="glyphicon glyphicon-question-sign"></span>&nbsp;{{ _('About') }}</a></li>
+ {% endif %}
+ </ul>
+ </div><!-- /.navbar-collapse -->
+ </div><!-- /.container-fluid -->
+ </nav>
+ {% endblock %}
+ <br />
+
+ <div class="container alert-message not-at-home">
+ {% block messages %}
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ {% for category, message in messages %}
+ <div class="alert alert-{{category}}">
+ <button type="button" class="close" data-dismiss="alert">&times;</button>
+ {{ message }}
+ </div>
+ {% endfor %}
+ {% endif %}
+ {% endwith %}
+ {% endblock %}
+ </div>
+
+ {% block content %}{% endblock %}
+
+ <!-- Placed at the end of the document so the pages load faster -->
+ <script type="text/javascript" src="{{ url_for('static', filename = 'js/articles.js') }}"></script>
+ <script type="text/javascript" src="{{ url_for('static', filename = 'js/feed.js') }}"></script>
+ </body>
+</html>
diff --git a/newspipe/web/templates/login.html b/newspipe/web/templates/login.html
new file mode 100644
index 00000000..d2724ac3
--- /dev/null
+++ b/newspipe/web/templates/login.html
@@ -0,0 +1,28 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <h2>{{ _('Log In') }}</h2>
+ <form action="{{ url_for('login') }}" method=post>
+ {{ form.hidden_tag() }}
+
+ <div class="form-group">
+ {{ form.nickmane(class_="form-control", placeholder=_('Your nickname')) }}
+ </div>
+ {% for message in form.nickmane.errors %}
+ <div class="alert alert-warning" role="alert">{{ message }}</div>
+ {% endfor %}
+
+ <div class="form-group">
+ {{ form.password(class_="form-control", placeholder=_('Your Password')) }}
+ </div>
+ {% for message in form.password.errors %}
+ <div class="alert alert-warning" role="alert">{{ message }}</div>
+ {% endfor %}
+
+ {{ form.submit(class_="btn btn-default") }}
+ </form>
+ </div>
+ <a href="/signup" class="btn btn-default">{{ _('Sign up') }}</a>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/management.html b/newspipe/web/templates/management.html
new file mode 100644
index 00000000..4d7c2da3
--- /dev/null
+++ b/newspipe/web/templates/management.html
@@ -0,0 +1,72 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="well">
+ <div class="row">
+ <div class="col-md-6">
+ <h1>{{ _('Your subscriptions') }}</h1>
+ <p>{{ _('You are subscribed to') }} {{ nb_feeds }} <a href="/feeds">{{ _('feeds') }}</a>. <a href="{{ url_for("feed.form") }}">{{ _('Add') }}</a> {{ _('a feed') }}.</p>
+ <p>{{ nb_articles }} {{ _('articles are stored in the database with') }} {{ nb_unread_articles }} {{ _('unread articles') }}.</p>
+ <p>{{ _('You have') }} {{ nb_categories }} <a href="{{ url_for("categories.list_")}}">{{ _('categories') }}</a>.</p>
+ <a href="{{ url_for("articles.expire", weeks=10) }}" class="btn btn-default" onclick="return confirm('{{ _('You are going to delete old articles.') }}');">{{ _('Delete articles older than 10 weeks') }}</a>
+ </div>
+ <div class="col-md-6">
+ <h1>{{ _('Your bookmarks') }}</h1>
+ <p>{{ _('You have') }} {{ nb_bookmarks }} <a href="{{ url_for("bookmarks.list_")}}">{{ _('bookmarks') }}</a>.</p>
+ <a href="{{ url_for("bookmarks.delete_all") }}" class="btn btn-default" onclick="return confirm('{{ _('You are going to delete all bookmarks.') }}');">{{ _('Delete all bookmarks') }}</a>
+ </div>
+ </div>
+ </div>
+ <div class="well">
+ <div class="row">
+ <div class="col-md-6">
+ <h1>{{ _('Your data') }}</h1>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-6">
+ <h2>{{ _('Articles') }}</h2>
+ <h3>{{ _('Import') }}</h3>
+ <form action="" method="post" id="formImportJSON" enctype="multipart/form-data">
+ <span class="btn btn-default btn-file">{{ _('Import a Newspipe account') }} (<span class="text-info">*.json</span>)<input type="file" name="jsonfile" /></span>
+ <button class="btn btn-default" type="submit">OK</button>
+ </form>
+ <h3>{{ _('Export') }}</h3>
+ <a href="{{ url_for('articles.export') }}" class="btn btn-default">{{ _('Export your Newspipe account to JSON') }}</a>
+ </div>
+ <div class="col-md-6">
+ <h2 id="import">{{ _('OPML') }}</h2>
+ <h3>{{ _('Import') }}</h3>
+ <form action="" method="post" id="formImportOPML" enctype="multipart/form-data">
+ <span class="btn btn-default btn-file">{{ _('Batch import feeds from OPML') }} (<span class="text-info">*.xml {{ _('or') }} *.opml</span>)<input type="file" name="opmlfile" /></span>
+ <button class="btn btn-default" type="submit">OK</button>
+ </form>
+ <h3>{{ _('Export') }}</h3>
+ <form class="form-inline" action="{{ url_for('feeds.export') }}" method="GET" id="formExportOPML">
+ <div class="form-group">
+ <div class="input-group">
+ <label>Include disabled feeds</label>
+ <input type="checkbox" class="form-control" name="includedisabled" checked />
+ </div>
+ <div class="input-group">
+ <label title="Newspipe encountered too much problems when retrieving these feeds.">Include dead feeds</label>
+ <input type="checkbox" class="form-control" name="includeexceedederrorcount" checked />
+ </div>
+ <div class="input-group">
+ <label>Include private feeds</label>
+ <input type="checkbox" class="form-control" name="includeprivate" checked />
+ </div>
+ </div>
+ <button class="btn btn-default" type="submit">{{ _('Export your feeds to OPML') }}</button>
+ </form>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-12">
+ <h1>{{ _('Bookmarks') }}</h1>
+ <a href="{{ url_for('bookmarks.export') }}" class="btn btn-default">{{ _('Export your bookmarks to JSON') }}</a>
+ </div>
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/opml.xml b/newspipe/web/templates/opml.xml
new file mode 100644
index 00000000..7159e279
--- /dev/null
+++ b/newspipe/web/templates/opml.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!-- OPML generated by Newspipe on {{ now | datetime }} -->
+<opml version="1.1">
+ <head>
+ <title>Feeds of {{ user.nickname }}</title>
+ <dateCreated>{{ now | datetime }}</dateCreated>
+ <dateModified>{{ now | datetime }}</dateModified>
+ <ownerName>{{ user.nickname }}</ownerName>
+ </head>
+ <body>
+ {% for feed in feeds %} <outline title="{{ feed.title|escape }}" text="{{ feed.title|escape }}" description="{{ feed.description|escape }}" {% if feed.category_id != None %}category="/{{ categories[feed.category_id].name }}"{% endif %} xmlUrl="{{ feed.link|escape }}" htmlUrl="{{ feed.site_link|escape }}" />
+ {% endfor %}</body>
+</opml>
diff --git a/newspipe/web/templates/popular.html b/newspipe/web/templates/popular.html
new file mode 100644
index 00000000..4b207903
--- /dev/null
+++ b/newspipe/web/templates/popular.html
@@ -0,0 +1,27 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="row">
+ <div class="col-md-12">
+ <h1>{{ _('Popular feeds') }}</h1>
+ <a href="{{ url_for('popular', nb_days='all') }}">all</a>&nbsp;&#8231;&nbsp;<a href="{{ url_for('popular', nb_days=365) }}">last year</a>&nbsp;&#8231;&nbsp;<a href="{{ url_for('popular', nb_days=31) }}">last month</a>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-12">
+ <ul class="list-group">
+ {% for feed in popular %}
+ <li class="list-group-item">
+ <a href="{{ feed[0] }}">{{ feed[0] }}</a>&nbsp;
+ <a href="{{ url_for('feed.bookmarklet', url=feed[0]) }}" >
+ <span class="glyphicon glyphicon-plus text-muted" title="follow this feed"></span>
+ <span class="text-muted">{{ _('add this feed') }}</span>
+ </a>
+ <span class="badge">{{ feed[1] }}</span>
+ </li>
+ {% endfor %}
+ </ul>
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/profile.html b/newspipe/web/templates/profile.html
new file mode 100644
index 00000000..523e7c3c
--- /dev/null
+++ b/newspipe/web/templates/profile.html
@@ -0,0 +1,65 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <h1>{{ _('Your Profile') }}</h1>
+ <div class="row">
+ <div class="col-md-6">
+ <p>{{ _('Member since') }}: {{ user.date_created | datetime }}.</p>
+ <p>{{ _('Last seen') }}: {{ user.last_seen | datetime }}.</p>
+ </div>
+ </div>
+ <div class="well">
+ <div class="row">
+ <div class="col-md-12">
+ <h2>Edit your profile</h2>
+ </div>
+ </div>
+ <form action="" method="post" name="save">
+ <div class="row">
+ {{ form.hidden_tag() }}
+
+ <div class="col-md-6">
+ {{ form.nickname.label }}
+ {{ form.nickname(class_="form-control") }} {% for error in form.nickname.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.password.label }}
+ {{ form.password(class_="form-control") }} {% for error in form.password.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.password_conf.label }}
+ {{ form.password_conf(class_="form-control") }} {% for error in form.password_conf.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ </div>
+
+ <div class="col-md-6">
+ {{ form.bio.label }}
+ {{ form.bio(class_="form-control") }} {% for error in form.bio.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.webpage.label }}
+ {{ form.webpage(class_="form-control") }} {% for error in form.webpage.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.twitter.label }}
+ {{ form.twitter(class_="form-control") }} {% for error in form.twitter.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+
+ {{ form.is_public_profile.label }}
+ {{ form.is_public_profile(class_="form-control") }} {% for error in form.is_public_profile.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ <p>{{ _('Your profile will be available <a href="%(url)s">here</a>.', url=url_for('user.profile_public', nickname=user.nickname) ) }}</p>
+
+ {{ form.automatic_crawling.label }}
+ {{ form.automatic_crawling(class_="form-control") }} {% for error in form.automatic_crawling.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ <p>{{ _('Uncheck if you are using your own crawler.') }}</p>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-12">
+ <br />
+ {{ form.submit(class_="btn btn-default") }}
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="row">
+ <div class="col-md-12">
+ <a href="/delete_account" class="btn btn-default" onclick="return confirm('{{ _('You are going to delete your account.') }}');">{{ _('Delete your account') }}</a>
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/profile_public.html b/newspipe/web/templates/profile_public.html
new file mode 100644
index 00000000..e933a04b
--- /dev/null
+++ b/newspipe/web/templates/profile_public.html
@@ -0,0 +1,45 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <h1>{{ user.nickname }} / <a href="{{ url_for('user.user_stream', nickname=user.nickname) }}">stream</a></h1>
+ <div class="row">
+ <div class="col-md-12">
+ <p>
+ <span class="glyphicon glyphicon-time" aria-hidden="true"></span>&nbsp;
+ {{ _('Member since') }}: {{ user.date_created | datetime }}
+ </p>
+ <p>
+ <span class="glyphicon glyphicon-time" aria-hidden="true"></span>&nbsp;
+ {{ _('Last seen') }}: {{ user.last_seen | datetime }}
+ </p>
+ {% if user.webpage %}
+ <p>
+ <span class="glyphicon glyphicon-link" aria-hidden="true"></span>&nbsp;
+ {{ _('Webpage') }}: <a href="{{ user.webpage | safe }}">{{ user.webpage | safe }}</a>
+ </p>
+ {% endif %}
+ {% if user.twitter %}
+ <p>
+ <span class="glyphicon glyphicon-link" aria-hidden="true"></span>&nbsp;
+ {{ _('Twitter') }}: <a href="{{ user.twitter | safe }}">{{ user.twitter | safe }}</a>
+ </p>
+ {% endif %}
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">
+ {% if user.bio %}
+ <p align="justify">{{ user.bio }}</p>
+ {% endif %}
+ </div>
+ <div class="col-md-6 pull-right"></div>
+ </div>
+
+ <h2>{{ _('Feeds') }}</h2>
+ <div class="row">
+ <div class="col-md-12">
+ {% include "feed_list_per_categories.html" %}
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/signup.html b/newspipe/web/templates/signup.html
new file mode 100644
index 00000000..8d34b3bf
--- /dev/null
+++ b/newspipe/web/templates/signup.html
@@ -0,0 +1,24 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="form well">
+ <form action="" method="post" name="save">
+ {{ form.hidden_tag() }}
+ <div class="form-group">
+ {{ form.nickname(class_="form-control", placeholder=_('Your nickname')) }} {% for error in form.nickname.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ <p class="help-block">{{ _('Letters, numbers, dots and underscores only.') }}</p>
+ </div>
+ <div class="form-group">
+ {{ form.email(class_="form-control", placeholder=_('Your email')) }} {% for error in form.email.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ <p class="help-block">{{ _("Only for account activation. Your email won't be stored.") }}</p>
+ </div>
+ <div class="form-group">
+ {{ form.password(class_="form-control", placeholder=_('Your password')) }} {% for error in form.password.errors %} <span style="color: red;">{{ error }}<br /></span>{% endfor %}
+ <p class="help-block">{{ _('Minimum 6 characters.') }}</p>
+ </div>
+ <br />
+ {{ form.submit(class_="btn btn-default") }}
+ </form>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/templates/user_stream.html b/newspipe/web/templates/user_stream.html
new file mode 100644
index 00000000..b05376a8
--- /dev/null
+++ b/newspipe/web/templates/user_stream.html
@@ -0,0 +1,70 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="container">
+ <div class="row">
+ <div class="col-md-8">
+ <form class="form-inline">
+ <div class="form-group">
+ <label>Filter per category</label>
+ <select class="form-control" id="category-select" name="category_id">
+ <option value="0">All</option>
+ {% for cur_category in user.categories %}
+ <option value="{{cur_category.id}}" {% if cur_category.id==category.id %}selected{% endif %}>{{ cur_category.name }}</option>
+ {% endfor %}
+ </select>
+ <button type="submit" class="btn btn-primary mb-2">OK</button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <br /><br />
+
+ {% if category %}
+ <div class="row">
+ <div class="col-md-8 offset-md-1">
+ <p class="lead">Articles from the category <a href="{{ url_for('user.profile_public', nickname=user.nickname, category_id=category.id) }}">{{ category.name }}</a></p>
+ </div>
+ </div>
+ {% endif %}
+
+ <div class="row">
+ <div class="col-md-8 offset-md-1">
+ {{ pagination.info }}
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-8 offset-md-1">
+ {{ pagination.links }}
+ </div>
+ </div>
+
+ <div class="table-responsive">
+ <table id="table-feeds" class="table table-striped">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>{{ _('Title') }}</th>
+ <th>{{ _('Published at') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for article in articles %}
+ <tr>
+ <td>{{ loop.index }}</td>
+ <td><a href="{{ url_for('article.article_pub', article_id=article.id) }}">{{ article.title }}</a></td>
+ <td>{{ article.date }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+
+ <div class="row">
+ <div class="col-md-8 offset-md-1">
+ {{ pagination.links }}
+ </div>
+ </div>
+</div><!-- /.container -->
+{% endblock %}
diff --git a/newspipe/web/translations/babel.cfg b/newspipe/web/translations/babel.cfg
new file mode 100644
index 00000000..f0234b32
--- /dev/null
+++ b/newspipe/web/translations/babel.cfg
@@ -0,0 +1,3 @@
+[python: **.py]
+[jinja2: **/templates/**.html]
+extensions=jinja2.ext.autoescape,jinja2.ext.with_
diff --git a/newspipe/web/translations/fr/LC_MESSAGES/messages.mo b/newspipe/web/translations/fr/LC_MESSAGES/messages.mo
new file mode 100644
index 00000000..87b48bb3
--- /dev/null
+++ b/newspipe/web/translations/fr/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/newspipe/web/translations/fr/LC_MESSAGES/messages.po b/newspipe/web/translations/fr/LC_MESSAGES/messages.po
new file mode 100644
index 00000000..be948d2e
--- /dev/null
+++ b/newspipe/web/translations/fr/LC_MESSAGES/messages.po
@@ -0,0 +1,1403 @@
+# French translations for PROJECT.
+# Copyright (C) 2014 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2014.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2018-10-15 10:14+0200\n"
+"PO-Revision-Date: 2018-10-15 10:14+0200\n"
+"Last-Translator: Cédric Bonhomme <cedric@cedricbonhomme.org>\n"
+"Language-Team: fr <LL@li.org>\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"Generated-By: Babel 1.3\n"
+"X-Generator: Poedit 2.0.6\n"
+
+#: ../forms.py:46 ../forms.py:130 ../forms.py:151
+#: ../templates/admin/dashboard.html:12
+msgid "Nickname"
+msgstr "Pseudonyme"
+
+#: ../forms.py:47 ../forms.py:96 ../forms.py:131 ../forms.py:152
+msgid "Please enter your nickname."
+msgstr "S'il vous plaît, entrez votre pseudonyme."
+
+#: ../forms.py:48
+msgid "Email"
+msgstr "Email"
+
+#: ../forms.py:51
+#, fuzzy
+#| msgid "Please enter your email address or nickname."
+msgid ""
+"Please enter your email address (only for account activation, won't be "
+"stored)."
+msgstr "S'il vous plaît, entrez votre adresse email ou pseudo."
+
+#: ../forms.py:52 ../forms.py:97 ../forms.py:132 ../forms.py:153
+msgid "Password"
+msgstr "Mot de passe"
+
+#: ../forms.py:53 ../forms.py:98
+msgid "Please enter a password."
+msgstr "S'il vous plaît entrer un mot de passe."
+
+#: ../forms.py:55 ../templates/login.html:26
+msgid "Sign up"
+msgstr "S'inscrire"
+
+#: ../forms.py:64 ../forms.py:140 ../forms.py:172
+msgid ""
+"This nickname has invalid characters. Please use letters, numbers, dots and "
+"underscores only."
+msgstr ""
+"Ce pseudonyme a des caractères non valides. Utilisez seulement des lettres, "
+"des chiffres, des points et '_'."
+
+#: ../forms.py:100 ../templates/login.html:5
+msgid "Log In"
+msgstr "Connexion"
+
+#: ../forms.py:133 ../forms.py:155
+msgid "Automatic crawling"
+msgstr "Récupération automatique"
+
+#: ../forms.py:135 ../forms.py:162 ../forms.py:185 ../forms.py:198
+#: ../forms.py:212
+msgid "Save"
+msgstr "Sauver"
+
+#: ../forms.py:154
+msgid "Password Confirmation"
+msgstr "Confirmation du mot de passe"
+
+#: ../forms.py:157
+msgid "Bio"
+msgstr "Bio"
+
+#: ../forms.py:158 ../templates/profile_public.html:18
+msgid "Webpage"
+msgstr "Page web"
+
+#: ../forms.py:159 ../templates/profile_public.html:24
+msgid "Twitter"
+msgstr "Twitter"
+
+#: ../forms.py:160
+msgid "Public profile"
+msgstr "Profil public"
+
+#: ../forms.py:167
+msgid "Passwords aren't the same."
+msgstr "Les mots de passe ne sont pas identiques."
+
+#: ../forms.py:180 ../forms.py:205 ../templates/feed_list.html:8
+#: ../templates/feed_list_simple.html:7
+msgid "Title"
+msgstr "Titre"
+
+#: ../forms.py:181
+msgid "Feed link"
+msgstr "Lien du flux"
+
+#: ../forms.py:182
+msgid "Please enter the URL."
+msgstr "S'il vous plaît, entrez une adresse."
+
+#: ../forms.py:183
+msgid "Site link"
+msgstr "Lien du site"
+
+#: ../forms.py:184
+msgid "Check for updates"
+msgstr "Vérifier les mises à jour"
+
+#: ../forms.py:186
+msgid "Category of the feed"
+msgstr "Catégorie du flux"
+
+#: ../forms.py:188
+msgid "Private"
+msgstr "Privé"
+
+#: ../forms.py:197 ../templates/layout.html:87
+msgid "Category name"
+msgstr "Nom de la catégorie"
+
+#: ../forms.py:202
+msgid "URL"
+msgstr "URL"
+
+#: ../forms.py:204
+msgid "Please enter an URL."
+msgstr "S'il vous plaît, entrez une adresse."
+
+#: ../forms.py:207
+msgid "Description"
+msgstr "Description"
+
+#: ../forms.py:209
+msgid "Tags"
+msgstr "Tags"
+
+#: ../forms.py:210
+msgid "To read"
+msgstr "À lire"
+
+#: ../forms.py:211
+msgid "Shared"
+msgstr "Partagé"
+
+#: ../forms.py:216
+msgid "Subject"
+msgstr "Objet"
+
+#: ../forms.py:217
+msgid "Please enter a subject."
+msgstr "S'il vous plaît entrer un objet."
+
+#: ../forms.py:218
+msgid "Message"
+msgstr "Message"
+
+#: ../forms.py:219
+msgid "Please enter a content."
+msgstr "S'il vous plaît entrer un contenu."
+
+#: ../forms.py:220
+msgid "Send"
+msgstr "Envoyer"
+
+#: ../templates/about.html:5 ../templates/layout.html:113
+#: ../templates/layout.html:125
+msgid "About"
+msgstr "À propos"
+
+#: ../templates/about.html:7
+msgid "Newspipe is a news aggregator platform."
+msgstr "Newspipe est un agrégateur de nouvelles Web."
+
+#: ../templates/about.html:8
+msgid ""
+"You can easily <a href=\"https://newspipe.readthedocs.io/en/latest/"
+"deployment.html\">install Newspipe on your server</a>."
+msgstr ""
+"Vous pouvez facilement <a href=\"https://newspipe.readthedocs.io/en/latest/"
+"deployment.html\">installer Newspipe sur votre serveur</a>."
+
+#: ../templates/about.html:9
+msgid "Alternatively, you can deploy your own copy using this button:"
+msgstr ""
+"Alternativement, vous pouvez déployer votre propre copie en utilisant ce "
+"bouton:"
+
+#: ../templates/about.html:11
+msgid ""
+"This software is under AGPLv3 license. You are welcome to copy, modify or\n"
+" redistribute the <a href=\"https://gitlab.com/newspipe/newspipe"
+"\">source code</a>\n"
+" according to the <a href=\"https://www.gnu.org/licenses/agpl-3.0.html"
+"\">Affero GPL</a> license."
+msgstr ""
+"Ce logiciel est sous licence AGPLv3. Vous êtes invité à copier, modifier ou "
+"redistribuer le <a href=\"https://gitlab.com/newspipe/newspipe\">code "
+"source</a> selon la licence <a href=\"https://www.gnu.org/licenses/agpl-3.0."
+"html\">Affero GPL</a>."
+
+#: ../templates/about.html:14
+msgid ""
+"Found a bug? Report it <a href=\"https://gitlab.com/newspipe/newspipe/issues"
+"\">here</a>."
+msgstr ""
+"Vous avez trouvé un bug? Signalez-le <a href=\"https://gitlab.com/newspipe/"
+"newspipe/issues\">ici</a>."
+
+#: ../templates/about.html:15
+msgid "More information"
+msgstr "Plus d'informations"
+
+#: ../templates/about.html:15
+msgid "about this instance."
+msgstr "à propos de cette instance."
+
+#: ../templates/about.html:18
+msgid "Help"
+msgstr "Aide"
+
+#: ../templates/about.html:19
+msgid ""
+"The documentation of the API is <a href=\"https://newspipe.readthedocs.io/en/"
+"latest/web-services-v3.html\">here</a>."
+msgstr ""
+"La documentation de l'API est <a href=\"https://newspipe.readthedocs.io/en/"
+"latest/web-services-v3.html\">ici</a>."
+
+#: ../templates/about.html:20
+msgid "Contact"
+msgstr "Contact"
+
+#: ../templates/about.html:21
+msgid ""
+"You can subscribe to new feeds with a bookmarklet. Drag the following button "
+"to your browser bookmarks."
+msgstr ""
+"Vous pouvez vous abonner à de nouveaux flux avec un bookmarklet. Faites "
+"glisser le bouton suivant dans vos favoris."
+
+#: ../templates/about.html:22
+#, python-format
+msgid ""
+"<a class=\"btn btn-default\" href=\"%(bookmarklet)s\" rel=\"bookmark"
+"\">Subscribe to this feed using Newspipe</a>"
+msgstr ""
+"<a class=\"btn btn-default\" href=\"%(bookmarklet)s\" rel=\"bookmark"
+"\">Abonnez-vous à ce flux en utilisant Newspipe</a>"
+
+#: ../templates/about_more.html:5
+msgid "Newspipe version"
+msgstr "Version de Newspipe"
+
+#: ../templates/about_more.html:6
+msgid "Running on Heroku"
+msgstr "Fonctionne sur Heroku"
+
+#: ../templates/about_more.html:7
+msgid "Registration"
+msgstr "Inscription"
+
+#: ../templates/about_more.html:8
+msgid "Python version"
+msgstr "Version de Python"
+
+#: ../templates/about_more.html:9
+msgid "Number of users"
+msgstr "Nombre d'utilisateurs"
+
+#: ../templates/article.html:6 ../templates/article_pub.html:6
+msgid "from"
+msgstr "de"
+
+#: ../templates/article.html:7 ../templates/duplicates.html:22
+#: ../templates/duplicates.html:23
+msgid "Delete this article"
+msgstr "Supprimer cet article"
+
+#: ../templates/article.html:9
+msgid "One of your favorites"
+msgstr "Un de vos favoris"
+
+#: ../templates/article.html:11
+msgid "Click if you like this article"
+msgstr "Cliquez si vous aimez cet article"
+
+#: ../templates/article.html:14
+msgid "Mark this article as unread"
+msgstr "Marquer cet article comme non lu"
+
+#: ../templates/article.html:16
+msgid "Mark this article as read"
+msgstr "Marquer cet article comme lu"
+
+#: ../templates/article.html:25 ../templates/article.html:28
+#: ../templates/article.html:31 ../templates/article_pub.html:14
+#: ../templates/article_pub.html:17 ../templates/article_pub.html:20
+msgid "Share on"
+msgstr "Partager sur"
+
+#: ../templates/categories.html:4
+#, python-format
+msgid ""
+"You have %(categories)d categories &middot; Add a %(start_link)scategory"
+"%(end_link)s"
+msgstr ""
+"Vous avez %(categories)d catégories &middot; Ajouter une "
+"%(start_link)scategorie%(end_link)s"
+
+#: ../templates/categories.html:6
+msgid "No category"
+msgstr "Aucune catégorie"
+
+#: ../templates/categories.html:13
+msgid "Name"
+msgstr "Nom de famille"
+
+#: ../templates/categories.html:14 ../templates/profile_public.html:38
+msgid "Feeds"
+msgstr "Flux"
+
+#: ../templates/categories.html:15 ../templates/feed_list.html:10
+msgid "Articles"
+msgstr "Articles"
+
+#: ../templates/admin/dashboard.html:15 ../templates/categories.html:16
+#: ../templates/feed_list.html:11
+msgid "Actions"
+msgstr "Actions"
+
+#: ../templates/categories.html:27
+msgid "Edit this category"
+msgstr "Éditer la catégorie"
+
+#: ../templates/categories.html:28
+msgid "Delete this category"
+msgstr "Supprimer cette catégorie"
+
+#: ../templates/categories.html:28
+msgid "You are going to delete this category."
+msgstr "Vous allez supprimer cette catégorie."
+
+#: ../templates/duplicates.html:4
+msgid "Duplicates in the feed"
+msgstr "Doublons dans le flux"
+
+#: ../templates/duplicates.html:11 ../templates/duplicates.html:14
+msgid "Delete all in this column"
+msgstr "Supprimer tout dans cette colonne"
+
+#: ../templates/edit_bookmark.html:65 ../templates/layout.html:59
+#: ../views/bookmark.py:119
+msgid "Add a new bookmark"
+msgstr "Ajouter un nouveau marque-page"
+
+#: ../templates/edit_bookmark.html:68
+msgid ""
+"You can add a bookmark with a bookmarklet. Drag the following button to your "
+"browser bookmarks."
+msgstr ""
+"Vous pouvez ajouter un marque-page avec un bookmarklet. Faites glisser le "
+"bouton suivant dans vos favoris."
+
+#: ../templates/edit_bookmark.html:69
+#, python-format
+msgid ""
+"<a class=\"btn btn-default\" href=\"%(bookmarklet)s\" rel=\"bookmark"
+"\">Bookmark this page using Newspipe</a>"
+msgstr ""
+"<a class=\"btn btn-default\" href=\"%(bookmarklet)s\" rel=\"bookmark"
+"\">Marquez cette page en utilisant Newspipe</a>"
+
+#: ../templates/edit_bookmark.html:73
+msgid "Import bookmarks from Pinboard"
+msgstr "Importez les marque-pages depuis Pinboard"
+
+#: ../templates/edit_feed.html:19 ../templates/edit_feed.html:27
+#: ../templates/edit_feed.html:35
+msgid "Optional"
+msgstr "Facultatif"
+
+#: ../templates/edit_feed.html:55
+#, python-format
+msgid ""
+"If checked, articles of this feed won't be available to others and the feed "
+"won't be listed on <a href='%(url)s'>your profile page</a>."
+msgstr ""
+"Si cette option est cochée, les articles de ce flux ne seront pas visibles "
+"aux autres et ne seront pas répertoriés sur <a href='%(url)s'>votre profil "
+"public</a>."
+
+#: ../templates/edit_feed.html:56
+msgid "Check this box if there is a private token in the link of the feed."
+msgstr "Cochez cette case si il ya un token privé dans le lien du flux."
+
+#: ../templates/edit_feed.html:62
+msgid "Filters"
+msgstr "Filtres"
+
+#: ../templates/edit_feed.html:73
+msgid "simple match"
+msgstr "correspondance exact"
+
+#: ../templates/edit_feed.html:74
+msgid "regex"
+msgstr "expression régulière"
+
+#: ../templates/edit_feed.html:78
+msgid "match"
+msgstr "correspond"
+
+#: ../templates/edit_feed.html:79
+msgid "no match"
+msgstr "ne correspond pas"
+
+#: ../templates/edit_feed.html:82
+msgid "mark as read"
+msgstr "Marquer cet article comme lu"
+
+#: ../templates/edit_feed.html:83
+msgid "mark as favorite"
+msgstr "Marquer comme favori"
+
+#: ../templates/feed.html:8 ../templates/feed_list.html:35
+msgid "Delete this feed"
+msgstr "Supprimer ce flux"
+
+#: ../templates/feed.html:8 ../templates/feed_list.html:35
+msgid "You are going to delete this feed."
+msgstr "Vous allez supprimer ce flux."
+
+#: ../templates/feed.html:9 ../templates/feed_list.html:33
+msgid "Edit this feed"
+msgstr "Éditer ce flux"
+
+#: ../templates/feed.html:14
+msgid "This feed contains"
+msgstr "Ce flux contient"
+
+#: ../templates/feed.html:14
+msgid "articles"
+msgstr "articles"
+
+#: ../templates/feed.html:16
+#, python-format
+msgid "This feed is part of category %(category_name)s"
+msgstr "Ce flux fait partie de la catégorie %(category_name)s"
+
+#: ../templates/feed.html:18
+msgid "Address of the feed"
+msgstr "Adresse du flux"
+
+#: ../templates/feed.html:20
+msgid "Address of the site"
+msgstr "Adresse du site"
+
+#: ../templates/feed.html:26
+msgid "Last download:"
+msgstr "Dernier téléchargement:"
+
+#: ../templates/feed.html:30
+msgid ""
+"That feed has encountered too much consecutive errors and won't be retrieved "
+"anymore."
+msgstr ""
+"Ce flux a rencontré trop d'erreurs consécutives et ne sera plus récupéré."
+
+#: ../templates/feed.html:31
+#, python-format
+msgid ""
+"You can click <a href='%(reset_error_url)s'>here</a> to reset the error "
+"count and reactivate the feed."
+msgstr ""
+"Vous pouvez cliquer <a href='%(reset_error_url)s'>ici</a> pour réinitialiser "
+"le nombre d'erreurs et réactiver le flux."
+
+#: ../templates/feed.html:33
+msgid ""
+"The download of this feed has encountered some problems. However its error "
+"counter will be reinitialized at the next successful retrieving."
+msgstr ""
+"Le téléchargement de ce flux a rencontré quelques problèmes. Cependant, son "
+"compteur d'erreurs sera réinitialisé lors de la prochaine récupération "
+"réussie."
+
+#: ../templates/feed.html:37
+msgid "Here's the last error encountered while retrieving this feed:"
+msgstr "Voici la dernière erreur survenue lors de la récupération ce flux:"
+
+#: ../templates/feed.html:41
+msgid "The last article was posted"
+msgstr "Le dernier article a été posté il y a"
+
+#: ../templates/feed.html:41
+msgid "day(s) ago."
+msgstr "jours."
+
+#: ../templates/feed.html:42
+msgid "Daily average"
+msgstr "Moyenne journalière"
+
+#: ../templates/feed.html:42
+msgid "between the"
+msgstr "entre le"
+
+#: ../templates/feed.html:42
+msgid "and the"
+msgstr "et le"
+
+#: ../templates/feed.html:53
+msgid "Article"
+msgstr "Article"
+
+#: ../templates/feed.html:54
+msgid "Date"
+msgstr "Date"
+
+#: ../templates/feed.html:69
+msgid "Most recurrent words"
+msgstr "Mots les plus récurrents"
+
+#: ../templates/feed_list.html:7
+msgid "Status"
+msgstr "Statut"
+
+#: ../templates/feed_list.html:9 ../templates/feed_list_simple.html:8
+msgid "Site"
+msgstr "Site"
+
+#: ../templates/feed_list.html:20
+msgid "Feed enabled"
+msgstr "Flux activé"
+
+#: ../templates/feed_list.html:22
+msgid "Feed disabled"
+msgstr "Flux désactivé"
+
+#: ../templates/feed_list.html:25
+msgid "Feed encountered too much errors."
+msgstr "Le flux a rencontré trop d'erreurs."
+
+#: ../templates/feed_list.html:32
+msgid "Information"
+msgstr "Information"
+
+#: ../templates/feed_list.html:34
+msgid "Duplicate articles"
+msgstr "Articles doublon"
+
+#: ../templates/feeds.html:4
+#, python-format
+msgid "You are subscribed to %(feed_count)d feeds."
+msgstr "Vous êtes abonné à %(feed_count)d flux."
+
+#: ../templates/feeds.html:4 ../templates/management.html:8
+msgid "Add"
+msgstr "Ajouter"
+
+#: ../templates/feeds.html:4 ../templates/management.html:8
+msgid "a feed"
+msgstr "un flux"
+
+#: ../templates/history.html:4 ../templates/layout.html:100
+msgid "History"
+msgstr "Historique"
+
+#: ../templates/history.html:9
+msgid "all years"
+msgstr "toutes les années"
+
+#: ../templates/inactives.html:6
+msgid "Days of inactivity"
+msgstr "Jours d'inactivité"
+
+#: ../templates/inactives.html:17
+msgid "days"
+msgstr "jours"
+
+#: ../templates/inactives.html:22
+msgid "No inactive feeds."
+msgstr "Aucun flux inactifs."
+
+#: ../templates/layout.html:51
+msgid "Fetch"
+msgstr "Télécharger"
+
+#: ../templates/layout.html:58 ../templates/management.html:14
+msgid "Your bookmarks"
+msgstr "Vos marque-pages"
+
+#: ../templates/layout.html:64
+msgid "Add a new feed"
+msgstr "Ajouter un nouveau flux"
+
+#: ../templates/layout.html:70
+msgid "Site or feed url"
+msgstr "Site ou adresse de flux"
+
+#: ../templates/layout.html:81
+msgid "Add a new category"
+msgstr "Ajouter une nouvelle catégorie"
+
+#: ../templates/layout.html:97
+msgid "Feed"
+msgstr "Flux"
+
+#: ../templates/layout.html:99
+msgid "Inactive"
+msgstr "Flux inactifs"
+
+#: ../templates/layout.html:101
+msgid "All"
+msgstr "Tout"
+
+#: ../templates/layout.html:103
+msgid "Popular"
+msgstr "Populaire"
+
+#: ../templates/layout.html:111
+msgid "Profile"
+msgstr "Profil"
+
+#: ../templates/layout.html:112
+msgid "Your data"
+msgstr "Vos données"
+
+#: ../templates/layout.html:116
+msgid "Dashboard"
+msgstr "Tableau de bord"
+
+#: ../templates/layout.html:119
+msgid "Logout"
+msgstr "Déconnexion"
+
+#: ../templates/layout.html:123 ../views/bookmark.py:87
+msgid "Recent bookmarks"
+msgstr "Marque-pages récents"
+
+#: ../templates/layout.html:124 ../templates/popular.html:4
+msgid "Popular feeds"
+msgstr "Flux populaires"
+
+#: ../templates/login.html:10 ../templates/signup.html:8
+#, fuzzy
+#| msgid "Your email or nickname"
+msgid "Your nickname"
+msgstr "Votre email ou pseudo"
+
+#: ../templates/login.html:17
+msgid "Your Password"
+msgstr "Votre mot de passe"
+
+#: ../templates/management.html:7
+msgid "Your subscriptions"
+msgstr "Vos abonnements"
+
+#: ../templates/management.html:8
+msgid "You are subscribed to"
+msgstr "Vous êtes abonné à"
+
+#: ../templates/management.html:8
+msgid "feeds"
+msgstr "flux"
+
+#: ../templates/management.html:9
+msgid "articles are stored in the database with"
+msgstr "articles sont stockés dans la base avec"
+
+#: ../templates/management.html:9
+msgid "unread articles"
+msgstr "articles non lus"
+
+#: ../templates/management.html:10 ../templates/management.html:15
+msgid "You have"
+msgstr "Vous avez"
+
+#: ../templates/management.html:10
+msgid "categories"
+msgstr "categories"
+
+#: ../templates/management.html:11
+msgid "You are going to delete old articles."
+msgstr "Vous allez supprimer les anciens articles."
+
+#: ../templates/management.html:11
+msgid "Delete articles older than 10 weeks"
+msgstr "Supprimer les articles de plus de 10 semaines"
+
+#: ../templates/management.html:15
+msgid "bookmarks"
+msgstr "marque-pages"
+
+#: ../templates/management.html:16
+msgid "You are going to delete all bookmarks."
+msgstr "Vous allez supprimer tous les marque-pages."
+
+#: ../templates/management.html:16
+msgid "Delete all bookmarks"
+msgstr "Supprimez tous les marque-pages"
+
+#: ../templates/management.html:23
+msgid "Data liberation"
+msgstr "Libération des données"
+
+#: ../templates/management.html:25
+msgid "Import a Newspipe account"
+msgstr "Importer un compte Newspipe"
+
+#: ../templates/management.html:29
+msgid "Export your Newspipe account to JSON"
+msgstr "Exporter le compte Newspipe au format JSON"
+
+#: ../templates/management.html:31
+msgid "Export your bookmarks to JSON"
+msgstr "Exporter les bookmarks au format JSON"
+
+#: ../templates/management.html:34
+msgid "OPML import/export"
+msgstr "Import/export OPML"
+
+#: ../templates/management.html:36
+msgid "Batch import feeds from OPML"
+msgstr "Import en lot via un fichier OPML"
+
+#: ../templates/management.html:36
+msgid "or"
+msgstr "ou"
+
+#: ../templates/management.html:40
+msgid "Export your feeds to OPML"
+msgstr "Exporter les flux au format OPML"
+
+#: ../templates/popular.html:11
+msgid "add this feed"
+msgstr "ajouter ce flux"
+
+#: ../templates/profile.html:4
+msgid "Your Profile"
+msgstr "Votre profil"
+
+#: ../templates/admin/dashboard.html:13 ../templates/profile.html:7
+#: ../templates/profile_public.html:9
+msgid "Member since"
+msgstr "Membre depuis le"
+
+#: ../templates/admin/dashboard.html:14 ../templates/profile.html:8
+#: ../templates/profile_public.html:13
+msgid "Last seen"
+msgstr "Vue la dernière fois le"
+
+#: ../templates/profile.html:44
+#, python-format
+msgid "Your profile will be available <a href=\"%(url)s\">here</a>."
+msgstr "Votre profil sera accessible <a href=\"%(url)s\">ici</a>."
+
+#: ../templates/profile.html:48
+msgid "Uncheck if you are using your own crawler."
+msgstr "Décochez si vous utilisez votre propre crawler."
+
+#: ../templates/profile.html:61
+msgid "You are going to delete your account."
+msgstr "Vous allez supprimer votre compte."
+
+#: ../templates/profile.html:61
+msgid "Delete your account"
+msgstr "Supprimer votre compte"
+
+#: ../templates/signup.html:9
+msgid "Letters, numbers, dots and underscores only."
+msgstr "Lettres, chiffres, points et '_' seulement."
+
+#: ../templates/signup.html:12
+msgid "Your email"
+msgstr "Votre email"
+
+#: ../templates/signup.html:13
+msgid "Only for account activation. Your email won't be stored."
+msgstr ""
+
+#: ../templates/signup.html:16
+#, fuzzy
+#| msgid "Your Password"
+msgid "Your password"
+msgstr "Votre mot de passe"
+
+#: ../templates/signup.html:17
+msgid "Minimum 6 characters."
+msgstr "Minimum 6 caractères."
+
+#: ../templates/admin/dashboard.html:7
+msgid "Registered users"
+msgstr "Utilisateurs enregistrés"
+
+#: ../templates/admin/dashboard.html:33
+msgid "Edit this user"
+msgstr "Éditer cet utilisateur"
+
+#: ../templates/admin/dashboard.html:37
+msgid "Disable this account"
+msgstr "Désactiver ce comptes"
+
+#: ../templates/admin/dashboard.html:39
+msgid "Enable this account"
+msgstr "Activer ce compte"
+
+#: ../templates/admin/dashboard.html:42
+msgid "Delete this user"
+msgstr "Supprimer cet utilisateur"
+
+#: ../templates/admin/dashboard.html:42
+msgid "You are going to delete this account."
+msgstr "Vous allez supprimer ce compte."
+
+#: ../templates/admin/dashboard.html:49 ../views/admin.py:40
+msgid "Add a new user"
+msgstr "Ajouter un nouvel utilisateur"
+
+#: ../views/admin.py:37
+#, python-format
+msgid "Edit the user <i>%(nick)s</i>"
+msgstr "Éditer l'utilisateur <i>%(nick)s</i>"
+
+#: ../views/admin.py:58
+msgid "Some errors were found"
+msgstr "Des erreurs ont été trouvées"
+
+#: ../views/admin.py:67 ../views/user.py:125
+#, python-format
+msgid "User %(nick)s successfully updated"
+msgstr "Utilisateur %(nick)s mis à jour avec succès."
+
+#: ../views/admin.py:76
+#, python-format
+msgid "User %(nick)s successfully created"
+msgstr "Utilisateur %(nick)s créé avec succès."
+
+#: ../views/admin.py:90
+#, python-format
+msgid "User %(nick)s successfully deleted"
+msgstr "Utilisateur %(nick)s supprimé avec succès."
+
+#: ../views/admin.py:94
+#, python-format
+msgid "An error occurred while trying to delete a user: %(error)s"
+msgstr ""
+"Une erreur est apparue lors de la suppression de l'utilisateur: %(error)s"
+
+#: ../views/admin.py:112
+msgid "This user does not exist."
+msgstr "Cet utilisateur n'existe pas."
+
+#: ../views/admin.py:117
+#, python-format
+msgid "User %(nickname)s successfully %(is_active)s"
+msgstr "Utilisateur %(nickname)s %(is_active)s avec succès."
+
+#: ../views/article.py:77
+#, python-format
+msgid "Article %(article_title)s deleted"
+msgstr "Article %(article_title)s supprimé."
+
+#: ../views/article.py:134
+#, python-format
+msgid "%(count)d articles deleted"
+msgstr "%(count)d articles supprimés."
+
+#: ../views/article.py:150
+msgid "Error when exporting articles."
+msgstr "Erreur lors de l'export des articles."
+
+#: ../views/article.py:166
+msgid "Export format not supported."
+msgstr "Ce format d'export n'est pas supporté."
+
+#: ../views/bookmark.py:58
+msgid "Bookmarks"
+msgstr "Marque-pages"
+
+#: ../views/bookmark.py:125
+msgid "Edit bookmark"
+msgstr "Modifiez le marque-page"
+
+#: ../views/bookmark.py:165
+msgid "Bookmark successfully updated."
+msgstr "Marque-page mis à jour avec succès."
+
+#: ../views/bookmark.py:177 ../views/bookmark.py:229
+msgid "Bookmark successfully created."
+msgstr "Marque-page créé avec succès."
+
+#: ../views/bookmark.py:186
+#, python-format
+msgid "Bookmark %(bookmark_name)s successfully deleted."
+msgstr "Marque-page %(bookmark_name)s supprimé avec succès."
+
+#: ../views/bookmark.py:197
+msgid "Bookmarks successfully deleted."
+msgstr "Marque-pages supprimés avec succès."
+
+#: ../views/bookmark.py:208
+msgid "Couldn't add bookmark: url missing."
+msgstr "Impossible d'ajouter le marque-page: URL manquante."
+
+#: ../views/bookmark.py:217
+msgid "Couldn't add bookmark: bookmark already exists."
+msgstr "Impossible d'ajouter le marque-page: l'URL existe déjà."
+
+#: ../views/bookmark.py:240
+#, python-format
+msgid "%(nb_bookmarks)s bookmarks successfully imported."
+msgstr "%(nb_bookmarks)s marque-pages importés avec succès."
+
+#: ../views/bookmark.py:243
+msgid "Error when importing bookmarks."
+msgstr "Erreur lors de l'import des marque-pages."
+
+#: ../views/category.py:33
+msgid "Add a category"
+msgstr "Ajouter une catégorie"
+
+#: ../views/category.py:39
+msgid "Edit category"
+msgstr "Éditer la catégorie"
+
+#: ../views/category.py:52
+#, python-format
+msgid "Category %(category_name)s successfully deleted."
+msgstr "Catégorie %(category_name)s supprimée avec succès."
+
+#: ../views/category.py:68
+msgid "Couldn't add category: already exists."
+msgstr "Impossible d'ajouter la catégorie: le catégorie existe déjà."
+
+#: ../views/category.py:76
+#, python-format
+msgid "Category %(cat_name)r successfully updated."
+msgstr "Catégorie %(cat_name)r mise à jour avec succès."
+
+#: ../views/category.py:83
+#, python-format
+msgid "Category %(category_name)r successfully created."
+msgstr "Catégorie %(category_name)r créée avec succès."
+
+#: ../views/feed.py:98
+#, python-format
+msgid "Feed %(feed_title)s successfully deleted."
+msgstr "Flux %(feed_title)s supprimé avec succès."
+
+#: ../views/feed.py:109 ../views/feed.py:226
+#, python-format
+msgid "Feed %(feed_title)r successfully updated."
+msgstr "Flux %(feed_title)r mis à jour avec succès."
+
+#: ../views/feed.py:121
+msgid "Couldn't add feed: url missing."
+msgstr "Impossible d'ajouter le flux: URL manquante."
+
+#: ../views/feed.py:126 ../views/feed.py:207
+msgid "Couldn't add feed: feed already exists."
+msgstr "Impossible d'ajouter le flux: le flux existe déjà."
+
+#: ../views/feed.py:133
+msgid "Impossible to connect to the address: {}."
+msgstr "Impossible de se connecter à l'adresse: {}."
+
+#: ../views/feed.py:141
+msgid ""
+"Couldn't find a feed url, you'll need to find a Atom or RSS link manually "
+"and reactivate this feed"
+msgstr ""
+"Impossible de trouver une URL de flux, vous devez trouver un lien RSS ou "
+"Atom manuellement et réactiver ce flux"
+
+#: ../views/feed.py:145
+msgid "Feed was successfully created."
+msgstr "Flux créé avec succès."
+
+#: ../views/feed.py:148 ../views/feed.py:238
+msgid "Downloading articles for the new feed..."
+msgstr "Téléchargement des articles du nouveau flux..."
+
+#: ../views/feed.py:166
+msgid "Feed successfully updated."
+msgstr "Flux mis à jour avec succès."
+
+#: ../views/feed.py:175
+msgid "Add a feed"
+msgstr "Ajouter un flux"
+
+#: ../views/feed.py:186
+msgid "Edit feed"
+msgstr "Éditez ce flux"
+
+#: ../views/feed.py:233
+#, python-format
+msgid "Feed %(feed_title)r successfully created."
+msgstr "Flux %(feed_title)r créé avec succès."
+
+#: ../views/feed.py:263
+msgid "No duplicates in the feed \"{}\"."
+msgstr "Pas de doublon dans le flux \"{}\"."
+
+#: ../views/home.py:168 ../views/user.py:68
+msgid "Downloading articles..."
+msgstr "Téléchargement des articles."
+
+#: ../views/home.py:170
+msgid ""
+"The manual retrieving of news is only available for administrator, on the "
+"Heroku platform."
+msgstr ""
+"La récupération manuelle de nouvelles est disponible uniquement pour "
+"l'administrateur, sur la plate-forme Heroku."
+
+#: ../views/session_mgmt.py:28
+msgid "Please log in to access this page."
+msgstr "S'il vous plaît connectez vous pour accéder à cette page."
+
+#: ../views/session_mgmt.py:90
+msgid "Self-registration is disabled."
+msgstr "L'auto-enregistrement est désactivé."
+
+#: ../views/session_mgmt.py:104
+#, python-format
+msgid "Problem while sending activation email: %(error)s"
+msgstr "Problème lors de l'envoi d'email d'activation: %(error)s"
+
+#: ../views/session_mgmt.py:108
+msgid "Your account has been created. Check your mail to confirm it."
+msgstr "Votre compte a été créé. Vérifiez votre courrier pour le confirmer."
+
+#: ../views/user.py:32
+msgid "You must set your profile to public."
+msgstr "Vous devez mettre votre profil en public."
+
+#: ../views/user.py:60 ../views/user.py:76 ../views/user.py:85
+msgid "File not allowed."
+msgstr "Fichier non autorisé."
+
+#: ../views/user.py:66
+msgid "feeds imported."
+msgstr "flux importés."
+
+#: ../views/user.py:70
+msgid "Impossible to import the new feeds."
+msgstr "Impossible d'importer les nouveaux flux."
+
+#: ../views/user.py:80
+msgid "Account imported."
+msgstr "Compte importé."
+
+#: ../views/user.py:82
+msgid "Impossible to import the account."
+msgstr "Impossible d'importer le compte."
+
+#: ../views/user.py:122
+#, python-format
+msgid "Problem while updating your profile: %(error)s"
+msgstr "Problème lors de la mise à jour de votre profil: %(error)s"
+
+#: ../views/user.py:143
+msgid "Your account has been deleted."
+msgstr "Votre compte a été supprimé."
+
+#: ../views/user.py:160
+msgid "Your account has been confirmed."
+msgstr "Votre compte a été confirmé."
+
+#: ../views/user.py:162
+msgid "Impossible to confirm this account."
+msgstr "Impossible de confirmer ce compte."
+
+#: ../views/views.py:23
+msgid "Authentication required."
+msgstr "Authentification requise."
+
+#: ../views/views.py:31
+msgid "Forbidden."
+msgstr "Interdit."
+
+#~ msgid "Please enter your email address."
+#~ msgstr "S'il vous plaît, entrez votre adresse email."
+
+#~ msgid "Please enter your email."
+#~ msgstr "S'il vous plaît, entrez votre email."
+
+#~ msgid "Recover"
+#~ msgstr "Récupérer"
+
+#~ msgid "Account not confirmed."
+#~ msgstr "Compte non confirmé."
+
+#~ msgid "Invalid email."
+#~ msgstr "Email invalide."
+
+#~ msgid "Forgot password"
+#~ msgstr "Mot de passe oublié"
+
+#~ msgid "Recover your account"
+#~ msgstr "Récupérer votre compte"
+
+#~ msgid "Send notification messages"
+#~ msgstr "Envoyer des messages de notification"
+
+#~ msgid "New password sent to your address."
+#~ msgstr "Nouveau mot de passe envoyé à votre adresse."
+
+#~ msgid "Problem while sending your new password: %(error)s"
+#~ msgstr "Problème lors de l'envoi de votre nouveau mot de passe: %(error)s"
+
+#~ msgid "Feeds refresh frequency (in minutes)"
+#~ msgstr "Fréquence de rafraîchissement du flux (en minutes)"
+
+#~ msgid "Export articles"
+#~ msgstr "Exporter les articles"
+
+#~ msgid ""
+#~ "If you have any problem, <a href=\"https://wiki.cedricbonhomme.org/contact"
+#~ "\">contact</a> the administrator."
+#~ msgstr ""
+#~ "Si vous avez des problèmes, <a href=\"https://wiki.cedricbonhomme.org/"
+#~ "contact\">contactez</a> l'administrateur."
+
+#~ msgid "Last seen:"
+#~ msgstr "Vue la dernière fois le"
+
+#~ msgid "Member since:"
+#~ msgstr "Membre depuis le:"
+
+#~ msgid "Webpage:"
+#~ msgstr "Page web:"
+
+#~ msgid "Donation"
+#~ msgstr "Don"
+
+#~ msgid ""
+#~ "If you wish and if you like Newspipe, you can donate via bitcoin <a href="
+#~ "\"https://blockexplorer.com/address/1GVmhR9fbBeEh7rP1qNq76jWArDdDQ3otZ"
+#~ "\">1GVmhR9fbBeEh7rP1qNq76jWArDdDQ3otZ</a>. Thank you!"
+#~ msgstr ""
+#~ "Si vous le souhaitez et si vous aimez Newspipe, vous pouvez faire un don "
+#~ "via bitcoin <a href=\"https://blockexplorer.com/"
+#~ "address/1GVmhR9fbBeEh7rP1qNq76jWArDdDQ3otZ"
+#~ "\">1GVmhR9fbBeEh7rP1qNq76jWArDdDQ3otZ</a>. Merci!"
+
+#~ msgid "Next post:"
+#~ msgstr "Billet suivant:"
+
+#~ msgid "Previous post:"
+#~ msgstr "Billet précédent:"
+
+#~ msgid "Submit"
+#~ msgstr "Soumettre"
+
+#~ msgid "Account not confirmed"
+#~ msgstr "Compte non confirmé"
+
+#~ msgid "Invalid email or password"
+#~ msgstr "E-mail ou mot de passe invalide"
+
+#~ msgid "No feed"
+#~ msgstr "Aucun flux"
+
+#~ msgid "Add a"
+#~ msgstr "Ajouter un"
+
+#~ msgid "feed"
+#~ msgstr "flux"
+
+#~ msgid "You don't have any feeds."
+#~ msgstr "Vous n'avez pas de flux."
+
+#~ msgid "Add some"
+#~ msgstr "Ajoutez en"
+
+#~ msgid "upload an OPML file."
+#~ msgstr "téléchargez un fichier OPML."
+
+#~ msgid "All feeds"
+#~ msgstr "Tous les flux"
+
+#~ msgid "error"
+#~ msgstr "erreur"
+
+#~ msgid "Details"
+#~ msgstr "Détails"
+
+#~ msgid "Mark this feed as read"
+#~ msgstr "Marquer ce flux comme lu"
+
+#~ msgid "Mark this feed as unread"
+#~ msgstr "Marquer ce flux comme non lu"
+
+#~ msgid "Read"
+#~ msgstr "Lus"
+
+#~ msgid "No icon found for this feed"
+#~ msgstr "Aucune icône trouvé pour ce flux."
+
+#~ msgid "Home"
+#~ msgstr "Accueil"
+
+#~ msgid "Favorites"
+#~ msgstr "Favoris"
+
+#~ msgid "Mark all as read"
+#~ msgstr "Marquer tout comme lu"
+
+#~ msgid "Mark all as read older than yesterday"
+#~ msgstr "Marquer tout comme lu plus ancien qu'hier"
+
+#~ msgid "Mark all as read older than %(days)s days"
+#~ msgstr "Marquer tout comme lu plus ancien que %(days)s jours"
+
+#~ msgid "Search"
+#~ msgstr "Recherche"
+
+#~ msgid "View this user"
+#~ msgstr "Voir cet utilisateur"
+
+#~ msgid "Membership"
+#~ msgstr "Adhésion"
+
+#~ msgid "Logged out successfully."
+#~ msgstr "Déconnecté avec succès."
+
+#~ msgid "Email already used."
+#~ msgstr "Email déjà utilisé."
+
+#~ msgid "No text to search were provided."
+#~ msgstr "Aucun texte à chercher a été soumis."
+
+#~ msgid "Search:"
+#~ msgstr "Recherche:"
+
+#~ msgid "deleted."
+#~ msgstr "supprimé."
+
+#~ msgid "This article do not exist."
+#~ msgstr "Cet article n'existe pas."
+
+#~ msgid "User"
+#~ msgstr "Utilisateur"
+
+#~ msgid "Problem while sending email"
+#~ msgstr "Problème lors de l'envoi de l'email"
+
+#~ msgid "successfully deleted."
+#~ msgstr "supprimé avec succès."
+
+#~ msgid "Account of the user"
+#~ msgstr "Compte de l'utilisateur"
+
+#~ msgid "successfully activated."
+#~ msgstr "activé avec succès."
+
+#~ msgid "Problem while sending activation email"
+#~ msgstr "Problème lors de l'envoi d'email d'activation"
+
+#~ msgid "successfully disabled."
+#~ msgstr "désactivé avec succès."
+
+#~ msgid "You can easily install Newspipe on your server."
+#~ msgstr "Vous pouvez facilement installer Newspipe sur votre serveur."
+
+#~ msgid "This user is not subscribed to any feed."
+#~ msgstr "Cet utilisateur n'est pas encore abonné à des flux."
+
+#~ msgid "(unread) articles"
+#~ msgstr "(non lus) articles"
+
+#~ msgid "Logged in successfully."
+#~ msgstr "Connecté avec succès."
+
+#~ msgid "Index database"
+#~ msgstr "Indexer la base de données"
+
+#~ msgid "Indexing database..."
+#~ msgstr "Indexation la base de données..."
+
+#~ msgid "An error occurred"
+#~ msgstr "Une erreur est survenue."
+
+#~ msgid "Option not available on Heroku."
+#~ msgstr "Option non disponible sur Heroku."
+
+#~ msgid "Full text search is not yet implemented for Heroku."
+#~ msgstr "La recherche rapide n'est pas supporté sur Heroku."
+
+#~ msgid "Favorites articles"
+#~ msgstr "Articles favoris"
+
+#~ msgid "More articles"
+#~ msgstr "Plus d'articles"
+
+#~ msgid "Management"
+#~ msgstr "Gestion"
+
+#~ msgid "Update your"
+#~ msgstr "Mettez à jour votre"
+
+#~ msgid "No unread articles"
+#~ msgstr "Pas d'articles non lus"
+
+#~ msgid "Unread articles"
+#~ msgstr "Articles non lus"
+
+#~ msgid "Mark all feed as read"
+#~ msgstr "Marquer tout comme lu"
+
+#~ msgid "Mark all feed as unread"
+#~ msgstr "Marquer tout comme non lu"
+
+#~ msgid "Feed already in the database."
+#~ msgstr "Flux déjà dans la base de données."
+
+#~ msgid "of the database"
+#~ msgstr "de la base de données"
+
+#~ msgid "Email notification"
+#~ msgstr "Notification par email"
+
+#~ msgid "Database indexed."
+#~ msgstr "Base de données indexée."
+
+#~ msgid "Oh!"
+#~ msgstr "Oh!"
+
+#~ msgid "This feed is empty."
+#~ msgstr "Ce flux est vide."
+
+#~ msgid "Work for you!"
+#~ msgstr "Du travail pour toi!"
+
+#~ msgid "All articles are unread."
+#~ msgstr "Tous les articles sont non lus."
+
+#~ msgid "Well done!"
+#~ msgstr "Bien joué!"
+
+#~ msgid "You read all articles."
+#~ msgstr "Tu as lu tous les articles."
+
+#~ msgid "Please enter a link for the feed."
+#~ msgstr "S'il vous plaît, entrez un lien pour le flux."
+
+#~ msgid "Articles marked as read."
+#~ msgstr "Articles marqués comme lus."
+
+#~ msgid "Fetch this feed"
+#~ msgstr "Récupérer ce flux"
+
+#~ msgid "Import"
+#~ msgstr "Importer"
+
+#~ msgid "Export"
+#~ msgstr "Exporter"
+
+#~ msgid "Fix this."
+#~ msgstr "Résolvez ce problème."
+
+#~ msgid "As an administrator you are not listed in this table"
+#~ msgstr "En tant qu'administrateur vous ne figurez pas dans ce tableau"
+
+#~ msgid "Activation key:"
+#~ msgstr "Clé d'activation:"
+
+#~ msgid "First name"
+#~ msgstr "Prénom"
+
+#~ msgid "Please enter your last name."
+#~ msgstr "S'il vous plaît, entrez votre nom de famille."
+
+#~ msgid ""
+#~ "This lastname has invalid characters. Please use letters, numbers, dots "
+#~ "and underscores only."
+#~ msgstr ""
+#~ "Ce nom de famille a des caractères non valides. S'il vous plaît utiliser "
+#~ "des lettres, des chiffres, des points et '_' seulement."
+
+#~ msgid "Firstname"
+#~ msgstr "Prénom"
+
+#~ msgid "Lastname"
+#~ msgstr "Nom de famille"
+
+#~ msgid "Account creation"
+#~ msgstr "Ouverture de compte"
+
+#~ msgid "Request an account."
+#~ msgstr "Demander un compte."
diff --git a/newspipe/web/translations/internationalization.sh b/newspipe/web/translations/internationalization.sh
new file mode 100755
index 00000000..51efa840
--- /dev/null
+++ b/newspipe/web/translations/internationalization.sh
@@ -0,0 +1,4 @@
+#! /bin/sh
+
+pybabel extract -F babel.cfg -k lazy_gettext -o messages.pot ..
+poedit fr/LC_MESSAGES/messages.po
diff --git a/newspipe/web/translations/messages.pot b/newspipe/web/translations/messages.pot
new file mode 100644
index 00000000..871417c8
--- /dev/null
+++ b/newspipe/web/translations/messages.pot
@@ -0,0 +1,1050 @@
+# Translations template for PROJECT.
+# Copyright (C) 2018 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2018-10-15 10:14+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.5.3\n"
+
+#: ../forms.py:46 ../forms.py:130 ../forms.py:151
+#: ../templates/admin/dashboard.html:12
+msgid "Nickname"
+msgstr ""
+
+#: ../forms.py:47 ../forms.py:96 ../forms.py:131 ../forms.py:152
+msgid "Please enter your nickname."
+msgstr ""
+
+#: ../forms.py:48
+msgid "Email"
+msgstr ""
+
+#: ../forms.py:51
+msgid ""
+"Please enter your email address (only for account activation, won't be "
+"stored)."
+msgstr ""
+
+#: ../forms.py:52 ../forms.py:97 ../forms.py:132 ../forms.py:153
+msgid "Password"
+msgstr ""
+
+#: ../forms.py:53 ../forms.py:98
+msgid "Please enter a password."
+msgstr ""
+
+#: ../forms.py:55 ../templates/login.html:26
+msgid "Sign up"
+msgstr ""
+
+#: ../forms.py:64 ../forms.py:140 ../forms.py:172
+msgid ""
+"This nickname has invalid characters. Please use letters, numbers, dots "
+"and underscores only."
+msgstr ""
+
+#: ../forms.py:100 ../templates/login.html:5
+msgid "Log In"
+msgstr ""
+
+#: ../forms.py:133 ../forms.py:155
+msgid "Automatic crawling"
+msgstr ""
+
+#: ../forms.py:135 ../forms.py:162 ../forms.py:185 ../forms.py:198
+#: ../forms.py:212
+msgid "Save"
+msgstr ""
+
+#: ../forms.py:154
+msgid "Password Confirmation"
+msgstr ""
+
+#: ../forms.py:157
+msgid "Bio"
+msgstr ""
+
+#: ../forms.py:158 ../templates/profile_public.html:18
+msgid "Webpage"
+msgstr ""
+
+#: ../forms.py:159 ../templates/profile_public.html:24
+msgid "Twitter"
+msgstr ""
+
+#: ../forms.py:160
+msgid "Public profile"
+msgstr ""
+
+#: ../forms.py:167
+msgid "Passwords aren't the same."
+msgstr ""
+
+#: ../forms.py:180 ../forms.py:205 ../templates/feed_list.html:8
+#: ../templates/feed_list_simple.html:7
+msgid "Title"
+msgstr ""
+
+#: ../forms.py:181
+msgid "Feed link"
+msgstr ""
+
+#: ../forms.py:182
+msgid "Please enter the URL."
+msgstr ""
+
+#: ../forms.py:183
+msgid "Site link"
+msgstr ""
+
+#: ../forms.py:184
+msgid "Check for updates"
+msgstr ""
+
+#: ../forms.py:186
+msgid "Category of the feed"
+msgstr ""
+
+#: ../forms.py:188
+msgid "Private"
+msgstr ""
+
+#: ../forms.py:197 ../templates/layout.html:87
+msgid "Category name"
+msgstr ""
+
+#: ../forms.py:202
+msgid "URL"
+msgstr ""
+
+#: ../forms.py:204
+msgid "Please enter an URL."
+msgstr ""
+
+#: ../forms.py:207
+msgid "Description"
+msgstr ""
+
+#: ../forms.py:209
+msgid "Tags"
+msgstr ""
+
+#: ../forms.py:210
+msgid "To read"
+msgstr ""
+
+#: ../forms.py:211
+msgid "Shared"
+msgstr ""
+
+#: ../forms.py:216
+msgid "Subject"
+msgstr ""
+
+#: ../forms.py:217
+msgid "Please enter a subject."
+msgstr ""
+
+#: ../forms.py:218
+msgid "Message"
+msgstr ""
+
+#: ../forms.py:219
+msgid "Please enter a content."
+msgstr ""
+
+#: ../forms.py:220
+msgid "Send"
+msgstr ""
+
+#: ../templates/about.html:5 ../templates/layout.html:113
+#: ../templates/layout.html:125
+msgid "About"
+msgstr ""
+
+#: ../templates/about.html:7
+msgid "Newspipe is a news aggregator platform."
+msgstr ""
+
+#: ../templates/about.html:8
+msgid ""
+"You can easily <a "
+"href=\"https://newspipe.readthedocs.io/en/latest/deployment.html\">install"
+" Newspipe on your server</a>."
+msgstr ""
+
+#: ../templates/about.html:9
+msgid "Alternatively, you can deploy your own copy using this button:"
+msgstr ""
+
+#: ../templates/about.html:11
+msgid ""
+"This software is under AGPLv3 license. You are welcome to copy, modify or"
+"\n"
+" redistribute the <a "
+"href=\"https://gitlab.com/newspipe/newspipe\">source code</a>\n"
+" according to the <a "
+"href=\"https://www.gnu.org/licenses/agpl-3.0.html\">Affero GPL</a> "
+"license."
+msgstr ""
+
+#: ../templates/about.html:14
+msgid ""
+"Found a bug? Report it <a "
+"href=\"https://gitlab.com/newspipe/newspipe/issues\">here</a>."
+msgstr ""
+
+#: ../templates/about.html:15
+msgid "More information"
+msgstr ""
+
+#: ../templates/about.html:15
+msgid "about this instance."
+msgstr ""
+
+#: ../templates/about.html:18
+msgid "Help"
+msgstr ""
+
+#: ../templates/about.html:19
+msgid ""
+"The documentation of the API is <a "
+"href=\"https://newspipe.readthedocs.io/en/latest/web-"
+"services-v3.html\">here</a>."
+msgstr ""
+
+#: ../templates/about.html:20
+msgid "Contact"
+msgstr ""
+
+#: ../templates/about.html:21
+msgid ""
+"You can subscribe to new feeds with a bookmarklet. Drag the following "
+"button to your browser bookmarks."
+msgstr ""
+
+#: ../templates/about.html:22
+#, python-format
+msgid ""
+"<a class=\"btn btn-default\" href=\"%(bookmarklet)s\" "
+"rel=\"bookmark\">Subscribe to this feed using Newspipe</a>"
+msgstr ""
+
+#: ../templates/about_more.html:5
+msgid "Newspipe version"
+msgstr ""
+
+#: ../templates/about_more.html:6
+msgid "Running on Heroku"
+msgstr ""
+
+#: ../templates/about_more.html:7
+msgid "Registration"
+msgstr ""
+
+#: ../templates/about_more.html:8
+msgid "Python version"
+msgstr ""
+
+#: ../templates/about_more.html:9
+msgid "Number of users"
+msgstr ""
+
+#: ../templates/article.html:6 ../templates/article_pub.html:6
+msgid "from"
+msgstr ""
+
+#: ../templates/article.html:7 ../templates/duplicates.html:22
+#: ../templates/duplicates.html:23
+msgid "Delete this article"
+msgstr ""
+
+#: ../templates/article.html:9
+msgid "One of your favorites"
+msgstr ""
+
+#: ../templates/article.html:11
+msgid "Click if you like this article"
+msgstr ""
+
+#: ../templates/article.html:14
+msgid "Mark this article as unread"
+msgstr ""
+
+#: ../templates/article.html:16
+msgid "Mark this article as read"
+msgstr ""
+
+#: ../templates/article.html:25 ../templates/article.html:28
+#: ../templates/article.html:31 ../templates/article_pub.html:14
+#: ../templates/article_pub.html:17 ../templates/article_pub.html:20
+msgid "Share on"
+msgstr ""
+
+#: ../templates/categories.html:4
+#, python-format
+msgid ""
+"You have %(categories)d categories &middot; Add a "
+"%(start_link)scategory%(end_link)s"
+msgstr ""
+
+#: ../templates/categories.html:6
+msgid "No category"
+msgstr ""
+
+#: ../templates/categories.html:13
+msgid "Name"
+msgstr ""
+
+#: ../templates/categories.html:14 ../templates/profile_public.html:38
+msgid "Feeds"
+msgstr ""
+
+#: ../templates/categories.html:15 ../templates/feed_list.html:10
+msgid "Articles"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:15 ../templates/categories.html:16
+#: ../templates/feed_list.html:11
+msgid "Actions"
+msgstr ""
+
+#: ../templates/categories.html:27
+msgid "Edit this category"
+msgstr ""
+
+#: ../templates/categories.html:28
+msgid "Delete this category"
+msgstr ""
+
+#: ../templates/categories.html:28
+msgid "You are going to delete this category."
+msgstr ""
+
+#: ../templates/duplicates.html:4
+msgid "Duplicates in the feed"
+msgstr ""
+
+#: ../templates/duplicates.html:11 ../templates/duplicates.html:14
+msgid "Delete all in this column"
+msgstr ""
+
+#: ../templates/edit_bookmark.html:65 ../templates/layout.html:59
+#: ../views/bookmark.py:119
+msgid "Add a new bookmark"
+msgstr ""
+
+#: ../templates/edit_bookmark.html:68
+msgid ""
+"You can add a bookmark with a bookmarklet. Drag the following button to "
+"your browser bookmarks."
+msgstr ""
+
+#: ../templates/edit_bookmark.html:69
+#, python-format
+msgid ""
+"<a class=\"btn btn-default\" href=\"%(bookmarklet)s\" "
+"rel=\"bookmark\">Bookmark this page using Newspipe</a>"
+msgstr ""
+
+#: ../templates/edit_bookmark.html:73
+msgid "Import bookmarks from Pinboard"
+msgstr ""
+
+#: ../templates/edit_feed.html:19 ../templates/edit_feed.html:27
+#: ../templates/edit_feed.html:35
+msgid "Optional"
+msgstr ""
+
+#: ../templates/edit_feed.html:55
+#, python-format
+msgid ""
+"If checked, articles of this feed won't be available to others and the "
+"feed won't be listed on <a href='%(url)s'>your profile page</a>."
+msgstr ""
+
+#: ../templates/edit_feed.html:56
+msgid "Check this box if there is a private token in the link of the feed."
+msgstr ""
+
+#: ../templates/edit_feed.html:62
+msgid "Filters"
+msgstr ""
+
+#: ../templates/edit_feed.html:73
+msgid "simple match"
+msgstr ""
+
+#: ../templates/edit_feed.html:74
+msgid "regex"
+msgstr ""
+
+#: ../templates/edit_feed.html:78
+msgid "match"
+msgstr ""
+
+#: ../templates/edit_feed.html:79
+msgid "no match"
+msgstr ""
+
+#: ../templates/edit_feed.html:82
+msgid "mark as read"
+msgstr ""
+
+#: ../templates/edit_feed.html:83
+msgid "mark as favorite"
+msgstr ""
+
+#: ../templates/feed.html:8 ../templates/feed_list.html:35
+msgid "Delete this feed"
+msgstr ""
+
+#: ../templates/feed.html:8 ../templates/feed_list.html:35
+msgid "You are going to delete this feed."
+msgstr ""
+
+#: ../templates/feed.html:9 ../templates/feed_list.html:33
+msgid "Edit this feed"
+msgstr ""
+
+#: ../templates/feed.html:14
+msgid "This feed contains"
+msgstr ""
+
+#: ../templates/feed.html:14
+msgid "articles"
+msgstr ""
+
+#: ../templates/feed.html:16
+#, python-format
+msgid "This feed is part of category %(category_name)s"
+msgstr ""
+
+#: ../templates/feed.html:18
+msgid "Address of the feed"
+msgstr ""
+
+#: ../templates/feed.html:20
+msgid "Address of the site"
+msgstr ""
+
+#: ../templates/feed.html:26
+msgid "Last download:"
+msgstr ""
+
+#: ../templates/feed.html:30
+msgid ""
+"That feed has encountered too much consecutive errors and won't be "
+"retrieved anymore."
+msgstr ""
+
+#: ../templates/feed.html:31
+#, python-format
+msgid ""
+"You can click <a href='%(reset_error_url)s'>here</a> to reset the error "
+"count and reactivate the feed."
+msgstr ""
+
+#: ../templates/feed.html:33
+msgid ""
+"The download of this feed has encountered some problems. However its "
+"error counter will be reinitialized at the next successful retrieving."
+msgstr ""
+
+#: ../templates/feed.html:37
+msgid "Here's the last error encountered while retrieving this feed:"
+msgstr ""
+
+#: ../templates/feed.html:41
+msgid "The last article was posted"
+msgstr ""
+
+#: ../templates/feed.html:41
+msgid "day(s) ago."
+msgstr ""
+
+#: ../templates/feed.html:42
+msgid "Daily average"
+msgstr ""
+
+#: ../templates/feed.html:42
+msgid "between the"
+msgstr ""
+
+#: ../templates/feed.html:42
+msgid "and the"
+msgstr ""
+
+#: ../templates/feed.html:53
+msgid "Article"
+msgstr ""
+
+#: ../templates/feed.html:54
+msgid "Date"
+msgstr ""
+
+#: ../templates/feed.html:69
+msgid "Most recurrent words"
+msgstr ""
+
+#: ../templates/feed_list.html:7
+msgid "Status"
+msgstr ""
+
+#: ../templates/feed_list.html:9 ../templates/feed_list_simple.html:8
+msgid "Site"
+msgstr ""
+
+#: ../templates/feed_list.html:20
+msgid "Feed enabled"
+msgstr ""
+
+#: ../templates/feed_list.html:22
+msgid "Feed disabled"
+msgstr ""
+
+#: ../templates/feed_list.html:25
+msgid "Feed encountered too much errors."
+msgstr ""
+
+#: ../templates/feed_list.html:32
+msgid "Information"
+msgstr ""
+
+#: ../templates/feed_list.html:34
+msgid "Duplicate articles"
+msgstr ""
+
+#: ../templates/feeds.html:4
+#, python-format
+msgid "You are subscribed to %(feed_count)d feeds."
+msgstr ""
+
+#: ../templates/feeds.html:4 ../templates/management.html:8
+msgid "Add"
+msgstr ""
+
+#: ../templates/feeds.html:4 ../templates/management.html:8
+msgid "a feed"
+msgstr ""
+
+#: ../templates/history.html:4 ../templates/layout.html:100
+msgid "History"
+msgstr ""
+
+#: ../templates/history.html:9
+msgid "all years"
+msgstr ""
+
+#: ../templates/inactives.html:6
+msgid "Days of inactivity"
+msgstr ""
+
+#: ../templates/inactives.html:17
+msgid "days"
+msgstr ""
+
+#: ../templates/inactives.html:22
+msgid "No inactive feeds."
+msgstr ""
+
+#: ../templates/layout.html:51
+msgid "Fetch"
+msgstr ""
+
+#: ../templates/layout.html:58 ../templates/management.html:14
+msgid "Your bookmarks"
+msgstr ""
+
+#: ../templates/layout.html:64
+msgid "Add a new feed"
+msgstr ""
+
+#: ../templates/layout.html:70
+msgid "Site or feed url"
+msgstr ""
+
+#: ../templates/layout.html:81
+msgid "Add a new category"
+msgstr ""
+
+#: ../templates/layout.html:97
+msgid "Feed"
+msgstr ""
+
+#: ../templates/layout.html:99
+msgid "Inactive"
+msgstr ""
+
+#: ../templates/layout.html:101
+msgid "All"
+msgstr ""
+
+#: ../templates/layout.html:103
+msgid "Popular"
+msgstr ""
+
+#: ../templates/layout.html:111
+msgid "Profile"
+msgstr ""
+
+#: ../templates/layout.html:112
+msgid "Your data"
+msgstr ""
+
+#: ../templates/layout.html:116
+msgid "Dashboard"
+msgstr ""
+
+#: ../templates/layout.html:119
+msgid "Logout"
+msgstr ""
+
+#: ../templates/layout.html:123 ../views/bookmark.py:87
+msgid "Recent bookmarks"
+msgstr ""
+
+#: ../templates/layout.html:124 ../templates/popular.html:4
+msgid "Popular feeds"
+msgstr ""
+
+#: ../templates/login.html:10 ../templates/signup.html:8
+msgid "Your nickname"
+msgstr ""
+
+#: ../templates/login.html:17
+msgid "Your Password"
+msgstr ""
+
+#: ../templates/management.html:7
+msgid "Your subscriptions"
+msgstr ""
+
+#: ../templates/management.html:8
+msgid "You are subscribed to"
+msgstr ""
+
+#: ../templates/management.html:8
+msgid "feeds"
+msgstr ""
+
+#: ../templates/management.html:9
+msgid "articles are stored in the database with"
+msgstr ""
+
+#: ../templates/management.html:9
+msgid "unread articles"
+msgstr ""
+
+#: ../templates/management.html:10 ../templates/management.html:15
+msgid "You have"
+msgstr ""
+
+#: ../templates/management.html:10
+msgid "categories"
+msgstr ""
+
+#: ../templates/management.html:11
+msgid "You are going to delete old articles."
+msgstr ""
+
+#: ../templates/management.html:11
+msgid "Delete articles older than 10 weeks"
+msgstr ""
+
+#: ../templates/management.html:15
+msgid "bookmarks"
+msgstr ""
+
+#: ../templates/management.html:16
+msgid "You are going to delete all bookmarks."
+msgstr ""
+
+#: ../templates/management.html:16
+msgid "Delete all bookmarks"
+msgstr ""
+
+#: ../templates/management.html:23
+msgid "Data liberation"
+msgstr ""
+
+#: ../templates/management.html:25
+msgid "Import a Newspipe account"
+msgstr ""
+
+#: ../templates/management.html:29
+msgid "Export your Newspipe account to JSON"
+msgstr ""
+
+#: ../templates/management.html:31
+msgid "Export your bookmarks to JSON"
+msgstr ""
+
+#: ../templates/management.html:34
+msgid "OPML import/export"
+msgstr ""
+
+#: ../templates/management.html:36
+msgid "Batch import feeds from OPML"
+msgstr ""
+
+#: ../templates/management.html:36
+msgid "or"
+msgstr ""
+
+#: ../templates/management.html:40
+msgid "Export your feeds to OPML"
+msgstr ""
+
+#: ../templates/popular.html:11
+msgid "add this feed"
+msgstr ""
+
+#: ../templates/profile.html:4
+msgid "Your Profile"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:13 ../templates/profile.html:7
+#: ../templates/profile_public.html:9
+msgid "Member since"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:14 ../templates/profile.html:8
+#: ../templates/profile_public.html:13
+msgid "Last seen"
+msgstr ""
+
+#: ../templates/profile.html:44
+#, python-format
+msgid "Your profile will be available <a href=\"%(url)s\">here</a>."
+msgstr ""
+
+#: ../templates/profile.html:48
+msgid "Uncheck if you are using your own crawler."
+msgstr ""
+
+#: ../templates/profile.html:61
+msgid "You are going to delete your account."
+msgstr ""
+
+#: ../templates/profile.html:61
+msgid "Delete your account"
+msgstr ""
+
+#: ../templates/signup.html:9
+msgid "Letters, numbers, dots and underscores only."
+msgstr ""
+
+#: ../templates/signup.html:12
+msgid "Your email"
+msgstr ""
+
+#: ../templates/signup.html:13
+msgid "Only for account activation. Your email won't be stored."
+msgstr ""
+
+#: ../templates/signup.html:16
+msgid "Your password"
+msgstr ""
+
+#: ../templates/signup.html:17
+msgid "Minimum 6 characters."
+msgstr ""
+
+#: ../templates/admin/dashboard.html:7
+msgid "Registered users"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:33
+msgid "Edit this user"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:37
+msgid "Disable this account"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:39
+msgid "Enable this account"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:42
+msgid "Delete this user"
+msgstr ""
+
+#: ../templates/admin/dashboard.html:42
+msgid "You are going to delete this account."
+msgstr ""
+
+#: ../templates/admin/dashboard.html:49 ../views/admin.py:40
+msgid "Add a new user"
+msgstr ""
+
+#: ../views/admin.py:37
+#, python-format
+msgid "Edit the user <i>%(nick)s</i>"
+msgstr ""
+
+#: ../views/admin.py:58
+msgid "Some errors were found"
+msgstr ""
+
+#: ../views/admin.py:67 ../views/user.py:125
+#, python-format
+msgid "User %(nick)s successfully updated"
+msgstr ""
+
+#: ../views/admin.py:76
+#, python-format
+msgid "User %(nick)s successfully created"
+msgstr ""
+
+#: ../views/admin.py:90
+#, python-format
+msgid "User %(nick)s successfully deleted"
+msgstr ""
+
+#: ../views/admin.py:94
+#, python-format
+msgid "An error occurred while trying to delete a user: %(error)s"
+msgstr ""
+
+#: ../views/admin.py:112
+msgid "This user does not exist."
+msgstr ""
+
+#: ../views/admin.py:117
+#, python-format
+msgid "User %(nickname)s successfully %(is_active)s"
+msgstr ""
+
+#: ../views/article.py:77
+#, python-format
+msgid "Article %(article_title)s deleted"
+msgstr ""
+
+#: ../views/article.py:134
+#, python-format
+msgid "%(count)d articles deleted"
+msgstr ""
+
+#: ../views/article.py:150
+msgid "Error when exporting articles."
+msgstr ""
+
+#: ../views/article.py:166
+msgid "Export format not supported."
+msgstr ""
+
+#: ../views/bookmark.py:58
+msgid "Bookmarks"
+msgstr ""
+
+#: ../views/bookmark.py:125
+msgid "Edit bookmark"
+msgstr ""
+
+#: ../views/bookmark.py:165
+msgid "Bookmark successfully updated."
+msgstr ""
+
+#: ../views/bookmark.py:177 ../views/bookmark.py:229
+msgid "Bookmark successfully created."
+msgstr ""
+
+#: ../views/bookmark.py:186
+#, python-format
+msgid "Bookmark %(bookmark_name)s successfully deleted."
+msgstr ""
+
+#: ../views/bookmark.py:197
+msgid "Bookmarks successfully deleted."
+msgstr ""
+
+#: ../views/bookmark.py:208
+msgid "Couldn't add bookmark: url missing."
+msgstr ""
+
+#: ../views/bookmark.py:217
+msgid "Couldn't add bookmark: bookmark already exists."
+msgstr ""
+
+#: ../views/bookmark.py:240
+#, python-format
+msgid "%(nb_bookmarks)s bookmarks successfully imported."
+msgstr ""
+
+#: ../views/bookmark.py:243
+msgid "Error when importing bookmarks."
+msgstr ""
+
+#: ../views/category.py:33
+msgid "Add a category"
+msgstr ""
+
+#: ../views/category.py:39
+msgid "Edit category"
+msgstr ""
+
+#: ../views/category.py:52
+#, python-format
+msgid "Category %(category_name)s successfully deleted."
+msgstr ""
+
+#: ../views/category.py:68
+msgid "Couldn't add category: already exists."
+msgstr ""
+
+#: ../views/category.py:76
+#, python-format
+msgid "Category %(cat_name)r successfully updated."
+msgstr ""
+
+#: ../views/category.py:83
+#, python-format
+msgid "Category %(category_name)r successfully created."
+msgstr ""
+
+#: ../views/feed.py:98
+#, python-format
+msgid "Feed %(feed_title)s successfully deleted."
+msgstr ""
+
+#: ../views/feed.py:109 ../views/feed.py:226
+#, python-format
+msgid "Feed %(feed_title)r successfully updated."
+msgstr ""
+
+#: ../views/feed.py:121
+msgid "Couldn't add feed: url missing."
+msgstr ""
+
+#: ../views/feed.py:126 ../views/feed.py:207
+msgid "Couldn't add feed: feed already exists."
+msgstr ""
+
+#: ../views/feed.py:133
+msgid "Impossible to connect to the address: {}."
+msgstr ""
+
+#: ../views/feed.py:141
+msgid ""
+"Couldn't find a feed url, you'll need to find a Atom or RSS link manually"
+" and reactivate this feed"
+msgstr ""
+
+#: ../views/feed.py:145
+msgid "Feed was successfully created."
+msgstr ""
+
+#: ../views/feed.py:148 ../views/feed.py:238
+msgid "Downloading articles for the new feed..."
+msgstr ""
+
+#: ../views/feed.py:166
+msgid "Feed successfully updated."
+msgstr ""
+
+#: ../views/feed.py:175
+msgid "Add a feed"
+msgstr ""
+
+#: ../views/feed.py:186
+msgid "Edit feed"
+msgstr ""
+
+#: ../views/feed.py:233
+#, python-format
+msgid "Feed %(feed_title)r successfully created."
+msgstr ""
+
+#: ../views/feed.py:263
+msgid "No duplicates in the feed \"{}\"."
+msgstr ""
+
+#: ../views/home.py:168 ../views/user.py:68
+msgid "Downloading articles..."
+msgstr ""
+
+#: ../views/home.py:170
+msgid ""
+"The manual retrieving of news is only available for administrator, on the"
+" Heroku platform."
+msgstr ""
+
+#: ../views/session_mgmt.py:28
+msgid "Please log in to access this page."
+msgstr ""
+
+#: ../views/session_mgmt.py:90
+msgid "Self-registration is disabled."
+msgstr ""
+
+#: ../views/session_mgmt.py:104
+#, python-format
+msgid "Problem while sending activation email: %(error)s"
+msgstr ""
+
+#: ../views/session_mgmt.py:108
+msgid "Your account has been created. Check your mail to confirm it."
+msgstr ""
+
+#: ../views/user.py:32
+msgid "You must set your profile to public."
+msgstr ""
+
+#: ../views/user.py:60 ../views/user.py:76 ../views/user.py:85
+msgid "File not allowed."
+msgstr ""
+
+#: ../views/user.py:66
+msgid "feeds imported."
+msgstr ""
+
+#: ../views/user.py:70
+msgid "Impossible to import the new feeds."
+msgstr ""
+
+#: ../views/user.py:80
+msgid "Account imported."
+msgstr ""
+
+#: ../views/user.py:82
+msgid "Impossible to import the account."
+msgstr ""
+
+#: ../views/user.py:122
+#, python-format
+msgid "Problem while updating your profile: %(error)s"
+msgstr ""
+
+#: ../views/user.py:143
+msgid "Your account has been deleted."
+msgstr ""
+
+#: ../views/user.py:160
+msgid "Your account has been confirmed."
+msgstr ""
+
+#: ../views/user.py:162
+msgid "Impossible to confirm this account."
+msgstr ""
+
+#: ../views/views.py:23
+msgid "Authentication required."
+msgstr ""
+
+#: ../views/views.py:31
+msgid "Forbidden."
+msgstr ""
+
diff --git a/newspipe/web/views/__init__.py b/newspipe/web/views/__init__.py
new file mode 100644
index 00000000..41bb52f3
--- /dev/null
+++ b/newspipe/web/views/__init__.py
@@ -0,0 +1,23 @@
+from web.views.api import v2, v3
+from web.views import views, home, session_mgmt
+from web.views.article import article_bp, articles_bp
+from web.views.feed import feed_bp, feeds_bp
+from web.views.category import category_bp, categories_bp
+from web.views.icon import icon_bp
+from web.views.admin import admin_bp
+from web.views.user import user_bp, users_bp
+from web.views.bookmark import bookmark_bp, bookmarks_bp
+
+__all__ = ['views', 'home', 'session_mgmt', 'v2', 'v3',
+ 'article_bp', 'articles_bp', 'feed_bp', 'feeds_bp',
+ 'category_bp', 'categories_bp', 'icon_bp',
+ 'admin_bp', 'user_bp', 'users_bp', 'bookmark_bp', 'bookmarks_bp']
+
+import conf
+from flask import request
+from flask import g
+
+
+@g.babel.localeselector
+def get_locale():
+ return request.accept_languages.best_match(conf.LANGUAGES.keys())
diff --git a/newspipe/web/views/admin.py b/newspipe/web/views/admin.py
new file mode 100644
index 00000000..73b2b668
--- /dev/null
+++ b/newspipe/web/views/admin.py
@@ -0,0 +1,119 @@
+from datetime import datetime
+from flask import (Blueprint, render_template, redirect, flash, url_for)
+from flask_babel import gettext, format_timedelta
+from flask_login import login_required, current_user
+
+from lib.utils import redirect_url
+from web.views.common import admin_permission
+from web.controllers import UserController
+from web.forms import InformationMessageForm, UserForm
+
+admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
+
+
+@admin_bp.route('/dashboard', methods=['GET', 'POST'])
+@login_required
+@admin_permission.require(http_exception=403)
+def dashboard():
+ last_cons, now = {}, datetime.utcnow()
+ users = list(UserController().read().order_by('id'))
+ form = InformationMessageForm()
+ for user in users:
+ last_cons[user.id] = format_timedelta(now - user.last_seen)
+ return render_template('admin/dashboard.html', now=datetime.utcnow(),
+ last_cons=last_cons, users=users, current_user=current_user,
+ form=form)
+
+
+@admin_bp.route('/user/create', methods=['GET'])
+@admin_bp.route('/user/edit/<int:user_id>', methods=['GET'])
+@login_required
+@admin_permission.require(http_exception=403)
+def user_form(user_id=None):
+ if user_id is not None:
+ user = UserController().get(id=user_id)
+ form = UserForm(obj=user)
+ message = gettext('Edit the user <i>%(nick)s</i>', nick=user.nickname)
+ else:
+ form = UserForm()
+ message = gettext('Add a new user')
+ return render_template('/admin/create_user.html',
+ form=form, message=message)
+
+
+@admin_bp.route('/user/create', methods=['POST'])
+@admin_bp.route('/user/edit/<int:user_id>', methods=['POST'])
+@login_required
+@admin_permission.require(http_exception=403)
+def process_user_form(user_id=None):
+ """
+ Create or edit a user.
+ """
+ form = UserForm()
+ user_contr = UserController()
+
+ if not form.validate():
+ return render_template('/admin/create_user.html', form=form,
+ message=gettext('Some errors were found'))
+
+ if user_id is not None:
+ # Edit a user
+ user_contr.update({'id': user_id},
+ {'nickname': form.nickname.data,
+ 'password': form.password.data,
+ 'automatic_crawling': form.automatic_crawling.data})
+ user = user_contr.get(id=user_id)
+ flash(gettext('User %(nick)s successfully updated',
+ nick=user.nickname), 'success')
+ else:
+ # Create a new user (by the admin)
+ user = user_contr.create(nickname=form.nickname.data,
+ password=form.password.data,
+ automatic_crawling=form.automatic_crawling.data,
+ is_admin=False,
+ is_active=True)
+ flash(gettext('User %(nick)s successfully created',
+ nick=user.nickname), 'success')
+ return redirect(url_for('admin.user_form', user_id=user.id))
+
+
+@admin_bp.route('/delete_user/<int:user_id>', methods=['GET'])
+@login_required
+@admin_permission.require(http_exception=403)
+def delete_user(user_id=None):
+ """
+ Delete a user (with all its data).
+ """
+ try:
+ user = UserController().delete(user_id)
+ flash(gettext('User %(nick)s successfully deleted',
+ nick=user.nickname), 'success')
+ except Exception as error:
+ flash(
+ gettext('An error occurred while trying to delete a user: %(error)s',
+ error=error), 'danger')
+ return redirect(url_for('admin.dashboard'))
+
+
+@admin_bp.route('/toggle_user/<int:user_id>', methods=['GET'])
+@login_required
+@admin_permission.require()
+def toggle_user(user_id=None):
+ """
+ Enable or disable the account of a user.
+ """
+ ucontr = UserController()
+ user = ucontr.get(id=user_id)
+ user_changed = ucontr.update({'id': user_id},
+ {'is_active': not user.is_active})
+
+ if not user_changed:
+ flash(gettext('This user does not exist.'), 'danger')
+ return redirect(url_for('admin.dashboard'))
+
+ else:
+ act_txt = 'activated' if user.is_active else 'desactivated'
+ message = gettext('User %(nickname)s successfully %(is_active)s',
+ nickname=user.nickname, is_active=act_txt)
+ flash(message, 'success')
+ return redirect(url_for('admin.dashboard'))
diff --git a/newspipe/web/views/api/__init__.py b/newspipe/web/views/api/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/newspipe/web/views/api/__init__.py
diff --git a/newspipe/web/views/api/v2/__init__.py b/newspipe/web/views/api/v2/__init__.py
new file mode 100644
index 00000000..46760261
--- /dev/null
+++ b/newspipe/web/views/api/v2/__init__.py
@@ -0,0 +1,3 @@
+from web.views.api.v2 import article, feed, category
+
+__all__ = ['article', 'feed', 'category']
diff --git a/newspipe/web/views/api/v2/article.py b/newspipe/web/views/api/v2/article.py
new file mode 100644
index 00000000..2be286c6
--- /dev/null
+++ b/newspipe/web/views/api/v2/article.py
@@ -0,0 +1,53 @@
+from conf import API_ROOT
+import dateutil.parser
+from datetime import datetime
+from flask import current_app
+from flask_restful import Api
+
+from web.views.common import api_permission
+from web.controllers import ArticleController
+from web.views.api.v2.common import (PyAggAbstractResource,
+ PyAggResourceNew, PyAggResourceExisting, PyAggResourceMulti)
+
+
+class ArticleNewAPI(PyAggResourceNew):
+ controller_cls = ArticleController
+
+
+class ArticleAPI(PyAggResourceExisting):
+ controller_cls = ArticleController
+
+
+class ArticlesAPI(PyAggResourceMulti):
+ controller_cls = ArticleController
+
+
+class ArticlesChallenge(PyAggAbstractResource):
+ controller_cls = ArticleController
+ attrs = {'ids': {'type': list, 'default': []}}
+
+ @api_permission.require(http_exception=403)
+ def get(self):
+ parsed_args = self.reqparse_args(right='read')
+ # collecting all attrs for casting purpose
+ attrs = self.controller_cls._get_attrs_desc('admin')
+ for id_dict in parsed_args['ids']:
+ keys_to_ignore = []
+ for key in id_dict:
+ if key not in attrs:
+ keys_to_ignore.append(key)
+ if issubclass(attrs[key]['type'], datetime):
+ id_dict[key] = dateutil.parser.parse(id_dict[key])
+ for key in keys_to_ignore:
+ del id_dict[key]
+
+ result = list(self.controller.challenge(parsed_args['ids']))
+ return result or None, 200 if result else 204
+
+api = Api(current_app, prefix=API_ROOT)
+
+api.add_resource(ArticleNewAPI, '/article', endpoint='article_new.json')
+api.add_resource(ArticleAPI, '/article/<int:obj_id>', endpoint='article.json')
+api.add_resource(ArticlesAPI, '/articles', endpoint='articles.json')
+api.add_resource(ArticlesChallenge, '/articles/challenge',
+ endpoint='articles_challenge.json')
diff --git a/newspipe/web/views/api/v2/category.py b/newspipe/web/views/api/v2/category.py
new file mode 100644
index 00000000..70fda1ea
--- /dev/null
+++ b/newspipe/web/views/api/v2/category.py
@@ -0,0 +1,27 @@
+from conf import API_ROOT
+from flask import current_app
+from flask_restful import Api
+
+from web.controllers.category import CategoryController
+from web.views.api.v2.common import (PyAggResourceNew,
+ PyAggResourceExisting,
+ PyAggResourceMulti)
+
+
+class CategoryNewAPI(PyAggResourceNew):
+ controller_cls = CategoryController
+
+
+class CategoryAPI(PyAggResourceExisting):
+ controller_cls = CategoryController
+
+
+class CategoriesAPI(PyAggResourceMulti):
+ controller_cls = CategoryController
+
+
+api = Api(current_app, prefix=API_ROOT)
+api.add_resource(CategoryNewAPI, '/category', endpoint='category_new.json')
+api.add_resource(CategoryAPI, '/category/<int:obj_id>',
+ endpoint='category.json')
+api.add_resource(CategoriesAPI, '/categories', endpoint='categories.json')
diff --git a/newspipe/web/views/api/v2/common.py b/newspipe/web/views/api/v2/common.py
new file mode 100644
index 00000000..8a53d7e6
--- /dev/null
+++ b/newspipe/web/views/api/v2/common.py
@@ -0,0 +1,222 @@
+"""For a given resources, classes in the module intend to create the following
+routes :
+ GET resource/<id>
+ -> to retrieve one
+ POST resource
+ -> to create one
+ PUT resource/<id>
+ -> to update one
+ DELETE resource/<id>
+ -> to delete one
+
+ GET resources
+ -> to retrieve several
+ POST resources
+ -> to create several
+ PUT resources
+ -> to update several
+ DELETE resources
+ -> to delete several
+"""
+import ast
+import logging
+from functools import wraps
+from werkzeug.exceptions import Unauthorized, BadRequest, Forbidden, NotFound
+from flask import request
+from flask_restful import Resource, reqparse
+from flask_login import current_user
+
+from web.views.common import admin_permission, api_permission, \
+ login_user_bundle, jsonify
+from web.controllers import UserController
+
+logger = logging.getLogger(__name__)
+
+
+def authenticate(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if request.authorization:
+ ucontr = UserController()
+ try:
+ user = ucontr.get(nickname=request.authorization.username)
+ except NotFound:
+ raise Forbidden("Couldn't authenticate your user")
+ if not ucontr.check_password(user, request.authorization.password):
+ raise Forbidden("Couldn't authenticate your user")
+ if not user.is_active:
+ raise Forbidden("User is deactivated")
+ login_user_bundle(user)
+ if current_user.is_authenticated:
+ return func(*args, **kwargs)
+ raise Unauthorized()
+ return wrapper
+
+
+class PyAggAbstractResource(Resource):
+ method_decorators = [authenticate, jsonify]
+ controller_cls = None
+ attrs = None
+
+ @property
+ def controller(self):
+ if admin_permission.can():
+ return self.controller_cls()
+ return self.controller_cls(current_user.id)
+
+ def reqparse_args(self, right, req=None, strict=False, default=True,
+ allow_empty=False):
+ """
+ strict: bool
+ if True will throw 400 error if args are defined and not in request
+ default: bool
+ if True, won't return defaults
+ args: dict
+ the args to parse, if None, self.attrs will be used
+ """
+ try:
+ if req:
+ in_values = req.json
+ else:
+ in_values = request.args or request.json or {}
+ if not in_values and allow_empty:
+ return {}
+ except BadRequest:
+ if allow_empty:
+ return {}
+ raise
+ parser = reqparse.RequestParser()
+ if self.attrs is not None:
+ attrs = self.attrs
+ elif admin_permission.can():
+ attrs = self.controller_cls._get_attrs_desc('admin')
+ elif api_permission.can():
+ attrs = self.controller_cls._get_attrs_desc('api', right)
+ else:
+ attrs = self.controller_cls._get_attrs_desc('base', right)
+ assert attrs, "No defined attrs for %s" % self.__class__.__name__
+
+ for attr_name, attr in attrs.items():
+ if not default and attr_name not in in_values:
+ continue
+ else:
+ parser.add_argument(attr_name, location='json',
+ default=in_values[attr_name])
+ return parser.parse_args(req=request.args, strict=strict)
+
+
+class PyAggResourceNew(PyAggAbstractResource):
+
+ @api_permission.require(http_exception=403)
+ def post(self):
+ """Create a single new object"""
+ return self.controller.create(**self.reqparse_args(right='write')), 201
+
+
+class PyAggResourceExisting(PyAggAbstractResource):
+
+ def get(self, obj_id=None):
+ """Retrieve a single object"""
+ return self.controller.get(id=obj_id)
+
+ def put(self, obj_id=None):
+ """update an object, new attrs should be passed in the payload"""
+ args = self.reqparse_args(right='write', default=False)
+ if not args:
+ raise BadRequest()
+ return self.controller.update({'id': obj_id}, args), 200
+
+ def delete(self, obj_id=None):
+ """delete a object"""
+ self.controller.delete(obj_id)
+ return None, 204
+
+
+class PyAggResourceMulti(PyAggAbstractResource):
+
+ def get(self):
+ """retrieve several objects. filters can be set in the payload on the
+ different fields of the object, and a limit can be set in there as well
+ """
+ args = {}
+ try:
+ limit = request.json.pop('limit', 10)
+ order_by = request.json.pop('order_by', None)
+ except Exception:
+ args = self.reqparse_args(right='read', default=False)
+ limit = request.args.get('limit', 10)
+ order_by = request.args.get('order_by', None)
+ query = self.controller.read(**args)
+ if order_by:
+ query = query.order_by(order_by)
+ if limit:
+ query = query.limit(limit)
+ return [res for res in query]
+
+ @api_permission.require(http_exception=403)
+ def post(self):
+ """creating several objects. payload should be:
+ >>> payload
+ [{attr1: val1, attr2: val2}, {attr1: val1, attr2: val2}]
+ """
+ status, fail_count, results = 200, 0, []
+
+ class Proxy:
+ pass
+ for attrs in request.json:
+ try:
+ Proxy.json = attrs
+ args = self.reqparse_args('write', req=Proxy, default=False)
+ obj = self.controller.create(**args)
+ results.append(obj)
+ except Exception as error:
+ fail_count += 1
+ results.append(str(error))
+ if fail_count == len(results): # all failed => 500
+ status = 500
+ elif fail_count: # some failed => 206
+ status = 206
+ return results, status
+
+ def put(self):
+ """updating several objects. payload should be:
+ >>> payload
+ [[obj_id1, {attr1: val1, attr2: val2}]
+ [obj_id2, {attr1: val1, attr2: val2}]]
+ """
+ status, results = 200, []
+
+ class Proxy:
+ pass
+ for obj_id, attrs in request.json:
+ try:
+ Proxy.json = attrs
+ args = self.reqparse_args('write', req=Proxy, default=False)
+ result = self.controller.update({'id': obj_id}, args)
+ if result:
+ results.append('ok')
+ else:
+ results.append('nok')
+ except Exception as error:
+ results.append(str(error))
+ if results.count('ok') == 0: # all failed => 500
+ status = 500
+ elif results.count('ok') != len(results): # some failed => 206
+ status = 206
+ return results, status
+
+ def delete(self):
+ """will delete several objects,
+ a list of their ids should be in the payload"""
+ status, results = 204, []
+ for obj_id in request.json:
+ try:
+ self.controller.delete(obj_id)
+ results.append('ok')
+ except Exception as error:
+ status = 206
+ results.append(error)
+ # if no operation succeeded, it's not partial anymore, returning err 500
+ if status == 206 and results.count('ok') == 0:
+ status = 500
+ return results, status
diff --git a/newspipe/web/views/api/v2/feed.py b/newspipe/web/views/api/v2/feed.py
new file mode 100644
index 00000000..a0691277
--- /dev/null
+++ b/newspipe/web/views/api/v2/feed.py
@@ -0,0 +1,47 @@
+from conf import API_ROOT
+from flask import current_app
+from flask_restful import Api
+
+from web.views.common import api_permission
+from web.controllers.feed import (FeedController,
+ DEFAULT_MAX_ERROR,
+ DEFAULT_LIMIT)
+
+from web.views.api.v2.common import PyAggAbstractResource, \
+ PyAggResourceNew, \
+ PyAggResourceExisting, \
+ PyAggResourceMulti
+
+
+class FeedNewAPI(PyAggResourceNew):
+ controller_cls = FeedController
+
+
+class FeedAPI(PyAggResourceExisting):
+ controller_cls = FeedController
+
+
+class FeedsAPI(PyAggResourceMulti):
+ controller_cls = FeedController
+
+
+class FetchableFeedAPI(PyAggAbstractResource):
+ controller_cls = FeedController
+ attrs = {'max_error': {'type': int, 'default': DEFAULT_MAX_ERROR},
+ 'limit': {'type': int, 'default': DEFAULT_LIMIT}}
+
+ @api_permission.require(http_exception=403)
+ def get(self):
+ args = self.reqparse_args(right='read', allow_empty=True)
+ result = [feed for feed
+ in self.controller.list_fetchable(**args)]
+ return result or None, 200 if result else 204
+
+
+api = Api(current_app, prefix=API_ROOT)
+
+api.add_resource(FeedNewAPI, '/feed', endpoint='feed_new.json')
+api.add_resource(FeedAPI, '/feed/<int:obj_id>', endpoint='feed.json')
+api.add_resource(FeedsAPI, '/feeds', endpoint='feeds.json')
+api.add_resource(FetchableFeedAPI, '/feeds/fetchable',
+ endpoint='fetchable_feed.json')
diff --git a/newspipe/web/views/api/v3/__init__.py b/newspipe/web/views/api/v3/__init__.py
new file mode 100644
index 00000000..76aa1f19
--- /dev/null
+++ b/newspipe/web/views/api/v3/__init__.py
@@ -0,0 +1,3 @@
+from web.views.api.v3 import article
+
+__all__ = ['article']
diff --git a/newspipe/web/views/api/v3/article.py b/newspipe/web/views/api/v3/article.py
new file mode 100644
index 00000000..4cf35648
--- /dev/null
+++ b/newspipe/web/views/api/v3/article.py
@@ -0,0 +1,84 @@
+#! /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 <http://www.gnu.org/licenses/>
+
+__author__ = "Cedric Bonhomme"
+__version__ = "$Revision: 0.1 $"
+__date__ = "$Date: 2016/04/29 $"
+__revision__ = "$Date: 2016/04/29 $"
+__copyright__ = "Copyright (c) Cedric Bonhomme"
+__license__ = "GPLv3"
+
+from flask_login import current_user
+from werkzeug.exceptions import NotFound
+from flask_restless import ProcessingException
+from web import models
+from bootstrap import application, manager
+from web.controllers import ArticleController, FeedController
+from web.views.api.v3.common import AbstractProcessor
+from web.views.api.v3.common import url_prefix, auth_func
+
+class ArticleProcessor(AbstractProcessor):
+ """Concrete processors for the Article Web service.
+ """
+
+ def get_single_preprocessor(self, instance_id=None, **kw):
+ try:
+ article = ArticleController(current_user.id).get(id=instance_id)
+ except NotFound:
+ raise ProcessingException(description='No such article.', code=404)
+ self.is_authorized(current_user, article)
+
+ def post_preprocessor(self, data=None, **kw):
+ data["user_id"] = current_user.id
+
+ try:
+ feed = FeedController(current_user.id).get(id=data["feed_id"])
+ except NotFound:
+ raise ProcessingException(description='No such feed.', code=404)
+ self.is_authorized(current_user, feed)
+
+ data["category_id"] = feed.category_id
+
+ def delete_preprocessor(self, instance_id=None, **kw):
+ try:
+ article = ArticleController(current_user.id).get(id=instance_id)
+ except NotFound:
+ raise ProcessingException(description='No such article.', code=404)
+ self.is_authorized(current_user, article)
+
+article_processor = ArticleProcessor()
+
+blueprint_article = manager.create_api_blueprint(models.Article,
+ url_prefix=url_prefix,
+ methods=['GET', 'POST', 'PUT', 'DELETE'],
+ preprocessors=dict(GET_SINGLE=[auth_func,
+ article_processor.get_single_preprocessor],
+ GET_MANY=[auth_func,
+ article_processor.get_many_preprocessor],
+ POST=[auth_func,
+ article_processor.post_preprocessor],
+ PUT_SINGLE=[auth_func,
+ article_processor.put_single_preprocessor],
+ PUT_MANY=[auth_func,
+ article_processor.put_many_preprocessor],
+ DELETE=[auth_func,
+ article_processor.delete_preprocessor]))
+application.register_blueprint(blueprint_article)
diff --git a/newspipe/web/views/api/v3/common.py b/newspipe/web/views/api/v3/common.py
new file mode 100644
index 00000000..d5e94a3f
--- /dev/null
+++ b/newspipe/web/views/api/v3/common.py
@@ -0,0 +1,109 @@
+#! /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 <http://www.gnu.org/licenses/>
+
+__author__ = "Cedric Bonhomme"
+__version__ = "$Revision: 0.1 $"
+__date__ = "$Date: 2016/04/29 $"
+__revision__ = "$Date: 2016/04/29 $"
+__copyright__ = "Copyright (c) Cedric Bonhomme"
+__license__ = "GPLv3"
+
+from flask import request
+from flask_login import current_user
+from flask_restless import ProcessingException
+from werkzeug.exceptions import NotFound
+from web.controllers import ArticleController, UserController
+from web.views.common import login_user_bundle
+
+url_prefix = '/api/v3'
+
+def auth_func(*args, **kw):
+ if request.authorization:
+ ucontr = UserController()
+ try:
+ user = ucontr.get(nickname=request.authorization.username)
+ except NotFound:
+ raise ProcessingException("Couldn't authenticate your user",
+ code=401)
+ if not ucontr.check_password(user, request.authorization.password):
+ raise ProcessingException("Couldn't authenticate your user",
+ code=401)
+ if not user.is_active:
+ raise ProcessingException("User is deactivated", code=401)
+ login_user_bundle(user)
+ if not current_user.is_authenticated:
+ raise ProcessingException(description='Not authenticated!', code=401)
+
+class AbstractProcessor():
+ """Abstract processors for the Web services.
+ """
+
+ def is_authorized(self, user, obj):
+ if user.id != obj.user_id:
+ raise ProcessingException(description='Not Authorized', code=401)
+
+ def get_single_preprocessor(self, instance_id=None, **kw):
+ # Check if the user is authorized to modify the specified
+ # instance of the model.
+ pass
+
+ def get_many_preprocessor(self, search_params=None, **kw):
+ """Accepts a single argument, `search_params`, which is a dictionary
+ containing the search parameters for the request.
+ """
+ filt = dict(name="user_id",
+ op="eq",
+ val=current_user.id)
+
+ # Check if there are any filters there already.
+ if "filters" not in search_params:
+ search_params["filters"] = []
+
+ search_params["filters"].append(filt)
+
+ def post_preprocessor(self, data=None, **kw):
+ pass
+
+ def put_single_preprocessor(instance_id=None, data=None, **kw):
+ """Accepts two arguments, `instance_id`, the primary key of the
+ instance of the model to patch, and `data`, the dictionary of fields
+ to change on the instance.
+ """
+ pass
+
+ def put_many_preprocessor(search_params=None, data=None, **kw):
+ """Accepts two arguments: `search_params`, which is a dictionary
+ containing the search parameters for the request, and `data`, which
+ is a dictionary representing the fields to change on the matching
+ instances and the values to which they will be set.
+ """
+ filt = dict(name="user_id",
+ op="eq",
+ val=current_user.id)
+
+ # Check if there are any filters there already.
+ if "filters" not in search_params:
+ search_params["filters"] = []
+
+ search_params["filters"].append(filt)
+
+ def delete_preprocessor(self, instance_id=None, **kw):
+ pass
diff --git a/newspipe/web/views/api/v3/feed.py b/newspipe/web/views/api/v3/feed.py
new file mode 100644
index 00000000..2cbbafd9
--- /dev/null
+++ b/newspipe/web/views/api/v3/feed.py
@@ -0,0 +1,58 @@
+#! /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 <http://www.gnu.org/licenses/>
+
+__author__ = "Cedric Bonhomme"
+__version__ = "$Revision: 0.1 $"
+__date__ = "$Date: 2016/04/29 $"
+__revision__ = "$Date: 2016/04/29 $"
+__copyright__ = "Copyright (c) Cedric Bonhomme"
+__license__ = "GPLv3"
+
+from flask_login import current_user
+from web import models
+from bootstrap import application, manager
+from web.controllers import FeedController
+from web.views.api.v3.common import AbstractProcessor
+from web.views.api.v3.common import url_prefix, auth_func
+
+class FeedProcessor(AbstractProcessor):
+ """Concrete processors for the Feed Web service.
+ """
+
+ def get_single_preprocessor(self, instance_id=None, **kw):
+ # Check if the user is authorized to modify the specified
+ # instance of the model.
+ feed = FeedController(current_user.id).get(id=instance_id)
+ self.is_authorized(current_user, feed)
+
+feed_processor = FeedProcessor()
+
+blueprint_feed = manager.create_api_blueprint(models.Feed,
+ url_prefix=url_prefix,
+ methods=['GET', 'POST', 'PUT', 'DELETE'],
+ preprocessors=dict(GET_SINGLE=[auth_func,
+ feed_processor.get_single_preprocessor],
+ GET_MANY=[auth_func,
+ feed_processor.get_many_preprocessor],
+ PUT_SINGLE=[auth_func],
+ POST=[auth_func],
+ DELETE=[auth_func]))
+application.register_blueprint(blueprint_feed)
diff --git a/newspipe/web/views/article.py b/newspipe/web/views/article.py
new file mode 100644
index 00000000..bf39795d
--- /dev/null
+++ b/newspipe/web/views/article.py
@@ -0,0 +1,154 @@
+from datetime import datetime, timedelta
+from flask import (Blueprint, g, render_template, redirect,
+ flash, url_for, make_response, request)
+
+from flask_babel import gettext
+from flask_login import login_required, current_user
+
+
+from bootstrap import db
+from lib.utils import clear_string, redirect_url
+from lib.data import export_json
+from web.controllers import (ArticleController, UserController,
+ CategoryController)
+from web.lib.view_utils import etag_match
+
+articles_bp = Blueprint('articles', __name__, url_prefix='/articles')
+article_bp = Blueprint('article', __name__, url_prefix='/article')
+
+
+@article_bp.route('/redirect/<int:article_id>', methods=['GET'])
+@login_required
+def redirect_to_article(article_id):
+ contr = ArticleController(current_user.id)
+ article = contr.get(id=article_id)
+ if not article.readed:
+ contr.update({'id': article.id}, {'readed': True})
+ return redirect(article.link)
+
+
+@article_bp.route('/<int:article_id>', methods=['GET'])
+@login_required
+@etag_match
+def article(article_id=None):
+ """
+ Presents an article.
+ """
+ article = ArticleController(current_user.id).get(id=article_id)
+ return render_template('article.html',
+ head_titles=[clear_string(article.title)],
+ article=article)
+
+@article_bp.route('/public/<int:article_id>', methods=['GET'])
+@etag_match
+def article_pub(article_id=None):
+ """
+ Presents an article of a public feed if the profile of the owner is also
+ public.
+ """
+ article = ArticleController().get(id=article_id)
+ if article.source.private or not article.source.user.is_public_profile:
+ return render_template('errors/404.html'), 404
+ return render_template('article_pub.html',
+ head_titles=[clear_string(article.title)],
+ article=article)
+
+
+@article_bp.route('/like/<int:article_id>', methods=['GET'])
+@login_required
+def like(article_id=None):
+ """
+ Mark or unmark an article as favorites.
+ """
+ art_contr = ArticleController(current_user.id)
+ article = art_contr.get(id=article_id)
+ art_contr = art_contr.update({'id': article_id},
+ {'like': not article.like})
+ return redirect(redirect_url())
+
+
+@article_bp.route('/delete/<int:article_id>', methods=['GET'])
+@login_required
+def delete(article_id=None):
+ """
+ Delete an article from the database.
+ """
+ article = ArticleController(current_user.id).delete(article_id)
+ flash(gettext('Article %(article_title)s deleted',
+ article_title=article.title), 'success')
+ return redirect(url_for('home'))
+
+
+@articles_bp.route('/history', methods=['GET'])
+@articles_bp.route('/history/<int:year>', methods=['GET'])
+@articles_bp.route('/history/<int:year>/<int:month>', methods=['GET'])
+@login_required
+def history(year=None, month=None):
+ cntr, artcles = ArticleController(current_user.id).get_history(year, month)
+ return render_template('history.html', articles_counter=cntr,
+ articles=artcles, year=year, month=month)
+
+
+@article_bp.route('/mark_as/<string:new_value>', methods=['GET'])
+@article_bp.route('/mark_as/<string:new_value>/article/<int:article_id>',
+ methods=['GET'])
+@login_required
+def mark_as(new_value='read', feed_id=None, article_id=None):
+ """
+ Mark all unreaded articles as read.
+ """
+ readed = new_value == 'read'
+ art_contr = ArticleController(current_user.id)
+ filters = {'readed': not readed}
+ if feed_id is not None:
+ filters['feed_id'] = feed_id
+ message = 'Feed marked as %s.'
+ elif article_id is not None:
+ filters['id'] = article_id
+ message = 'Article marked as %s.'
+ else:
+ message = 'All article marked as %s.'
+ art_contr.update(filters, {"readed": readed})
+ flash(gettext(message % new_value), 'info')
+
+ if readed:
+ return redirect(redirect_url())
+ return redirect('home')
+
+
+@articles_bp.route('/expire_articles', methods=['GET'])
+@login_required
+def expire():
+ """
+ Delete articles older than the given number of weeks.
+ """
+ current_time = datetime.utcnow()
+ weeks_ago = current_time - timedelta(int(request.args.get('weeks', 10)))
+ art_contr = ArticleController(current_user.id)
+
+ query = art_contr.read(__or__={'date__lt': weeks_ago,
+ 'retrieved_date__lt': weeks_ago})
+ count = query.count()
+ query.delete()
+ db.session.commit()
+ flash(gettext('%(count)d articles deleted', count=count), 'info')
+ return redirect(redirect_url())
+
+
+@articles_bp.route('/export', methods=['GET'])
+@login_required
+def export():
+ """
+ Export articles to JSON.
+ """
+ user = UserController(current_user.id).get(id=current_user.id)
+ try:
+ json_result = export_json(user)
+ except Exception as e:
+ flash(gettext("Error when exporting articles."), 'danger')
+ return redirect(redirect_url())
+ response = make_response(json_result)
+ response.mimetype = 'application/json'
+ response.headers["Content-Disposition"] \
+ = 'attachment; filename=account.json'
+ return response
diff --git a/newspipe/web/views/bookmark.py b/newspipe/web/views/bookmark.py
new file mode 100644
index 00000000..21d832d2
--- /dev/null
+++ b/newspipe/web/views/bookmark.py
@@ -0,0 +1,256 @@
+#! /usr/bin/env python
+#-*- coding: utf-8 -*-
+
+# Newspipe - A Web based news aggregator.
+# Copyright (C) 2010-2017 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 <http://www.gnu.org/licenses/>.
+
+__author__ = "Cedric Bonhomme"
+__version__ = "$Revision: 0.1 $"
+__date__ = "$Date: 2017/05/24 $"
+__revision__ = "$Date: 2017/05/24 $"
+__copyright__ = "Copyright (c) Cedric Bonhomme"
+__license__ = "AGPLv3"
+
+import logging
+import datetime
+from werkzeug.exceptions import BadRequest
+
+from flask import Blueprint, render_template, flash, \
+ redirect, request, url_for, make_response
+from flask_babel import gettext
+from flask_login import login_required, current_user
+from flask_paginate import Pagination, get_page_args
+from sqlalchemy import desc
+
+import conf
+from lib.utils import redirect_url
+from lib.data import import_pinboard_json, export_bookmarks
+from bootstrap import db
+from web.forms import BookmarkForm
+from web.controllers import BookmarkController, BookmarkTagController
+from web.models import BookmarkTag
+
+logger = logging.getLogger(__name__)
+bookmarks_bp = Blueprint('bookmarks', __name__, url_prefix='/bookmarks')
+bookmark_bp = Blueprint('bookmark', __name__, url_prefix='/bookmark')
+
+
+@bookmarks_bp.route('/', defaults={'per_page': '50'}, methods=['GET'])
+@bookmarks_bp.route('/<string:status>', defaults={'per_page': '50'},
+ methods=['GET'])
+def list_(per_page, status='all'):
+ "Lists the bookmarks."
+ head_titles = [gettext("Bookmarks")]
+ user_id = None
+ filters = {}
+ tag = request.args.get('tag', None)
+ if tag:
+ filters['tags_proxy__contains'] = tag
+ query = request.args.get('query', None)
+ if query:
+ query_regex = '%' + query + '%'
+ filters['__or__'] = {'title__ilike': query_regex,
+ 'description__ilike': query_regex}
+ if current_user.is_authenticated:
+ # query for the bookmarks of the authenticated user
+ user_id = current_user.id
+ if status == 'public':
+ # load public bookmarks only
+ filters['shared'] = True
+ elif status == 'private':
+ # load private bookmarks only
+ filters['shared'] = False
+ else:
+ # no filter: load shared and public bookmarks
+ pass
+ if status == 'unread':
+ filters['to_read'] = True
+ else:
+ pass
+ else:
+ # query for the shared bookmarks (of all users)
+ head_titles = [gettext("Recent bookmarks")]
+ not_created_before = datetime.datetime.today() - \
+ datetime.timedelta(days=900)
+ filters['time__gt'] = not_created_before # only "recent" bookmarks
+ filters['shared'] = True
+
+ bookmarks = BookmarkController(user_id) \
+ .read(**filters) \
+ .order_by(desc('time'))
+
+ #tag_contr = BookmarkTagController(user_id)
+ #tag_contr.read().join(bookmarks).all()
+
+ page, per_page, offset = get_page_args()
+ pagination = Pagination(page=page, total=bookmarks.count(),
+ css_framework='bootstrap3',
+ search=False, record_name='bookmarks',
+ per_page=per_page)
+
+ return render_template('bookmarks.html',
+ head_titles=head_titles,
+ bookmarks=bookmarks.offset(offset).limit(per_page),
+ pagination=pagination,
+ tag=tag,
+ query=query)
+
+
+@bookmark_bp.route('/create', methods=['GET'])
+@bookmark_bp.route('/edit/<int:bookmark_id>', methods=['GET'])
+@login_required
+def form(bookmark_id=None):
+ "Form to create/edit bookmarks."
+ action = gettext("Add a new bookmark")
+ head_titles = [action]
+ if bookmark_id is None:
+ return render_template('edit_bookmark.html', action=action,
+ head_titles=head_titles, form=BookmarkForm())
+ bookmark = BookmarkController(current_user.id).get(id=bookmark_id)
+ action = gettext('Edit bookmark')
+ head_titles = [action]
+ form = BookmarkForm(obj=bookmark)
+ form.tags.data = ", ".join(bookmark.tags_proxy)
+ return render_template('edit_bookmark.html', action=action,
+ head_titles=head_titles, bookmark=bookmark,
+ form=form)
+
+
+@bookmark_bp.route('/create', methods=['POST'])
+@bookmark_bp.route('/edit/<int:bookmark_id>', methods=['POST'])
+@login_required
+def process_form(bookmark_id=None):
+ "Process the creation/edition of bookmarks."
+ form = BookmarkForm()
+ bookmark_contr = BookmarkController(current_user.id)
+ tag_contr = BookmarkTagController(current_user.id)
+
+ if not form.validate():
+ return render_template('edit_bookmark.html', form=form)
+
+ if form.title.data == '':
+ title = form.href.data
+ else:
+ title = form.title.data
+
+ bookmark_attr = {'href': form.href.data,
+ 'description': form.description.data,
+ 'title': title,
+ 'shared': form.shared.data,
+ 'to_read': form.to_read.data}
+
+ if bookmark_id is not None:
+ tags = []
+ for tag in form.tags.data.split(','):
+ new_tag = tag_contr.create(text=tag.strip(), user_id=current_user.id,
+ bookmark_id=bookmark_id)
+ tags.append(new_tag)
+ bookmark_attr['tags'] = tags
+ bookmark_contr.update({'id': bookmark_id}, bookmark_attr)
+ flash(gettext('Bookmark successfully updated.'), 'success')
+ return redirect(url_for('bookmark.form', bookmark_id=bookmark_id))
+
+ # Create a new bookmark
+ new_bookmark = bookmark_contr.create(**bookmark_attr)
+ tags = []
+ for tag in form.tags.data.split(','):
+ new_tag = tag_contr.create(text=tag.strip(), user_id=current_user.id,
+ bookmark_id=new_bookmark.id)
+ tags.append(new_tag)
+ bookmark_attr['tags'] = tags
+ bookmark_contr.update({'id': new_bookmark.id}, bookmark_attr)
+ flash(gettext('Bookmark successfully created.'), 'success')
+ return redirect(url_for('bookmark.form', bookmark_id=new_bookmark.id))
+
+
+@bookmark_bp.route('/delete/<int:bookmark_id>', methods=['GET'])
+@login_required
+def delete(bookmark_id=None):
+ "Delete a bookmark."
+ bookmark = BookmarkController(current_user.id).delete(bookmark_id)
+ flash(gettext("Bookmark %(bookmark_name)s successfully deleted.",
+ bookmark_name=bookmark.title), 'success')
+ return redirect(url_for('bookmarks.list_'))
+
+
+@bookmarks_bp.route('/delete', methods=['GET'])
+@login_required
+def delete_all():
+ "Delete all bookmarks."
+ bookmark = BookmarkController(current_user.id).read().delete()
+ db.session.commit()
+ flash(gettext("Bookmarks successfully deleted."), 'success')
+ return redirect(redirect_url())
+
+
+@bookmark_bp.route('/bookmarklet', methods=['GET', 'POST'])
+@login_required
+def bookmarklet():
+ bookmark_contr = BookmarkController(current_user.id)
+ href = (request.args if request.method == 'GET' else request.form)\
+ .get('href', None)
+ if not href:
+ flash(gettext("Couldn't add bookmark: url missing."), "error")
+ raise BadRequest("url is missing")
+ title = (request.args if request.method == 'GET' else request.form)\
+ .get('title', None)
+ if not title:
+ title = href
+
+ bookmark_exists = bookmark_contr.read(**{'href': href}).all()
+ if bookmark_exists:
+ flash(gettext("Couldn't add bookmark: bookmark already exists."),
+ "warning")
+ return redirect(url_for('bookmark.form',
+ bookmark_id=bookmark_exists[0].id))
+
+ bookmark_attr = {'href': href,
+ 'description': '',
+ 'title': title,
+ 'shared': True,
+ 'to_read': True}
+
+ new_bookmark = bookmark_contr.create(**bookmark_attr)
+ flash(gettext('Bookmark successfully created.'), 'success')
+ return redirect(url_for('bookmark.form', bookmark_id=new_bookmark.id))
+
+
+@bookmark_bp.route('/import_pinboard', methods=['POST'])
+@login_required
+def import_pinboard():
+ bookmarks = request.files.get('jsonfile', None)
+ if bookmarks:
+ try:
+ nb_bookmarks = import_pinboard_json(current_user, bookmarks.read())
+ flash(gettext("%(nb_bookmarks)s bookmarks successfully imported.",
+ nb_bookmarks=nb_bookmarks), 'success')
+ except Exception as e:
+ flash(gettext('Error when importing bookmarks.'), 'error')
+
+ return redirect(redirect_url())
+
+
+@bookmarks_bp.route('/export', methods=['GET'])
+@login_required
+def export():
+ bookmarks = export_bookmarks(current_user)
+ response = make_response(bookmarks)
+ response.mimetype = 'application/json'
+ response.headers["Content-Disposition"] \
+ = 'attachment; filename=newspipe_bookmarks_export.json'
+ return response
diff --git a/newspipe/web/views/category.py b/newspipe/web/views/category.py
new file mode 100644
index 00000000..138561dd
--- /dev/null
+++ b/newspipe/web/views/category.py
@@ -0,0 +1,86 @@
+from flask import Blueprint, render_template, flash, redirect, url_for
+from flask_babel import gettext
+from flask_login import login_required, current_user
+
+from web.forms import CategoryForm
+from lib.utils import redirect_url
+from web.lib.view_utils import etag_match
+from web.controllers import ArticleController, FeedController, \
+ CategoryController
+
+categories_bp = Blueprint('categories', __name__, url_prefix='/categories')
+category_bp = Blueprint('category', __name__, url_prefix='/category')
+
+
+@categories_bp.route('/', methods=['GET'])
+@login_required
+@etag_match
+def list_():
+ "Lists the subscribed feeds in a table."
+ art_contr = ArticleController(current_user.id)
+ return render_template('categories.html',
+ categories=list(CategoryController(current_user.id).read().order_by('name')),
+ feeds_count=FeedController(current_user.id).count_by_category(),
+ unread_article_count=art_contr.count_by_category(readed=False),
+ article_count=art_contr.count_by_category())
+
+
+@category_bp.route('/create', methods=['GET'])
+@category_bp.route('/edit/<int:category_id>', methods=['GET'])
+@login_required
+@etag_match
+def form(category_id=None):
+ action = gettext("Add a category")
+ head_titles = [action]
+ if category_id is None:
+ return render_template('edit_category.html', action=action,
+ head_titles=head_titles, form=CategoryForm())
+ category = CategoryController(current_user.id).get(id=category_id)
+ action = gettext('Edit category')
+ head_titles = [action]
+ if category.name:
+ head_titles.append(category.name)
+ return render_template('edit_category.html', action=action,
+ head_titles=head_titles, category=category,
+ form=CategoryForm(obj=category))
+
+
+@category_bp.route('/delete/<int:category_id>', methods=['GET'])
+@login_required
+def delete(category_id=None):
+ category = CategoryController(current_user.id).delete(category_id)
+ flash(gettext("Category %(category_name)s successfully deleted.",
+ category_name=category.name), 'success')
+ return redirect(redirect_url())
+
+
+@category_bp.route('/create', methods=['POST'])
+@category_bp.route('/edit/<int:category_id>', methods=['POST'])
+@login_required
+def process_form(category_id=None):
+ form = CategoryForm()
+ cat_contr = CategoryController(current_user.id)
+
+ if not form.validate():
+ return render_template('edit_category.html', form=form)
+ existing_cats = list(cat_contr.read(name=form.name.data))
+ if existing_cats and category_id is None:
+ flash(gettext("Couldn't add category: already exists."), "warning")
+ return redirect(url_for('category.form',
+ category_id=existing_cats[0].id))
+ # Edit an existing category
+ category_attr = {'name': form.name.data}
+
+ if category_id is not None:
+ cat_contr.update({'id': category_id}, category_attr)
+ flash(gettext('Category %(cat_name)r successfully updated.',
+ cat_name=category_attr['name']), 'success')
+ return redirect(url_for('category.form', category_id=category_id))
+
+ # Create a new category
+ new_category = cat_contr.create(**category_attr)
+
+ flash(gettext('Category %(category_name)r successfully created.',
+ category_name=new_category.name), 'success')
+
+ return redirect(url_for('category.form', category_id=new_category.id))
diff --git a/newspipe/web/views/common.py b/newspipe/web/views/common.py
new file mode 100644
index 00000000..e422fd57
--- /dev/null
+++ b/newspipe/web/views/common.py
@@ -0,0 +1,53 @@
+import json
+from functools import wraps
+from datetime import datetime
+from flask import current_app, Response
+from flask_login import login_user
+from flask_principal import (Identity, Permission, RoleNeed,
+ session_identity_loader, identity_changed)
+from web.controllers import UserController
+from lib.utils import default_handler
+
+admin_role = RoleNeed('admin')
+api_role = RoleNeed('api')
+
+admin_permission = Permission(admin_role)
+api_permission = Permission(api_role)
+
+
+def scoped_default_handler():
+ if admin_permission.can():
+ role = 'admin'
+ elif api_permission.can():
+ role = 'api'
+ else:
+ role = 'user'
+
+ @wraps(default_handler)
+ def wrapper(obj):
+ return default_handler(obj, role=role)
+ return wrapper
+
+
+def jsonify(func):
+ """Will cast results of func as a result, and try to extract
+ a status_code for the Response object"""
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ status_code = 200
+ result = func(*args, **kwargs)
+ if isinstance(result, Response):
+ return result
+ elif isinstance(result, tuple):
+ result, status_code = result
+ return Response(json.dumps(result, default=scoped_default_handler()),
+ mimetype='application/json', status=status_code)
+ return wrapper
+
+
+def login_user_bundle(user):
+ login_user(user)
+ identity_changed.send(current_app, identity=Identity(user.id))
+ session_identity_loader()
+ UserController(user.id).update(
+ {'id': user.id}, {'last_seen': datetime.utcnow()})
diff --git a/newspipe/web/views/feed.py b/newspipe/web/views/feed.py
new file mode 100644
index 00000000..b98a005a
--- /dev/null
+++ b/newspipe/web/views/feed.py
@@ -0,0 +1,306 @@
+import logging
+import requests.exceptions
+from datetime import datetime, timedelta
+from sqlalchemy import desc
+from werkzeug.exceptions import BadRequest
+
+from flask import Blueprint, render_template, flash, \
+ redirect, request, url_for, make_response
+from flask_babel import gettext
+from flask_login import login_required, current_user
+from flask_paginate import Pagination, get_page_args
+
+import conf
+from lib import misc_utils, utils
+from lib.feed_utils import construct_feed_from
+from web.lib.view_utils import etag_match
+from web.forms import AddFeedForm
+from web.controllers import (UserController, CategoryController,
+ FeedController, ArticleController)
+
+logger = logging.getLogger(__name__)
+feeds_bp = Blueprint('feeds', __name__, url_prefix='/feeds')
+feed_bp = Blueprint('feed', __name__, url_prefix='/feed')
+
+
+@feeds_bp.route('/', methods=['GET'])
+@login_required
+@etag_match
+def feeds():
+ "Lists the subscribed feeds in a table."
+ art_contr = ArticleController(current_user.id)
+ return render_template('feeds.html',
+ feeds=FeedController(current_user.id).read().order_by('title'),
+ unread_article_count=art_contr.count_by_feed(readed=False),
+ article_count=art_contr.count_by_feed())
+
+
+def feed_view(feed_id=None, user_id=None):
+ feed = FeedController(user_id).get(id=feed_id)
+ word_size = 6
+ category = None
+ if feed.category_id:
+ category = CategoryController(user_id).get(id=feed.category_id)
+ filters = {}
+ filters['feed_id'] = feed_id
+ articles = ArticleController(user_id).read_light(**filters)
+
+ # Server-side pagination
+ page, per_page, offset = get_page_args(per_page_parameter='per_page')
+ pagination = Pagination(page=page, total=articles.count(),
+ css_framework='bootstrap3',
+ search=False, record_name='articles',
+ per_page=per_page)
+
+ today = datetime.now()
+ try:
+ last_article = articles[0].date
+ first_article = articles[-1].date
+ delta = last_article - first_article
+ average = round(float(articles.count()) / abs(delta.days), 2)
+ except Exception as e:
+ last_article = datetime.fromtimestamp(0)
+ first_article = datetime.fromtimestamp(0)
+ delta = last_article - first_article
+ average = 0
+ elapsed = today - last_article
+
+ return render_template('feed.html',
+ head_titles=[utils.clear_string(feed.title)],
+ feed=feed, category=category,
+ articles=articles.offset(offset).limit(per_page),
+ pagination=pagination,
+ first_post_date=first_article,
+ end_post_date=last_article,
+ average=average, delta=delta, elapsed=elapsed)
+
+
+@feed_bp.route('/<int:feed_id>', methods=['GET'])
+@login_required
+@etag_match
+def feed(feed_id=None):
+ "Presents detailed information about a feed."
+ return feed_view(feed_id, current_user.id)
+
+
+@feed_bp.route('/public/<int:feed_id>', methods=['GET'])
+@etag_match
+def feed_pub(feed_id=None):
+ """
+ Presents details of a pubic feed if the profile of the owner is also
+ public.
+ """
+ feed = FeedController(None).get(id=feed_id)
+ if feed.private or not feed.user.is_public_profile:
+ return render_template('errors/404.html'), 404
+ return feed_view(feed_id, None)
+
+
+@feed_bp.route('/delete/<feed_id>', methods=['GET'])
+@login_required
+def delete(feed_id=None):
+ feed_contr = FeedController(current_user.id)
+ feed = feed_contr.get(id=feed_id)
+ feed_contr.delete(feed_id)
+ flash(gettext("Feed %(feed_title)s successfully deleted.",
+ feed_title=feed.title), 'success')
+ return redirect(url_for('home'))
+
+
+@feed_bp.route('/reset_errors/<int:feed_id>', methods=['GET', 'POST'])
+@login_required
+def reset_errors(feed_id):
+ feed_contr = FeedController(current_user.id)
+ feed = feed_contr.get(id=feed_id)
+ feed_contr.update({'id': feed_id}, {'error_count': 0, 'last_error': ''})
+ flash(gettext('Feed %(feed_title)r successfully updated.',
+ feed_title=feed.title), 'success')
+ return redirect(request.referrer or url_for('home'))
+
+
+@feed_bp.route('/bookmarklet', methods=['GET', 'POST'])
+@login_required
+def bookmarklet():
+ feed_contr = FeedController(current_user.id)
+ url = (request.args if request.method == 'GET' else request.form)\
+ .get('url', None)
+ if not url:
+ flash(gettext("Couldn't add feed: url missing."), "error")
+ raise BadRequest("url is missing")
+
+ feed_exists = list(feed_contr.read(__or__={'link': url, 'site_link': url}))
+ if feed_exists:
+ flash(gettext("Couldn't add feed: feed already exists."),
+ "warning")
+ return redirect(url_for('feed.form', feed_id=feed_exists[0].id))
+
+ try:
+ feed = construct_feed_from(url)
+ except requests.exceptions.ConnectionError:
+ flash(gettext("Impossible to connect to the address: {}.".format(url)),
+ "danger")
+ return redirect(url_for('home'))
+ except Exception:
+ logger.exception('something bad happened when fetching %r', url)
+ return redirect(url_for('home'))
+ if not feed.get('link'):
+ feed['enabled'] = False
+ flash(gettext("Couldn't find a feed url, you'll need to find a Atom or"
+ " RSS link manually and reactivate this feed"),
+ 'warning')
+ feed = feed_contr.create(**feed)
+ flash(gettext('Feed was successfully created.'), 'success')
+ if feed.enabled and conf.CRAWLING_METHOD == "default":
+ misc_utils.fetch(current_user.id, feed.id)
+ flash(gettext("Downloading articles for the new feed..."), 'info')
+ return redirect(url_for('feed.form', feed_id=feed.id))
+
+
+@feed_bp.route('/update/<action>/<int:feed_id>', methods=['GET', 'POST'])
+@feeds_bp.route('/update/<action>', methods=['GET', 'POST'])
+@login_required
+def update(action, feed_id=None):
+ readed = action == 'read'
+ filters = {'readed__ne': readed}
+
+ nb_days = request.args.get('nb_days', 0, type=int)
+ if nb_days != 0:
+ filters['date__lt'] = datetime.now() - timedelta(days=nb_days)
+
+ if feed_id:
+ filters['feed_id'] = feed_id
+ ArticleController(current_user.id).update(filters, {'readed': readed})
+ flash(gettext('Feed successfully updated.'), 'success')
+ return redirect(request.referrer or url_for('home'))
+
+
+@feed_bp.route('/create', methods=['GET'])
+@feed_bp.route('/edit/<int:feed_id>', methods=['GET'])
+@login_required
+@etag_match
+def form(feed_id=None):
+ action = gettext("Add a feed")
+ categories = CategoryController(current_user.id).read()
+ head_titles = [action]
+ if feed_id is None:
+ form = AddFeedForm()
+ form.set_category_choices(categories)
+ return render_template('edit_feed.html', action=action,
+ head_titles=head_titles, form=form)
+ feed = FeedController(current_user.id).get(id=feed_id)
+ form = AddFeedForm(obj=feed)
+ form.set_category_choices(categories)
+ action = gettext('Edit feed')
+ head_titles = [action]
+ if feed.title:
+ head_titles.append(feed.title)
+ return render_template('edit_feed.html', action=action,
+ head_titles=head_titles, categories=categories,
+ form=form, feed=feed)
+
+
+@feed_bp.route('/create', methods=['POST'])
+@feed_bp.route('/edit/<int:feed_id>', methods=['POST'])
+@login_required
+def process_form(feed_id=None):
+ form = AddFeedForm()
+ feed_contr = FeedController(current_user.id)
+ form.set_category_choices(CategoryController(current_user.id).read())
+
+ if not form.validate():
+ return render_template('edit_feed.html', form=form)
+ existing_feeds = list(feed_contr.read(link=form.link.data))
+ if existing_feeds and feed_id is None:
+ flash(gettext("Couldn't add feed: feed already exists."), "warning")
+ return redirect(url_for('feed.form', feed_id=existing_feeds[0].id))
+ # Edit an existing feed
+ feed_attr = {'title': form.title.data, 'enabled': form.enabled.data,
+ 'link': form.link.data, 'site_link': form.site_link.data,
+ 'filters': [], 'category_id': form.category_id.data,
+ 'private': form.private.data}
+ if not feed_attr['category_id'] or feed_attr['category_id'] == '0':
+ del feed_attr['category_id']
+
+ for filter_attr in ('type', 'pattern', 'action on', 'action'):
+ for i, value in enumerate(
+ request.form.getlist(filter_attr.replace(' ', '_'))):
+ if i >= len(feed_attr['filters']):
+ feed_attr['filters'].append({})
+ feed_attr['filters'][i][filter_attr] = value
+
+ if feed_id is not None:
+ feed_contr.update({'id': feed_id}, feed_attr)
+ flash(gettext('Feed %(feed_title)r successfully updated.',
+ feed_title=feed_attr['title']), 'success')
+ return redirect(url_for('feed.form', feed_id=feed_id))
+
+ # Create a new feed
+ new_feed = feed_contr.create(**feed_attr)
+
+ flash(gettext('Feed %(feed_title)r successfully created.',
+ feed_title=new_feed.title), 'success')
+
+ if conf.CRAWLING_METHOD == "default":
+ misc_utils.fetch(current_user.id, new_feed.id)
+ flash(gettext("Downloading articles for the new feed..."), 'info')
+
+ return redirect(url_for('feed.form', feed_id=new_feed.id))
+
+
+@feeds_bp.route('/inactives', methods=['GET'])
+@login_required
+def inactives():
+ """
+ List of inactive feeds.
+ """
+ nb_days = int(request.args.get('nb_days', 365))
+ inactives = FeedController(current_user.id).get_inactives(nb_days)
+ return render_template('inactives.html',
+ inactives=inactives, nb_days=nb_days)
+
+
+@feed_bp.route('/duplicates/<int:feed_id>', methods=['GET'])
+@login_required
+def duplicates(feed_id):
+ """
+ Return duplicates article for a feed.
+ """
+ feed, duplicates = FeedController(current_user.id).get_duplicates(feed_id)
+ if len(duplicates) == 0:
+ flash(gettext('No duplicates in the feed "{}".').format(feed.title),
+ 'info')
+ return redirect(url_for('home'))
+ return render_template('duplicates.html', duplicates=duplicates, feed=feed)
+
+
+@feeds_bp.route('/export', methods=['GET'])
+@login_required
+def export():
+ """
+ Export feeds to OPML.
+ """
+ include_disabled = request.args.get('includedisabled', '') == 'on'
+ include_private = request.args.get('includeprivate', '') == 'on'
+ include_exceeded_error_count = request.args. \
+ get('includeexceedederrorcount', '') == 'on'
+
+ filter = {}
+ if not include_disabled:
+ filter['enabled'] = True
+ if not include_private:
+ filter['private'] = False
+ if not include_exceeded_error_count:
+ filter['error_count__lt'] = conf.DEFAULT_MAX_ERROR
+
+ user = UserController(current_user.id).get(id=current_user.id)
+ feeds = FeedController(current_user.id).read(**filter)
+ categories = {cat.id: cat.dump()
+ for cat in CategoryController(user.id).read()}
+
+ response = make_response(render_template('opml.xml',
+ user=user, feeds=feeds,
+ categories=categories,
+ now=datetime.now()))
+ response.headers['Content-Type'] = 'application/xml'
+ response.headers['Content-Disposition'] = 'attachment; filename=feeds.opml'
+ return response
diff --git a/newspipe/web/views/home.py b/newspipe/web/views/home.py
new file mode 100644
index 00000000..34ecb9fa
--- /dev/null
+++ b/newspipe/web/views/home.py
@@ -0,0 +1,172 @@
+import pytz
+import logging
+from datetime import datetime
+
+from flask import current_app, render_template, \
+ request, flash, url_for, redirect
+from flask_login import login_required, current_user
+from flask_babel import gettext, get_locale
+from babel.dates import format_datetime, format_timedelta
+
+import conf
+from lib.utils import redirect_url
+from lib import misc_utils
+from web.lib.view_utils import etag_match
+from web.views.common import jsonify
+
+from web.controllers import FeedController, \
+ ArticleController, CategoryController
+
+localize = pytz.utc.localize
+logger = logging.getLogger(__name__)
+
+
+@current_app.route('/')
+@login_required
+@etag_match
+def home():
+ return render_template('home.html', cdn=conf.CDN_ADDRESS)
+
+
+@current_app.route('/menu')
+@login_required
+@etag_match
+@jsonify
+def get_menu():
+ now, locale = datetime.now(), get_locale()
+ categories_order = [0]
+ categories = {0: {'name': 'No category', 'id': 0}}
+ for cat in CategoryController(current_user.id).read().order_by('name'):
+ categories_order.append(cat.id)
+ categories[cat.id] = cat
+ unread = ArticleController(current_user.id).count_by_feed(readed=False)
+ for cat_id in categories:
+ categories[cat_id]['unread'] = 0
+ categories[cat_id]['feeds'] = []
+ feeds = {feed.id: feed for feed in FeedController(current_user.id).read()}
+ for feed_id, feed in feeds.items():
+ feed['created_rel'] = format_timedelta(feed.created_date - now,
+ add_direction=True, locale=locale)
+ feed['last_rel'] = format_timedelta(feed.last_retrieved - now,
+ add_direction=True, locale=locale)
+ feed['created_date'] = format_datetime(localize(feed.created_date),
+ locale=locale)
+ feed['last_retrieved'] = format_datetime(localize(feed.last_retrieved),
+ locale=locale)
+ feed['category_id'] = feed.category_id or 0
+ feed['unread'] = unread.get(feed.id, 0)
+ if not feed.filters:
+ feed['filters'] = []
+ if feed.icon_url:
+ feed['icon_url'] = url_for('icon.icon', url=feed.icon_url)
+ categories[feed['category_id']]['unread'] += feed['unread']
+ categories[feed['category_id']]['feeds'].append(feed_id)
+ return {'feeds': feeds, 'categories': categories,
+ 'categories_order': categories_order,
+ 'crawling_method': conf.CRAWLING_METHOD,
+ 'max_error': conf.DEFAULT_MAX_ERROR,
+ 'error_threshold': conf.ERROR_THRESHOLD,
+ 'is_admin': current_user.is_admin,
+ 'all_unread_count': sum(unread.values())}
+
+
+def _get_filters(in_dict):
+ filters = {}
+ query = in_dict.get('query')
+ if query:
+ search_title = in_dict.get('search_title') == 'true'
+ search_content = in_dict.get('search_content') == 'true'
+ if search_title:
+ filters['title__ilike'] = "%%%s%%" % query
+ if search_content:
+ filters['content__ilike'] = "%%%s%%" % query
+ if len(filters) == 0:
+ filters['title__ilike'] = "%%%s%%" % query
+ if len(filters) > 1:
+ filters = {"__or__": filters}
+ if in_dict.get('filter') == 'unread':
+ filters['readed'] = False
+ elif in_dict.get('filter') == 'liked':
+ filters['like'] = True
+ filter_type = in_dict.get('filter_type')
+ if filter_type in {'feed_id', 'category_id'} and in_dict.get('filter_id'):
+ filters[filter_type] = int(in_dict['filter_id']) or None
+ return filters
+
+
+@jsonify
+def _articles_to_json(articles, fd_hash=None):
+ now, locale = datetime.now(), get_locale()
+ fd_hash = {feed.id: {'title': feed.title,
+ 'icon_url': url_for('icon.icon', url=feed.icon_url)
+ if feed.icon_url else None}
+ for feed in FeedController(current_user.id).read()}
+
+ return {'articles': [{'title': art.title, 'liked': art.like,
+ 'read': art.readed, 'article_id': art.id, 'selected': False,
+ 'feed_id': art.feed_id, 'category_id': art.category_id or 0,
+ 'feed_title': fd_hash[art.feed_id]['title'] if fd_hash else None,
+ 'icon_url': fd_hash[art.feed_id]['icon_url'] if fd_hash else None,
+ 'date': format_datetime(localize(art.date), locale=locale),
+ 'rel_date': format_timedelta(art.date - now,
+ threshold=1.1, add_direction=True,
+ locale=locale)}
+ for art in articles.limit(1000)]}
+
+
+@current_app.route('/middle_panel')
+@login_required
+@etag_match
+def get_middle_panel():
+ filters = _get_filters(request.args)
+ art_contr = ArticleController(current_user.id)
+ articles = art_contr.read_light(**filters)
+ return _articles_to_json(articles)
+
+
+@current_app.route('/getart/<int:article_id>')
+@current_app.route('/getart/<int:article_id>/<parse>')
+@login_required
+@etag_match
+@jsonify
+def get_article(article_id, parse=False):
+ locale = get_locale()
+ contr = ArticleController(current_user.id)
+ article = contr.get(id=article_id)
+ if not article.readed:
+ article['readed'] = True
+ contr.update({'id': article_id}, {'readed': True})
+ article['category_id'] = article.category_id or 0
+ feed = FeedController(current_user.id).get(id=article.feed_id)
+ article['icon_url'] = url_for('icon.icon', url=feed.icon_url) \
+ if feed.icon_url else None
+ article['date'] = format_datetime(localize(article.date), locale=locale)
+ return article
+
+
+@current_app.route('/mark_all_as_read', methods=['PUT'])
+@login_required
+def mark_all_as_read():
+ filters = _get_filters(request.json)
+ acontr = ArticleController(current_user.id)
+ processed_articles = _articles_to_json(acontr.read_light(**filters))
+ acontr.update(filters, {'readed': True})
+ return processed_articles
+
+
+@current_app.route('/fetch', methods=['GET'])
+@current_app.route('/fetch/<int:feed_id>', methods=['GET'])
+@login_required
+def fetch(feed_id=None):
+ """
+ Triggers the download of news.
+ News are downloaded in a separated process.
+ """
+ if conf.CRAWLING_METHOD == "default" \
+ and (not conf.ON_HEROKU or current_user.is_admin):
+ misc_utils.fetch(current_user.id, feed_id)
+ flash(gettext("Downloading articles..."), "info")
+ else:
+ flash(gettext("The manual retrieving of news is only available " +
+ "for administrator, on the Heroku platform."), "info")
+ return redirect(redirect_url())
diff --git a/newspipe/web/views/icon.py b/newspipe/web/views/icon.py
new file mode 100644
index 00000000..64e54cab
--- /dev/null
+++ b/newspipe/web/views/icon.py
@@ -0,0 +1,15 @@
+import base64
+from flask import Blueprint, Response, request
+from web.controllers import IconController
+from web.lib.view_utils import etag_match
+
+icon_bp = Blueprint('icon', __name__, url_prefix='/icon')
+
+
+@icon_bp.route('/', methods=['GET'])
+@etag_match
+def icon():
+ icon = IconController().get(url=request.args['url'])
+ headers = {'Cache-Control': 'max-age=86400',
+ 'Content-Type': icon.mimetype}
+ return Response(base64.b64decode(icon.content), headers=headers)
diff --git a/newspipe/web/views/session_mgmt.py b/newspipe/web/views/session_mgmt.py
new file mode 100644
index 00000000..0db76115
--- /dev/null
+++ b/newspipe/web/views/session_mgmt.py
@@ -0,0 +1,113 @@
+import json
+import logging
+
+from datetime import datetime
+from werkzeug.security import generate_password_hash
+from werkzeug.exceptions import NotFound
+from flask import (render_template, flash, session, request,
+ url_for, redirect, current_app)
+from flask_babel import gettext, lazy_gettext
+from flask_login import LoginManager, logout_user, \
+ login_required, current_user
+from flask_principal import (Principal, AnonymousIdentity, UserNeed,
+ identity_changed, identity_loaded,
+ session_identity_loader)
+
+import conf
+from web.views.common import admin_role, api_role, login_user_bundle
+from web.controllers import UserController
+from web.forms import SignupForm, SigninForm
+from notifications import notifications
+
+Principal(current_app)
+# Create a permission with a single Need, in this case a RoleNeed.
+
+login_manager = LoginManager()
+login_manager.init_app(current_app)
+login_manager.login_view = 'login'
+login_manager.login_message = lazy_gettext('Please log in to access this page.')
+login_manager.login_message_category = 'info'
+
+logger = logging.getLogger(__name__)
+
+
+@identity_loaded.connect_via(current_app._get_current_object())
+def on_identity_loaded(sender, identity):
+ # Set the identity user object
+ identity.user = current_user
+
+ # Add the UserNeed to the identity
+ if current_user.is_authenticated:
+ identity.provides.add(UserNeed(current_user.id))
+ if current_user.is_admin:
+ identity.provides.add(admin_role)
+ if current_user.is_api:
+ identity.provides.add(api_role)
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ return UserController(user_id, ignore_context=True).get(
+ id=user_id, is_active=True)
+
+@current_app.before_request
+def before_request():
+ if current_user.is_authenticated:
+ UserController(current_user.id).update(
+ {'id': current_user.id}, {'last_seen': datetime.utcnow()})
+
+@current_app.route('/login', methods=['GET', 'POST'])
+def login():
+ if current_user.is_authenticated:
+ return redirect(url_for('home'))
+ form = SigninForm()
+ if form.validate_on_submit():
+ login_user_bundle(form.user)
+ return form.redirect('home')
+ return render_template('login.html', form=form)
+
+
+@current_app.route('/logout')
+@login_required
+def logout():
+ # Remove the user information from the session
+ logout_user()
+
+ # Remove session keys set by Flask-Principal
+ for key in ('identity.name', 'identity.auth_type'):
+ session.pop(key, None)
+
+ # Tell Flask-Principal the user is anonymous
+ identity_changed.send(current_app, identity=AnonymousIdentity())
+ session_identity_loader()
+
+ return redirect(url_for('login'))
+
+
+@current_app.route('/signup', methods=['GET', 'POST'])
+def signup():
+ if not conf.SELF_REGISTRATION:
+ flash(gettext('Self-registration is disabled.'), 'warning')
+ return redirect(url_for('home'))
+ if current_user.is_authenticated:
+ return redirect(url_for('home'))
+
+ form = SignupForm()
+ if form.validate_on_submit():
+ user = UserController().create(nickname=form.nickname.data,
+ pwdhash=generate_password_hash(form.password.data))
+
+ # Send the confirmation email
+ try:
+ notifications.new_account_notification(user, form.email.data)
+ except Exception as error:
+ flash(gettext('Problem while sending activation email: %(error)s',
+ error=error), 'danger')
+ return redirect(url_for('home'))
+
+ flash(gettext('Your account has been created. '
+ 'Check your mail to confirm it.'), 'success')
+
+ return redirect(url_for('home'))
+
+ return render_template('signup.html', form=form)
diff --git a/newspipe/web/views/user.py b/newspipe/web/views/user.py
new file mode 100644
index 00000000..24b73a60
--- /dev/null
+++ b/newspipe/web/views/user.py
@@ -0,0 +1,203 @@
+import string
+import random
+from datetime import datetime, timedelta
+from flask import (Blueprint, g, render_template, redirect,
+ flash, url_for, request)
+from flask_babel import gettext
+from flask_login import login_required, current_user
+from flask_paginate import Pagination, get_page_args
+
+import conf
+from notifications import notifications
+from lib import misc_utils
+from lib.data import import_opml, import_json
+from web.lib.user_utils import confirm_token
+from web.controllers import (UserController, FeedController, ArticleController,
+ CategoryController, BookmarkController)
+
+from web.forms import ProfileForm
+
+users_bp = Blueprint('users', __name__, url_prefix='/users')
+user_bp = Blueprint('user', __name__, url_prefix='/user')
+
+
+@user_bp.route('/<string:nickname>', methods=['GET'])
+def profile_public(nickname=None):
+ """
+ Display the public profile of the user.
+ """
+ category_id = int(request.args.get('category_id', 0))
+ user_contr = UserController()
+ user = user_contr.get(nickname=nickname)
+ if not user.is_public_profile:
+ if current_user.is_authenticated and current_user.id == user.id:
+ flash(gettext('You must set your profile to public.'), 'info')
+ return redirect(url_for('user.profile'))
+
+ filters = {}
+ filters['private'] = False
+ if category_id:
+ filters['category_id'] = category_id
+ feeds = FeedController(user.id).read(**filters)
+
+ return render_template('profile_public.html', user=user, feeds=feeds,
+ selected_category_id=category_id)
+
+
+@user_bp.route('/<string:nickname>/stream', defaults={'per_page': '25'}, methods=['GET'])
+def user_stream(per_page, nickname=None):
+ """
+ Display the stream of a user (list of articles of public feed).
+ """
+ user_contr = UserController()
+ user = user_contr.get(nickname=nickname)
+ if not user.is_public_profile:
+ if current_user.is_authenticated and current_user.id == user.id:
+ flash(gettext('You must set your profile to public.'), 'info')
+ return redirect(url_for('user.profile'))
+
+ category_id = int(request.args.get('category_id', 0))
+ category = CategoryController().read(id=category_id).first()
+
+ # Load the public feeds
+ filters = {}
+ filters['private'] = False
+ if category_id:
+ filters['category_id'] = category_id
+ feeds = FeedController().read(**filters).all()
+
+ # Re-initializes the filters to load the articles
+ filters = {}
+ filters['feed_id__in'] = [feed.id for feed in feeds]
+ if category:
+ filters['category_id'] = category_id
+ articles = ArticleController(user.id).read_light(**filters)
+
+ # Server-side pagination
+ page, per_page, offset = get_page_args(per_page_parameter='per_page')
+ pagination = Pagination(page=page, total=articles.count(),
+ css_framework='bootstrap3',
+ search=False, record_name='articles',
+ per_page=per_page)
+
+ return render_template('user_stream.html', user=user,
+ articles=articles.offset(offset).limit(per_page),
+ category=category,
+ pagination=pagination)
+
+
+@user_bp.route('/management', methods=['GET', 'POST'])
+@login_required
+def management():
+ """
+ Display the management page.
+ """
+ if request.method == 'POST':
+ if None != request.files.get('opmlfile', None):
+ # Import an OPML file
+ data = request.files.get('opmlfile', None)
+ if not misc_utils.allowed_file(data.filename):
+ flash(gettext('File not allowed.'), 'danger')
+ else:
+ try:
+ nb = import_opml(current_user.nickname, data.read())
+ if conf.CRAWLING_METHOD == "classic":
+ misc_utils.fetch(current_user.id, None)
+ flash(str(nb) + ' ' + gettext('feeds imported.'),
+ "success")
+ flash(gettext("Downloading articles..."), 'info')
+ except:
+ flash(gettext("Impossible to import the new feeds."),
+ "danger")
+ elif None != request.files.get('jsonfile', None):
+ # Import an account
+ data = request.files.get('jsonfile', None)
+ if not misc_utils.allowed_file(data.filename):
+ flash(gettext('File not allowed.'), 'danger')
+ else:
+ try:
+ nb = import_json(current_user.nickname, data.read())
+ flash(gettext('Account imported.'), "success")
+ except:
+ flash(gettext("Impossible to import the account."),
+ "danger")
+ else:
+ flash(gettext('File not allowed.'), 'danger')
+
+ nb_feeds = FeedController(current_user.id).read().count()
+ art_contr = ArticleController(current_user.id)
+ nb_articles = art_contr.read().count()
+ nb_unread_articles = art_contr.read(readed=False).count()
+ nb_categories = CategoryController(current_user.id).read().count()
+ nb_bookmarks = BookmarkController(current_user.id).read().count()
+ return render_template('management.html', user=current_user,
+ nb_feeds=nb_feeds, nb_articles=nb_articles,
+ nb_unread_articles=nb_unread_articles,
+ nb_categories=nb_categories,
+ nb_bookmarks=nb_bookmarks)
+
+
+@user_bp.route('/profile', methods=['GET', 'POST'])
+@login_required
+def profile():
+ """
+ Edit the profile of the currently logged user.
+ """
+ user_contr = UserController(current_user.id)
+ user = user_contr.get(id=current_user.id)
+ form = ProfileForm()
+
+ if request.method == 'POST':
+ if form.validate():
+ try:
+ user_contr.update({'id': current_user.id},
+ {'nickname': form.nickname.data,
+ 'password': form.password.data,
+ 'automatic_crawling': form.automatic_crawling.data,
+ 'is_public_profile': form.is_public_profile.data,
+ 'bio': form.bio.data,
+ 'webpage': form.webpage.data,
+ 'twitter': form.twitter.data})
+ except Exception as error:
+ flash(gettext('Problem while updating your profile: '
+ '%(error)s', error=error), 'danger')
+ else:
+ flash(gettext('User %(nick)s successfully updated',
+ nick=user.nickname), 'success')
+ return redirect(url_for('user.profile'))
+ else:
+ return render_template('profile.html', user=user, form=form)
+
+ if request.method == 'GET':
+ form = ProfileForm(obj=user)
+ return render_template('profile.html', user=user, form=form)
+
+
+@user_bp.route('/delete_account', methods=['GET'])
+@login_required
+def delete_account():
+ """
+ Delete the account of the user (with all its data).
+ """
+ UserController(current_user.id).delete(current_user.id)
+ flash(gettext('Your account has been deleted.'), 'success')
+ return redirect(url_for('login'))
+
+
+@user_bp.route('/confirm_account/<string:token>', methods=['GET'])
+def confirm_account(token=None):
+ """
+ Confirm the account of a user.
+ """
+ user_contr = UserController()
+ user, nickname = None, None
+ if token != "":
+ nickname = confirm_token(token)
+ if nickname:
+ user = user_contr.read(nickname=nickname).first()
+ if user is not None:
+ user_contr.update({'id': user.id}, {'is_active': True})
+ flash(gettext('Your account has been confirmed.'), 'success')
+ else:
+ flash(gettext('Impossible to confirm this account.'), 'danger')
+ return redirect(url_for('login'))
diff --git a/newspipe/web/views/views.py b/newspipe/web/views/views.py
new file mode 100644
index 00000000..57f790b1
--- /dev/null
+++ b/newspipe/web/views/views.py
@@ -0,0 +1,95 @@
+import sys
+import logging
+import operator
+from datetime import datetime, timedelta
+from flask import (request, render_template, flash,
+ url_for, redirect, current_app)
+from flask_babel import gettext
+from sqlalchemy import desc
+
+import conf
+from web import __version__
+from conf import API_ROOT, ADMIN_EMAIL
+from web.controllers import FeedController, UserController
+from web.lib.view_utils import etag_match
+
+logger = logging.getLogger(__name__)
+
+
+@current_app.errorhandler(401)
+def authentication_required(error):
+ if API_ROOT in request.url:
+ return error
+ flash(gettext('Authentication required.'), 'info')
+ return redirect(url_for('login'))
+
+
+@current_app.errorhandler(403)
+def authentication_failed(error):
+ if API_ROOT in request.url:
+ return error
+ flash(gettext('Forbidden.'), 'danger')
+ return redirect(url_for('login'))
+
+
+@current_app.errorhandler(404)
+def page_not_found(error):
+ return render_template('errors/404.html'), 404
+
+
+@current_app.errorhandler(500)
+def internal_server_error(error):
+ return render_template('errors/500.html'), 500
+
+
+@current_app.errorhandler(AssertionError)
+def handle_sqlalchemy_assertion_error(error):
+ return error.args[0], 400
+
+
+@current_app.route('/popular', methods=['GET'])
+@etag_match
+def popular():
+ """
+ Return the most popular feeds for the last nb_days days.
+ """
+ # try to get the 'recent' popular websites, created after
+ # 'not_created_before'
+ # ie: not_added_before = date_last_added_feed - nb_days
+ try:
+ nb_days = int(request.args.get('nb_days', 365))
+ except ValueError:
+ nb_days = 10000
+ last_added_feed = FeedController().read().\
+ order_by(desc('created_date')).limit(1).all()
+ if last_added_feed:
+ date_last_added_feed = last_added_feed[0].created_date
+ else:
+ date_last_added_feed = datetime.now()
+ not_added_before = date_last_added_feed - timedelta(days=nb_days)
+
+ filters = {}
+ filters['created_date__gt'] = not_added_before
+ filters['private'] = False
+ filters['error_count__lt'] = conf.DEFAULT_MAX_ERROR
+ feeds = FeedController().count_by_link(**filters)
+ sorted_feeds = sorted(list(feeds.items()), key=operator.itemgetter(1),
+ reverse=True)
+ return render_template('popular.html', popular=sorted_feeds)
+
+
+@current_app.route('/about', methods=['GET'])
+@etag_match
+def about():
+ return render_template('about.html', contact=ADMIN_EMAIL)
+
+@current_app.route('/about/more', methods=['GET'])
+@etag_match
+def about_more():
+ return render_template('about_more.html',
+ newspipe_version=__version__.split()[1],
+ on_heroku=[conf.ON_HEROKU and 'Yes' or 'No'][0],
+ registration=[conf.SELF_REGISTRATION and 'Open' or 'Closed'][0],
+ python_version="{}.{}.{}".format(*sys.version_info[:3]),
+ nb_users=UserController().read().count())
+
bgstack15