aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/web/controllers/article.py21
-rw-r--r--src/web/controllers/feed.py33
-rw-r--r--src/web/lib/utils.py16
-rw-r--r--src/web/templates/article.html2
-rw-r--r--src/web/templates/duplicates.html4
-rw-r--r--src/web/templates/feed_list.html2
-rw-r--r--src/web/templates/history.html8
-rw-r--r--src/web/templates/inactives.html2
-rw-r--r--src/web/templates/layout.html4
-rwxr-xr-xsrc/web/utils.py1
-rw-r--r--src/web/views/article.py81
-rw-r--r--src/web/views/feed.py26
-rw-r--r--src/web/views/views.py117
13 files changed, 192 insertions, 125 deletions
diff --git a/src/web/controllers/article.py b/src/web/controllers/article.py
index 3d8d5c01..72288a09 100644
--- a/src/web/controllers/article.py
+++ b/src/web/controllers/article.py
@@ -1,6 +1,8 @@
import re
import logging
+import sqlalchemy
from sqlalchemy import func
+from collections import Counter
from bootstrap import db
from .abstract import AbstractController
@@ -70,3 +72,22 @@ class ArticleController(AbstractController):
attrs['link'])
return super().create(**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
diff --git a/src/web/controllers/feed.py b/src/web/controllers/feed.py
index 3b05b294..b76c4e42 100644
--- a/src/web/controllers/feed.py
+++ b/src/web/controllers/feed.py
@@ -20,12 +20,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
+import itertools
from datetime import datetime, timedelta
import conf
from .abstract import AbstractController
from .icon import IconController
from web.models import Feed
+from web.lib.utils import clear_string
logger = logging.getLogger(__name__)
DEFAULT_LIMIT = 5
@@ -54,6 +56,37 @@ class FeedController(AbstractController):
{'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, 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
+ 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 _ensure_icon(self, attrs):
if not attrs.get('icon_url'):
return
diff --git a/src/web/lib/utils.py b/src/web/lib/utils.py
index aa552a12..88d24ba5 100644
--- a/src/web/lib/utils.py
+++ b/src/web/lib/utils.py
@@ -1,8 +1,10 @@
+import re
import types
import urllib
import logging
import requests
from hashlib import md5
+from flask import request, url_for
logger = logging.getLogger(__name__)
@@ -55,3 +57,17 @@ def try_get_icon_url(url, *splits):
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)
diff --git a/src/web/templates/article.html b/src/web/templates/article.html
index b1f950b4..6ab9d0bb 100644
--- a/src/web/templates/article.html
+++ b/src/web/templates/article.html
@@ -4,7 +4,7 @@
<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="/delete/{{ article.id }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a>
+ <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 %}
diff --git a/src/web/templates/duplicates.html b/src/web/templates/duplicates.html
index 32fbdf9a..d944a1a5 100644
--- a/src/web/templates/duplicates.html
+++ b/src/web/templates/duplicates.html
@@ -19,8 +19,8 @@
{% for pair in duplicates %}
<tr>
<td>{{ loop.index }}</td>
- <td id="{{ pair[0].id }}"><a href="/delete/{{ 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="/delete/{{ 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>
+ <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>
diff --git a/src/web/templates/feed_list.html b/src/web/templates/feed_list.html
index c5cadab0..27815250 100644
--- a/src/web/templates/feed_list.html
+++ b/src/web/templates/feed_list.html
@@ -36,7 +36,7 @@
<td>
<a href="{{ url_for("home", feed_id=feed.id, filter_="all") }}"><i class="glyphicon glyphicon-th-list" title="{{ _('Articles') }}"></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="/duplicates/{{ feed.id }}"><i class="glyphicon glyphicon-book" title="{{ _('Duplicate articles') }}"></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>
diff --git a/src/web/templates/history.html b/src/web/templates/history.html
index 499fe50b..caa0b057 100644
--- a/src/web/templates/history.html
+++ b/src/web/templates/history.html
@@ -3,18 +3,18 @@
<div class="container">
<h1>{{ _('History') }}</h1>
{% if month != None %}
- <h2><a href="/history/{{ year }}"><span class="glyphicon glyphicon-chevron-left"></span> {{ year }}</a></h2>
+ <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="/history"><span class="glyphicon glyphicon-chevron-left"></span>&nbsp{{ _('all years') }}</a></h2>
+ <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="/history/{{ article }}">{{ article }}</a> : {{ articles_counter[article] }} articles</li>
+ <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="/history/{{ year }}/{{ article }}">{{ article | month_name }}</a> : {{ articles_counter[article] }} articles</li>
+ <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 | sort(attribute="date", reverse = True) %}
<li class="list-group-item">{{ article.date | datetime }} - <a href="/article/{{ article.id }}">{{ article.title | safe }}</a></li>
diff --git a/src/web/templates/inactives.html b/src/web/templates/inactives.html
index eb546eca..e89a5fe1 100644
--- a/src/web/templates/inactives.html
+++ b/src/web/templates/inactives.html
@@ -2,7 +2,7 @@
{% block content %}
<div class="container">
<div class="well">
- <form method=get action="/inactives">
+ <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>
diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html
index 15cf3e97..cf2498e2 100644
--- a/src/web/templates/layout.html
+++ b/src/web/templates/layout.html
@@ -71,8 +71,8 @@
<li><a href="{{ url_for("feeds.update", action="read", nb_days="5") }}">{{ gettext('Mark all as read older than %(days)s days', days=5) }}</a></li>
<li><a href="{{ url_for("feeds.update", action="read", nb_days="10") }}">{{ gettext('Mark all as read older than %(days)s days', days=10) }}</a></li>
<li role="presentation" class="divider"></li>
- <li><a href="{{ url_for("inactives") }}">{{ _('Inactive') }}</a></li>
- <li><a href="{{ url_for("history") }}">{{ _('History') }}</a></li>
+ <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>
</ul>
</li>
diff --git a/src/web/utils.py b/src/web/utils.py
index 91e2bd9f..38d15f25 100755
--- a/src/web/utils.py
+++ b/src/web/utils.py
@@ -61,6 +61,7 @@ from flask import g
from bootstrap import application as app, db
from web import controllers
from web.models import User, Feed, Article
+from web.lib.utils import clear_string
logger = logging.getLogger(__name__)
diff --git a/src/web/views/article.py b/src/web/views/article.py
index bb914a6b..d308d37e 100644
--- a/src/web/views/article.py
+++ b/src/web/views/article.py
@@ -1,31 +1,32 @@
#! /usr/bin/env python
# -*- coding: utf-8 -
+from flask import Blueprint, g, render_template, redirect, flash, url_for
+from flask.ext.babel import gettext
+from flask.ext.login import login_required
-from flask import Blueprint, g, render_template, redirect
-
-from web import controllers, utils
+from web.lib.utils import clear_string, redirect_url
+from web.controllers import ArticleController
from web.lib.view_utils import etag_match
-from web.decorators import pyagg_default_decorator
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'])
-@pyagg_default_decorator
+@login_required
def redirect_to_article(article_id):
- article = controllers.ArticleController(g.user.id).get(id=article_id)
+ article = ArticleController(g.user.id).get(id=article_id)
return redirect(article.link)
@article_bp.route('/<int:article_id>', methods=['GET'])
-@pyagg_default_decorator
+@login_required
@etag_match
def article(article_id=None):
"""
Presents the content of an article.
"""
- article = controllers.ArticleController(g.user.id).get(id=article_id)
+ article = ArticleController(g.user.id).get(id=article_id)
previous_article = article.previous_article()
if previous_article is None:
previous_article = article.source.articles[0]
@@ -34,7 +35,69 @@ def article(article_id=None):
next_article = article.source.articles[-1]
return render_template('article.html',
- head_titles=[utils.clear_string(article.title)],
+ head_titles=[clear_string(article.title)],
article=article,
previous_article=previous_article,
next_article=next_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(g.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(g.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):
+ counter, articles = ArticleController(g.user.id).get_history(year, month)
+ return render_template('history.html', articles_counter=counter,
+ articles=articles, 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(g.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')
diff --git a/src/web/views/feed.py b/src/web/views/feed.py
index 68d8765a..6569d1b7 100644
--- a/src/web/views/feed.py
+++ b/src/web/views/feed.py
@@ -217,3 +217,29 @@ def process_form(feed_id=None):
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(g.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(g.user.id).get_duplicates(feed_id)
+ if len(duplicates) == 0:
+ flash(gettext('No duplicates in the feed "{}".').format(feed.title),
+ 'info')
+ return redirect('home')
+ return render_template('duplicates.html', duplicates=duplicates, feed=feed)
diff --git a/src/web/views/views.py b/src/web/views/views.py
index ed4ae8a7..1e7b7539 100644
--- a/src/web/views/views.py
+++ b/src/web/views/views.py
@@ -38,8 +38,7 @@ from bootstrap import application as app, db
from flask import render_template, request, flash, session, \
url_for, redirect, g, current_app, make_response
from flask.ext.login import LoginManager, login_user, logout_user, \
- login_required, current_user, AnonymousUserMixin, \
- login_url
+ login_required, current_user, AnonymousUserMixin
from flask.ext.principal import Principal, Identity, AnonymousIdentity, \
identity_changed, identity_loaded, Permission,\
RoleNeed, UserNeed
@@ -49,6 +48,7 @@ from sqlalchemy.exc import IntegrityError
from werkzeug import generate_password_hash
import conf
+from web.lib.utils import redirect_url
from web import utils, notifications, export
from web.lib.view_utils import etag_match
from web.models import User, Feed, Article, Role
@@ -90,6 +90,7 @@ def on_identity_loaded(sender, identity):
for role in current_user.roles:
identity.provides.add(RoleNeed(role.name))
+
@app.before_request
def before_request():
g.user = current_user
@@ -98,6 +99,7 @@ def before_request():
db.session.add(g.user)
db.session.commit()
+
@login_manager.user_loader
def load_user(id):
# Return an instance of the User model
@@ -111,15 +113,18 @@ def authentication_required(e):
flash(gettext('Authentication required.'), 'info')
return redirect(url_for('login'))
+
@app.errorhandler(403)
def authentication_failed(e):
flash(gettext('Forbidden.'), 'danger')
return redirect(url_for('login'))
+
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html'), 404
+
@app.errorhandler(500)
def internal_server_error(e):
return render_template('errors/500.html'), 500
@@ -138,6 +143,7 @@ def get_locale():
"""
return request.accept_languages.best_match(conf.LANGUAGES.keys())
+
@g.babel.timezoneselector
def get_timezone():
try:
@@ -167,6 +173,7 @@ def login():
return form.redirect('home')
return render_template('login.html', form=form)
+
@app.route('/logout')
@login_required
def logout():
@@ -367,97 +374,6 @@ def about():
return render_template('about.html')
-@app.route('/mark_as/<string:new_value>', methods=['GET'])
-@app.route('/mark_as/<string:new_value>/article/<int:article_id>', methods=['GET'])
-@login_required
-@feed_access_required
-def mark_as(new_value='read', feed_id=None, article_id=None):
- """
- Mark all unreaded articles as read.
- """
- readed = new_value == 'read'
- articles = Article.query.filter(Article.user_id == g.user.id)
- if feed_id is not None:
- articles = articles.filter(Article.feed_id == feed_id)
- message = 'Feed marked as %s.'
- elif article_id is not None:
- articles = articles.filter(Article.id == article_id)
- message = 'Article marked as %s.'
- else:
- message = 'All article marked as %s.'
- articles.filter(Article.readed == (not readed)).update({"readed": readed})
- flash(gettext(message % new_value), 'info')
- db.session.commit()
- if readed:
- return redirect(redirect_url())
- return redirect(url_for('home'))
-
-@app.route('/like/<int:article_id>', methods=['GET'])
-@login_required
-def like(article_id=None):
- """
- Mark or unmark an article as favorites.
- """
- Article.query.filter(Article.user_id == g.user.id, Article.id == article_id). \
- update({
- "like": not Article.query.filter(Article.id == article_id).first().like
- })
- db.session.commit()
- return redirect(redirect_url())
-
-@app.route('/delete/<int:article_id>', methods=['GET'])
-@login_required
-def delete(article_id=None):
- """
- Delete an article from the database.
- """
- article = Article.query.filter(Article.id == article_id).first()
- if article is not None and article.source.subscriber.id == g.user.id:
- db.session.delete(article)
- db.session.commit()
- flash(gettext('Article') + ' ' + article.title + ' ' + gettext('deleted.'), 'success')
- return redirect(redirect_url())
- else:
- flash(gettext('This article do not exist.'), 'danger')
- return redirect(url_for('home'))
-
-
-@app.route('/inactives', methods=['GET'])
-@login_required
-def inactives():
- """
- List of inactive feeds.
- """
- nb_days = int(request.args.get('nb_days', 365))
- user = UserController(g.user.id).get(email=g.user.email)
- today = datetime.datetime.now()
- inactives = []
- for feed in user.feeds:
- try:
- last_post = feed.articles[0].date
- except IndexError:
- continue
- elapsed = today - last_post
- if elapsed > datetime.timedelta(days=nb_days):
- inactives.append((feed, elapsed))
- inactives.sort(key=lambda tup: tup[1], reverse=True)
- return render_template('inactives.html', inactives=inactives, nb_days=nb_days)
-
-@app.route('/duplicates/<int:feed_id>', methods=['GET'])
-@login_required
-def duplicates(feed_id=None):
- """
- Return duplicates article for a feed.
- """
- feed = Feed.query.filter(Feed.user_id == g.user.id, Feed.id == feed_id).first()
- duplicates = []
- duplicates = utils.compare_documents(feed)
- if len(duplicates) == 0:
- flash(gettext('No duplicates in the feed "{}".').format(feed.title),
- 'info')
- return redirect(redirect_url())
- return render_template('duplicates.html', duplicates=duplicates, feed=feed)
-
@app.route('/export', methods=['GET'])
@login_required
def export_articles():
@@ -485,12 +401,14 @@ def export_articles():
return redirect(redirect_url())
response = make_response(json_result)
response.mimetype = 'application/json'
- response.headers["Content-Disposition"] = 'attachment; filename=account.json'
+ response.headers["Content-Disposition"] \
+ = 'attachment; filename=account.json'
else:
flash(gettext('Export format not supported.'), 'warning')
return redirect(redirect_url())
return response
+
@app.route('/export_opml', methods=['GET'])
@login_required
def export_opml():
@@ -551,17 +469,6 @@ def management():
nb_feeds=nb_feeds, nb_articles=nb_articles,
nb_unread_articles=nb_unread_articles)
-@app.route('/history', methods=['GET'])
-@app.route('/history/<int:year>', methods=['GET'])
-@app.route('/history/<int:year>/<int:month>', methods=['GET'])
-@login_required
-def history(year=None, month=None):
- articles_counter, articles = utils.history(g.user.id, year, month)
- return render_template('history.html',
- articles_counter=articles_counter,
- articles=articles,
- year=year, month=month)
-
@app.route('/profile', methods=['GET', 'POST'])
@login_required
bgstack15