The code is a bit hacked together and messy, mostly because I find writing Python and JavaScript concurrently very distracting.
--- /dev/null
+[submodule "media/reader/extjs"]
+ path = media/reader/extjs
+ url = git://github.com/probonogeek/extjs.git
+[submodule "media/reader/fugue-icons"]
+ path = media/reader/fugue-icons
+ url = git://git.ithinksw.org/fugue-icons.git
--- /dev/null
+Prerequisites (only tested on):
+ * Django 1.2.5+ <http://www.djangoproject.com/>
+ * django-staticmedia 0.2.2+ <http://pypi.python.org/pypi/django-staticmedia/>
+ * south 0.7.3+ <http://south.aeracode.org/>
+
--- /dev/null
+Subproject commit 530ef4b6c5b943cfa68b779d11cf7de29aa878bf
--- /dev/null
+Subproject commit 764abbef432ef8a7333a58ca21b0ef6ef0a80b1a
--- /dev/null
+.icon-add-feed {
+ background: url(fugue-icons/icons/feed--plus.png) 0 no-repeat !important;
+}
--- /dev/null
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+
+ # Adding model 'UserEntry'
+ db.create_table('reader_userentry', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='reader_userentries', to=orm['auth.User'])),
+ ('entry', self.gf('django.db.models.fields.related.ForeignKey')(related_name='userentries', to=orm['reader.Entry'])),
+ ('read', self.gf('django.db.models.fields.BooleanField')(default=False)),
+ ))
+ db.send_create_signal('reader', ['UserEntry'])
+
+
+ def backwards(self, orm):
+
+ # Deleting model 'UserEntry'
+ db.delete_table('reader_userentry')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'reader.entry': {
+ 'Meta': {'ordering': "['-published']", 'object_name': 'Entry'},
+ 'content': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'feed': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'entries'", 'to': "orm['reader.Feed']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'link': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}),
+ 'published': ('django.db.models.fields.DateTimeField', [], {}),
+ 'summary': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
+ 'uri': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+ },
+ 'reader.feed': {
+ 'Meta': {'object_name': 'Feed'},
+ 'alive': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'etag': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'link': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+ 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
+ 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
+ },
+ 'reader.subscription': {
+ 'Meta': {'object_name': 'Subscription'},
+ 'custom_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+ 'feed': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'subscriptions'", 'to': "orm['reader.Feed']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reader_subscriptions'", 'to': "orm['auth.User']"})
+ },
+ 'reader.userentry': {
+ 'Meta': {'object_name': 'UserEntry'},
+ 'entry': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'userentries'", 'to': "orm['reader.Entry']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'read': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reader_userentries'", 'to': "orm['auth.User']"})
+ }
+ }
+
+ complete_apps = ['reader']
@property
def title(self):
- return self.custom_title if self.custom_title else self.feed.title
\ No newline at end of file
+ return self.custom_title if self.custom_title else self.feed.title
+
+ def __unicode__(self):
+ return u'%s <%s>' % (self.user, self.feed)
+
+
+class UserEntry(models.Model):
+ user = models.ForeignKey(User, related_name='reader_userentries')
+ entry = models.ForeignKey(Entry, related_name='userentries')
+ read = models.BooleanField(default=False)
+
+ class Meta:
+ verbose_name = 'user entry relationship'
+ verbose_name_plural = 'user entry relationships'
+
+ def __unicode__(self):
+ return u'%s <%s>' % (self.user, self.entry)
\ No newline at end of file
{% extends 'reader/base.html' %}
+{% load staticmedia %}
-{% block title %}{{ block.super }} - Home{% endblock %}
-
-{% block body %}
- <h1>Feeds</h1>
- {% for feed in feeds %}
- {% if forloop.first %}<ul>{% endif %}
- <li><a href="{{ feed.link }}">{{ feed.title }}</a></li>
- {% if forloop.last %}</ul>{% endif %}
- {% endfor %}
- <h1>Entries</h1>
- {% for entry in entries %}
- {% include 'reader/includes/entry.html' %}
- {% endfor %}
-{% endblock %}
\ No newline at end of file
+{% block extrahead %}
+<link rel="stylesheet" href="{% mediaurl 'reader/extjs/resources/css/ext-all.css' %}" type="text/css" />
+<link rel="stylesheet" href="{% mediaurl 'reader/icons.css' %}" type="text/css" />
+<script src="{% mediaurl 'reader/extjs/adapter/ext/ext-base.js' %}" type="text/javascript"></script>
+<script src="{% mediaurl 'reader/extjs/ext-all.js' %}" type="text/javascript"></script>
+<script src="{% url reader_lib %}" type="text/javascript"></script>
+<script type="text/javascript">
+ Ext.onReady(function() {
+ READER = new Reader.Application();
+ });
+</script>
+{% endblock %}\
\ No newline at end of file
--- /dev/null
+Ext.ns('Reader');
+
+
+Reader.SubscriptionPanel = Ext.extend(Ext.Panel, {
+ constructor: function(application, config) {
+ this.application = application;
+ var self = this;
+
+ var listView = this.listView = new Ext.list.ListView({
+ emptyText: 'No Subscriptions',
+ store: this.application.subscription_store,
+ columns: [
+ {
+ header: 'Title',
+ dataIndex: 'title',
+ width: .75,
+ },
+ {
+ header: 'Unread',
+ dataIndex: 'unread',
+ align: 'right',
+ },
+ ],
+ hideHeaders: true,
+ singleSelect: true,
+ loadingText: 'Loading...',
+ });
+
+ Reader.SubscriptionPanel.superclass.constructor.call(this, Ext.applyIf(config||{}, {
+ title: 'Subscriptions',
+ items: [
+ this.listView,
+ ],
+ bbar: [
+ {
+ text: 'Add Feed...',
+ iconCls: 'icon-add-feed',
+ handler: function() {
+ Ext.MessageBox.prompt('Add Feed', 'Enter the URL to the feed:', function(button, text) {
+ if (button == 'ok') {
+ Ext.Ajax.request({
+ url: '{% url reader_add_subscription %}',
+ params: {
+ 'url': text,
+ 'csrfmiddlewaretoken': '{% with csrf_token as csrf_token_clean %}{{ csrf_token_clean }}{% endwith %}',
+ },
+ success: function() {
+ self.application.subscription_store.reload();
+ },
+ failure: function() {
+ alert('Unable to add feed.');
+ },
+ });
+ }
+ });
+ },
+ },'->',{
+ text: 'Refresh',
+ handler: function() {
+ self.application.subscription_store.reload();
+ },
+ },
+ ],
+ collapsible: true,
+ }));
+
+ listView.on('selectionchange', function(listView, selections) {
+ if (selections.length > 0) {
+ self.application.selected_subscription(listView.getSelectedRecords()[0]['data']);
+ }
+ });
+ },
+});
+
+
+Reader.EntryPanel = Ext.extend(Ext.Panel, {
+ constructor: function(application, config) {
+ this.application = application;
+ var self = this;
+
+ var gridView = this.gridView = new Ext.grid.GridPanel({
+ store: this.application.entry_store,
+ columns: [
+ {
+ header: 'Title',
+ dataIndex: 'title',
+ },
+ {
+ header: 'Date',
+ dataIndex: 'date',
+ },
+ ],
+ viewConfig: {
+ forceFit: true,
+ },
+ sm: new Ext.grid.RowSelectionModel({singleSelect: true}),
+ region: 'north',
+ split: true,
+ height: 200,
+ minSize: 150,
+ maxSize: 400,
+ });
+
+ var entryDetailTpl = this.entryDetailTpl = new Ext.Template(
+ '<h1><a href="{link}" target="_blank">{title}</a></h1>',
+ '<p style="font-size: smaller;">{date}</p>',
+ '<p>{content}</p>'
+ );
+
+ var entryDetail = this.entryDetail = {
+ id: 'entryDetail',
+ region: 'center',
+ bodyStyle: {
+ background: 'white',
+ padding: '7px',
+ color: 'black',
+ },
+ preventBodyReset: true,
+ html: 'Select an entry to view',
+ autoScroll: true,
+ };
+
+ gridView.getSelectionModel().on('rowselect', function(sm, rowIdx, r) {
+ var detailPanel = Ext.getCmp('entryDetail');
+ entryDetailTpl.overwrite(detailPanel.body, r.data);
+
+ Ext.Ajax.request({
+ url: '{% url reader_read_entry %}',
+ params: {
+ 'entry_id': r.data['id'],
+ 'csrfmiddlewaretoken': '{% with csrf_token as csrf_token_clean %}{{ csrf_token_clean }}{% endwith %}',
+ },
+ success: function() {
+ self.application.subscription_store.reload();
+ },
+ failure: function() {
+ console.log('Unable to set read entry');
+ },
+ });
+ });
+
+ Reader.EntryPanel.superclass.constructor.call(this, Ext.applyIf(config||{}, {
+ title: 'Entries',
+ header: true,
+ items: [ this.gridView, this.entryDetail ],
+ layout: 'border',
+ }));
+ },
+});
+
+
+Reader.Application = Ext.extend(Ext.util.Observable, {
+ constructor: function(config) {
+ Ext.apply(this, config, {
+ renderTo: Ext.getBody(),
+ });
+ Reader.Application.superclass.constructor.call(this);
+ this.init();
+ },
+ init: function() {
+ Ext.QuickTips.init();
+
+ var subscription_store = this.subscription_store = new Ext.data.JsonStore({
+ autoLoad: true,
+ autoDestroy: true,
+ url: '{% url reader_get_subscriptions %}',
+ root: 'root',
+ totalProperty: 'len',
+ fields: [
+ 'id',
+ 'title',
+ 'unread',
+ ]
+ });
+
+ var entry_store = this.entry_store = new Ext.data.JsonStore({
+ url: '{% url reader_get_entries %}',
+ baseParams: {
+ 'subscription_id': '-2',
+ 'csrfmiddlewaretoken': '{% with csrf_token as csrf_token_clean %}{{ csrf_token_clean }}{% endwith %}',
+ },
+ root: 'root',
+ totalProperty: 'len',
+ fields: [
+ 'id', 'title', 'date', 'content', 'link'
+ ],
+ });
+
+ var subscription_panel = this.subscription_panel = new Reader.SubscriptionPanel(this, {
+ region: 'west',
+ split: true,
+ width: 200,
+ minSize: 150,
+ maxSize: 400,
+ });
+ var entry_panel = this.entry_panel = new Reader.EntryPanel(this, {
+ region: 'center',
+ autoScroll: true,
+ });
+ var viewport = this.viewport = new Ext.Viewport({
+ layout: 'border',
+ renderTo: this.renderTo,
+ items: [
+ this.subscription_panel,
+ this.entry_panel,
+ ],
+ });
+
+ this.viewport.doLayout();
+ },
+ selected_subscription: function(subscription) {
+ this.entry_panel.setTitle(subscription['title']);
+ this.entry_store.reload({ params: {
+ 'subscription_id': subscription['id'],
+ }});
+ }
+});
\ No newline at end of file
-from .views import home
+from .views import home, lib, get_subscriptions, add_subscription, get_entries, read_entry
from django.conf.urls.defaults import patterns, url
urlpatterns = patterns('',
url(r'^$', home, name='reader_home'),
+ url(r'^lib.js$', lib, name='reader_lib'),
+ url(r'^get_subscriptions$', get_subscriptions, name='reader_get_subscriptions'),
+ url(r'^add_subscription$', add_subscription, name='reader_add_subscription'),
+ url(r'^get_entries$', get_entries, name='reader_get_entries'),
+ url(r'^read_entry$', read_entry, name='reader_read_entry'),
)
\ No newline at end of file
-from .models import Feed, Entry
+from .models import Feed, Entry, Subscription, UserEntry
import datetime
import feedparser
refresh_feed(feed)
-def add_feed(url):
- feed = Feed(url=url)
+def add_feed(url, user=None):
+ try:
+ feed = Feed.objects.get(url=url)
+ except Feed.DoesNotExist:
+ feed = Feed(url=url)
refresh_feed(feed)
- return feed
\ No newline at end of file
+ if user is not None:
+ sub, _ = Subscription.objects.get_or_create(user=user, feed=feed)
+ return feed
+
+
+def unread_count(user, feed=None):
+ if feed:
+ entries = feed.entries
+ else:
+ entries = Entry.objects.filter(feed__subscriptions__user=user)
+ total_entries = entries.count()
+ read_entries = entries.filter(userentries__user=user, userentries__read=True).count()
+ return total_entries - read_entries
+
\ No newline at end of file
-from .models import Feed, Entry
+from .models import Feed, Entry, Subscription, UserEntry
from django.shortcuts import render_to_response
from django.template import RequestContext
+from django.http import HttpResponse
+from django.contrib.auth.decorators import login_required
+from django.utils import simplejson as json
+from django.db.models import Q
+from .utils import add_feed, unread_count
+@login_required
+def lib(request):
+ return render_to_response('reader/lib.js', context_instance=RequestContext(request), mimetype='text/javascript')
+
+
+@login_required
def home(request):
return render_to_response('reader/home.html', {
- 'entries': Entry.objects.all(),
- 'feeds': Feed.objects.all(),
- }, context_instance=RequestContext(request))
\ No newline at end of file
+ 'entries': Entry.objects.filter(feed__subscriptions__user=request.user),
+ 'feeds': Feed.objects.filter(subscriptions__user=request.user),
+ }, context_instance=RequestContext(request))
+
+
+@login_required
+def get_subscriptions(request):
+ root = []
+ for subscription in Subscription.objects.filter(user=request.user):
+ root.append({
+ 'id': subscription.pk,
+ 'type': 'feed',
+ 'title': subscription.title,
+ 'unread': unread_count(request.user, feed=subscription.feed)
+ })
+ root.append({
+ 'id': -2,
+ 'type': 'all',
+ 'title': 'All Entries',
+ 'unread': unread_count(request.user),
+ })
+ root.append({
+ 'id': -1,
+ 'type': 'unread',
+ 'title': 'Unread Entries',
+ 'unread': unread_count(request.user),
+ })
+ root.sort(key=lambda sub: sub['id'])
+
+ return HttpResponse(json.dumps({'len': len(root), 'root': root}), mimetype='application/json')
+
+
+@login_required
+def add_subscription(request):
+ try:
+ url = request.POST['url']
+ add_feed(url, user=request.user)
+ return HttpResponse()
+ except:
+ raise Http404
+
+
+@login_required
+def get_entries(request):
+ if 'subscription_id' in request.POST:
+ subscription_id = str(request.POST['subscription_id'])
+ if subscription_id == '-2':
+ entries = Entry.objects.filter(feed__subscriptions__user=request.user)
+ elif subscription_id == '-1':
+ entries = Entry.objects.filter(feed__subscriptions__user=request.user).exclude(userentries__user=request.user, userentries__read=True)
+ elif subscription_id.startswith('tag:'):
+ pass
+ else:
+ try:
+ subscription = Subscription.objects.get(user=request.user, id=subscription_id)
+ entries = subscription.feed.entries.all()
+ except Subscription.DoesNotExist:
+ raise Http404
+ else:
+ raise Http404
+
+ root = []
+ for entry in entries:
+ root.append({
+ 'id': entry.pk,
+ 'title': entry.title,
+ 'date': entry.published.isoformat(),
+ 'content': entry.content if entry.content else entry.summary,
+ 'link': entry.link if entry.link else entry.feed.link,
+ })
+ root.sort(key=lambda entry: entry['date'])
+ root.reverse()
+
+ return HttpResponse(json.dumps({'len': len(root), 'root': root}), mimetype='application/json')
+
+
+@login_required
+def read_entry(request):
+ if 'entry_id' in request.POST:
+ entry = Entry.objects.get(id=request.POST['entry_id'])
+ userentry, created = UserEntry.objects.get_or_create(entry=entry, user=request.user, defaults={ 'read': True })
+ if not created:
+ userentry.read = True
+ userentry.save()
+ else:
+ raise Http404
+
+ return HttpResponse()
\ No newline at end of file