diff --git a/THIRDPARTYLICENSES.md b/THIRDPARTYLICENSES.md
index 2ac46c7d..ec3a6674 100644
--- a/THIRDPARTYLICENSES.md
+++ b/THIRDPARTYLICENSES.md
@@ -32,10 +32,11 @@
## Server
+ * [feedparser](https://github.com/kurtmckee/feedparser): BSD
* [Flask](http://flask.pocoo.org/): BSD
* [Flask-Assets](http://github.com/miracle2k/flask-assets): BSD
* [Flask-Babel](http://github.com/mitsuhiko/flask-babel): BSD
- * [Flask-Login](https://flask-login.readthedocs.org/en/latest/https://github.com/maxcountryman/flask-login): MIT
+ * [Flask-Login](https://github.com/maxcountryman/flask-login): MIT
* [Flask-Markdown](http://github.com/dcolish/flask-markdown): BSD
* [Flask-Principal](http://packages.python.org/Flask-Principal/): MIT
* [netaddr](https://github.com/drkjam/netaddr/): BSD
diff --git a/setup.py b/setup.py
index cdfca852..565113f2 100644
--- a/setup.py
+++ b/setup.py
@@ -35,7 +35,8 @@ INSTALL_REQUIRES = [
"requests==2.7.0",
"semantic_version==2.4.2",
"psutil==3.2.1",
- "awesome-slugify>=1.6.5,<1.7"
+ "awesome-slugify>=1.6.5,<1.7",
+ "feedparser>=5.2.1,<5.3"
]
# Additional requirements for optional install options
diff --git a/src/octoprint/plugins/announcements/__init__.py b/src/octoprint/plugins/announcements/__init__.py
new file mode 100644
index 00000000..33cf6ae1
--- /dev/null
+++ b/src/octoprint/plugins/announcements/__init__.py
@@ -0,0 +1,286 @@
+# coding=utf-8
+from __future__ import absolute_import
+
+__author__ = "Gina Häußge "
+__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
+__copyright__ = "Copyright (C) 2016 The OctoPrint Project - Released under terms of the AGPLv3 License"
+
+
+import octoprint.plugin
+
+import codecs
+import datetime
+import os
+import re
+import time
+import threading
+
+import feedparser
+import flask
+
+from octoprint.server import admin_permission
+from octoprint.server.util.flask import restricted_access
+from flask.ext.babel import gettext
+
+class AnnouncementPlugin(octoprint.plugin.AssetPlugin,
+ octoprint.plugin.SettingsPlugin,
+ octoprint.plugin.BlueprintPlugin,
+ octoprint.plugin.StartupPlugin,
+ octoprint.plugin.TemplatePlugin):
+
+ def __init__(self):
+ self._cached_channels = dict()
+ self._cached_channels_mutex = threading.RLock()
+
+ # StartupPlugin
+
+ def on_after_startup(self):
+ self._fetch_all_channels()
+
+ # SettingsPlugin
+
+ def get_settings_defaults(self):
+ return dict(channels=dict(_important=dict(name="Important OctoPrint Announcements",
+ priority=1,
+ type="rss",
+ url="http://octoprint.org/feeds/important.xml",
+ read_until=1449442800),
+ _releases=dict(name="OctoPrint Release Announcements",
+ priority=2,
+ type="rss",
+ url="http://octoprint.org/feeds/releases.xml",
+ read_until=1458117576),
+ _spotlight=dict(name="OctoPrint Community Spotlights",
+ priority=2,
+ type="rss",
+ url="http://octoprint.org/feeds/spotlight.xml",
+ read_until=1447950371),
+ _octopi=dict(name="OctoPi Announcements",
+ priority=2,
+ type="rss",
+ url="http://octoprint.org/feeds/octopi.xml",
+ read_until=1462197000),
+ _plugins=dict(name="New Plugins in the Repository",
+ priority=2,
+ type="rss",
+ url="http://plugins.octoprint.org/feed.xml",
+ read_until=1461625200)),
+ enabled_channels=[],
+ forced_channels=["_important"],
+ ttl=6*60,
+ display_limit=3,
+ summary_limit=300)
+
+ # AssetPlugin
+
+ def get_assets(self):
+ return dict(js=["js/announcements.js"],
+ less=["less/announcements.less"],
+ css=["css/announcements.css"])
+
+ # Template Plugin
+
+ def get_template_configs(self):
+ return [
+ dict(type="settings", name=gettext("Announcements"), template="announcements_settings.jinja2", custom_bindings=True),
+ dict(type="navbar", template="announcements_navbar.jinja2", styles=["display: none"], data_bind="visible: loginState.isAdmin")
+ ]
+
+ # Blueprint Plugin
+
+ @octoprint.plugin.BlueprintPlugin.route("/channels", methods=["GET"])
+ @restricted_access
+ @admin_permission.require(403)
+ def get_channel_data(self):
+ result = dict()
+
+ channel_data = self._fetch_all_channels()
+
+ channel_configs = self._get_channel_configs()
+ enabled = self._settings.get(["enabled_channels"])
+ forced = self._settings.get(["forced_channels"])
+ for key, data in channel_configs.items():
+ entries = self._to_internal_feed(channel_data.get(key, []), read_until=channel_configs[key].get("read_until", None))
+ unread = len(filter(lambda e: not e["read"], entries))
+
+ result[key] = dict(channel=data["name"],
+ url=data["url"],
+ priority=data["priority"],
+ enabled=key in enabled or key in forced,
+ forced=key in forced,
+ data=entries,
+ unread=unread)
+
+ return flask.jsonify(result)
+
+ @octoprint.plugin.BlueprintPlugin.route("/channels/", methods=["POST"])
+ @restricted_access
+ @admin_permission.require(403)
+ def channel_command(self, channel):
+ from octoprint.server.util.flask import get_json_command_from_request
+ from octoprint.server import NO_CONTENT
+
+ valid_commands = dict(read=["until"],
+ toggle=[])
+
+ command, data, response = get_json_command_from_request(flask.request, valid_commands=valid_commands)
+ if response is not None:
+ return response
+
+ if command == "read":
+ current_read_until = None
+ channel_data = self._settings.get(["channels", channel], merged=True)
+ if channel_data:
+ current_read_until = channel_data.get("read_until", None)
+
+ defaults = dict(plugins=dict(announcements=dict(channels=dict())))
+ defaults["plugins"]["announcements"]["channels"][channel] = dict(read_until=current_read_until)
+
+ until = data["until"]
+ self._settings.set(["channels", channel, "read_until"], until, defaults=defaults)
+ self._settings.save()
+
+ elif command == "toggle":
+ enabled_channels = list(self._settings.get(["enabled_channels"]))
+
+ if channel in enabled_channels:
+ enabled_channels.remove(channel)
+ else:
+ enabled_channels.append(channel)
+
+ self._settings.set(["enabled_channels"], enabled_channels)
+ self._settings.save()
+
+ return NO_CONTENT
+
+ # Internal Tools
+
+ def _get_channel_configs(self):
+ return self._settings.get(["channels"], merged=True)
+
+ def _fetch_all_channels(self):
+ with self._cached_channels_mutex:
+ channels = self._get_channel_configs()
+ enabled = self._settings.get(["enabled_channels"])
+ forced = self._settings.get(["forced_channels"])
+
+ all_channels = dict()
+ for key, config in channels.items():
+ if not key in enabled and not key in forced:
+ continue
+
+ data = self._get_channel_data(key, config)
+ if data is not None:
+ all_channels[key] = data
+
+ self._cached_channels = all_channels
+
+ return self._cached_channels
+
+ def _get_channel_data(self, key, config):
+ data = self._get_channel_data_from_cache(key, config)
+ if data is None:
+ data = self._get_channel_data_from_network(key, config)
+ return data
+
+ def _get_channel_data_from_cache(self, key, config):
+ channel_path = os.path.join(self.get_plugin_data_folder(), "{}.cache".format(key))
+
+ if os.path.exists(channel_path):
+ if "ttl" in config and isinstance(config["ttl"], int):
+ ttl = config["ttl"]
+ else:
+ ttl = self._settings.get_int(["ttl"])
+
+ ttl *= 60
+ now = time.time()
+ if os.stat(channel_path).st_mtime + ttl > now:
+ d = feedparser.parse(channel_path)
+ self._logger.info("Loaded channel {} from cache".format(key))
+ return d
+
+ return None
+
+ def _get_channel_data_from_network(self, key, config):
+ import requests
+
+ url = config["url"]
+ try:
+ r = requests.get(url)
+ self._logger.info("Loaded channel {} from {}".format(key, config["url"]))
+ except Exception as e:
+ self._logger.exception(
+ "Could not fetch channel {} from {}: {}".format(key, config["url"], str(e)))
+ return None
+
+ response = r.text
+ channel_path = os.path.join(self.get_plugin_data_folder(), "{}.cache".format(key))
+ with codecs.open(channel_path, mode="w", encoding="utf-8") as f:
+ f.write(response)
+ return feedparser.parse(response)
+
+ def _to_internal_feed(self, feed, read_until=None):
+ result = []
+ if "entries" in feed:
+ for entry in feed["entries"]:
+ internal_entry = self._to_internal_entry(entry, read_until=read_until)
+ if internal_entry:
+ result.append(internal_entry)
+ return result
+
+ def _to_internal_entry(self, entry, read_until=None):
+ published = time.mktime(entry["published_parsed"])
+
+ read = False
+ if read_until is not None:
+ read = published <= read_until
+
+ return dict(title=entry["title"],
+ title_without_tags=_strip_tags(entry["title"]),
+ summary=entry["summary"],
+ summary_without_images=_strip_images(entry["summary"]),
+ published=published,
+ link=entry["link"],
+ read=read)
+
+
+_image_tag_re = re.compile(r'')
+def _strip_images(text):
+ return _image_tag_re.sub('', text)
+
+
+def _strip_tags(text):
+ """
+ >>> _strip_tags(u"Hello world<img src='foo.jpg'>")
+ u"Hello world<img src='foo.jpg'>"
+ >>> _strip_tags(u"> > Foo")
+ u'> > Foo'
+ """
+
+ from HTMLParser import HTMLParser
+
+ class TagStripper(HTMLParser):
+
+ def __init__(self):
+ HTMLParser.__init__(self)
+ self._fed = []
+
+ def handle_data(self, data):
+ self._fed.append(data)
+
+ def handle_entityref(self, ref):
+ self._fed.append("&{};".format(ref))
+
+ def handle_charref(self, ref):
+ self._fed.append("{};".format(ref))
+
+ def get_data(self):
+ return "".join(self._fed)
+
+ tag_stripper = TagStripper()
+ tag_stripper.feed(text)
+ return tag_stripper.get_data()
+
+
+__plugin_name__ = "Announcement Plugin"
+__plugin_implementation__ = AnnouncementPlugin()
diff --git a/src/octoprint/plugins/announcements/static/js/announcements.js b/src/octoprint/plugins/announcements/static/js/announcements.js
new file mode 100644
index 00000000..812d7005
--- /dev/null
+++ b/src/octoprint/plugins/announcements/static/js/announcements.js
@@ -0,0 +1,300 @@
+$(function() {
+ function AnnouncementsViewModel(parameters) {
+ var self = this;
+
+ self.loginState = parameters[0];
+ self.settings = parameters[1];
+
+ self.channels = new ItemListHelper(
+ "plugin.announcements.channels",
+ {
+ "channel": function (a, b) {
+ // sorts ascending
+ if (a["channel"].toLocaleLowerCase() < b["channel"].toLocaleLowerCase()) return -1;
+ if (a["channel"].toLocaleLowerCase() > b["channel"].toLocaleLowerCase()) return 1;
+ return 0;
+ }
+ },
+ {
+ },
+ "name",
+ [],
+ [],
+ 5
+ );
+
+ self.unread = ko.observable();
+ self.hiddenChannels = [];
+ self.channelNotifications = {};
+
+ self.announcementDialog = undefined;
+ self.announcementDialogContent = undefined;
+ self.announcementDialogTabs = undefined;
+
+ self.setupTabLink = function(item) {
+ $("a[data-toggle='tab']", item).on("show", self.resetContentScroll);
+ };
+
+ self.resetContentScroll = function() {
+ self.announcementDialogContent.scrollTop(0);
+ };
+
+ self.toggleButtonCss = function(data) {
+ var icon = data.enabled ? "icon-circle" : "icon-circle-blank";
+ var disabled = (self.enableToggle(data)) ? "" : " disabled";
+
+ return icon + disabled;
+ };
+
+ self.toggleButtonTitle = function(data) {
+ return data.forced ? gettext("Cannot be toggled") : (data.enabled ? gettext("Disable Channel") : gettext("Enable Channel"));
+ };
+
+ self.enableToggle = function(data) {
+ return !data.forced;
+ };
+
+ self.markRead = function(channel, until) {
+ if (!self.loginState.isAdmin()) return;
+
+ var url = PLUGIN_BASEURL + "announcements/channels/" + channel;
+
+ var payload = {
+ command: "read",
+ until: until
+ };
+
+ $.ajax({
+ url: url,
+ type: "POST",
+ dataType: "json",
+ data: JSON.stringify(payload),
+ contentType: "application/json; charset=UTF-8",
+ success: function() {
+ self.retrieveData()
+ }
+ })
+ };
+
+ self.toggleChannel = function(channel) {
+ if (!self.loginState.isAdmin()) return;
+
+ var url = PLUGIN_BASEURL + "announcements/channels/" + channel;
+
+ var payload = {
+ command: "toggle"
+ };
+
+ $.ajax({
+ url: url,
+ type: "POST",
+ dataType: "json",
+ data: JSON.stringify(payload),
+ contentType: "application/json; charset=UTF-8",
+ success: function() {
+ self.retrieveData()
+ }
+ })
+ };
+
+ self.retrieveData = function(force) {
+ if (!self.loginState.isAdmin()) return;
+
+ var url = PLUGIN_BASEURL + "announcements/channels";
+ if (force) {
+ url += "?force=true";
+ }
+
+ $.ajax({
+ url: url,
+ type: "GET",
+ dataType: "json",
+ success: function(data) {
+ self.fromResponse(data);
+ }
+ });
+ };
+
+ self.fromResponse = function(data) {
+ var currentTab = $("li.active a", self.announcementDialogTabs).attr("href");
+
+ var unread = 0;
+ var channels = [];
+ _.each(data, function(value, key) {
+ value.key = key;
+ value.last = value.data.length ? value.data[0].published : undefined;
+ value.count = value.data.length;
+ unread += value.unread;
+ channels.push(value);
+ });
+ self.channels.updateItems(channels);
+ self.unread(unread);
+
+ self.displayAnnouncements(channels);
+
+ self.selectTab(currentTab);
+ };
+
+ self.showAnnouncementDialog = function(channel) {
+ self.announcementDialogContent.scrollTop(0);
+
+ if (!self.announcementDialog.hasClass("in")) {
+ self.announcementDialog.modal({
+ minHeight: function() { return Math.max($.fn.modal.defaults.maxHeight() - 80, 250); }
+ }).css({
+ width: 'auto',
+ 'margin-left': function() { return -($(this).width() /2); }
+ });
+ }
+
+ var tab = undefined;
+ if (channel) {
+ tab = "#plugin_announcements_dialog_channel_" + channel;
+ }
+ self.selectTab(tab);
+
+ return false;
+ };
+
+ self.selectTab = function(tab) {
+ if (tab != undefined) {
+ if (!_.startsWith(tab, "#")) {
+ tab = "#" + tab;
+ }
+ $('a[href="' + tab + '"]', self.announcementDialogTabs).tab("show");
+ } else {
+ $('a:first', self.announcementDialogTabs).tab("show");
+ }
+ };
+
+ self.displayAnnouncements = function(channels) {
+ var displayLimit = self.settings.settings.plugins.announcements.display_limit();
+ var maxLength = self.settings.settings.plugins.announcements.summary_limit();
+
+ var cutAfterNewline = function(text) {
+ text = text.trim();
+
+ var firstNewlinePos = text.indexOf("\n");
+ if (firstNewlinePos > 0) {
+ text = text.substr(0, firstNewlinePos).trim();
+ }
+
+ return text;
+ };
+
+ var stripParagraphs = function(text) {
+ if (_.startsWith(text, "")) {
+ text = text.substr("
".length);
+ }
+ if (_.endsWith(text, "
")) {
+ text = text.substr(0, text.length - "
".length);
+ }
+
+ return text.replace(/<\/p>\s*/ig, "
");
+ };
+
+ _.each(channels, function(value) {
+ var key = value.key;
+ var channel = value.channel;
+ var priority = value.priority;
+ var items = value.data;
+
+ if ($.inArray(key, self.hiddenChannels) > -1) {
+ // channel currently ignored
+ return;
+ }
+
+ var newItems = _.filter(items, function(entry) { return !entry.read; });
+ if (newItems.length == 0) {
+ // no new items at all, we don't display anything for this channel
+ return;
+ }
+
+ var displayedItems;
+ if (newItems.length > displayLimit) {
+ displayedItems = newItems.slice(0, displayLimit);
+ } else {
+ displayedItems = newItems;
+ }
+ var rest = newItems.length - displayedItems.length;
+
+ var text = "
";
+ _.each(displayedItems, function(item) {
+ var limitedSummary = stripParagraphs(item.summary_without_images.trim());
+ if (limitedSummary.length > maxLength) {
+ limitedSummary = limitedSummary.substr(0, maxLength);
+ limitedSummary = limitedSummary.substr(0, Math.min(limitedSummary.length, limitedSummary.lastIndexOf(" ")));
+ limitedSummary += "...";
+ }
+
+ text += "- " + cutAfterNewline(item.title) + "
" + formatTimeAgo(item.published) + "" + limitedSummary + "
";
+ });
+ text += "
";
+
+ if (rest) {
+ text += gettext(_.sprintf("... and %(rest)d more.", {rest: rest}));
+ }
+
+ var options = {
+ title: channel,
+ text: text,
+ hide: false,
+ confirm: {
+ confirm: true,
+ buttons: [{
+ text: gettext("Later"),
+ click: function(notice) {
+ self.hiddenChannels.push(key);
+ notice.remove();
+ }
+ }, {
+ text: gettext("Mark read"),
+ click: function(notice) {
+ self.markRead(key, value.last);
+ notice.remove();
+ }
+ }, {
+ text: gettext("Read..."),
+ addClass: "btn-primary",
+ click: function(notice) {
+ self.showAnnouncementDialog(key);
+ self.markRead(key, value.last);
+ notice.remove();
+ }
+ }]
+ },
+ buttons: {
+ sticker: false,
+ closer: false
+ }
+ };
+
+ if (priority == 1) {
+ options.type = "error";
+ }
+
+ if (self.channelNotifications[key]) {
+ self.channelNotifications[key].remove();
+ }
+ self.channelNotifications[key] = new PNotify(options);
+ });
+ };
+
+ self.onUserLoggedIn = function() {
+ self.retrieveData();
+ };
+
+ self.onStartup = function() {
+ self.announcementDialog = $("#plugin_announcements_dialog");
+ self.announcementDialogContent = $("#plugin_announcements_dialog_content");
+ self.announcementDialogTabs = $("#plugin_announcements_dialog_tabs");
+ }
+ }
+
+ // view model class, parameters for constructor, container to bind to
+ ADDITIONAL_VIEWMODELS.push([
+ AnnouncementsViewModel,
+ ["loginStateViewModel", "settingsViewModel"],
+ ["#plugin_announcements_dialog", "#settings_plugin_announcements", "#navbar_plugin_announcements"]
+ ]);
+});
diff --git a/src/octoprint/plugins/announcements/static/less/announcements.less b/src/octoprint/plugins/announcements/static/less/announcements.less
new file mode 100644
index 00000000..35f0f667
--- /dev/null
+++ b/src/octoprint/plugins/announcements/static/less/announcements.less
@@ -0,0 +1,54 @@
+table {
+ th, td {
+ &.settings_plugin_announcements_channels_name {
+ text-overflow: ellipsis;
+ text-align: left;
+ }
+
+ &.settings_plugin_announcements_channels_actions {
+ text-align: center;
+ width: 80px;
+
+ a {
+ text-decoration: none;
+ color: #000;
+
+ &.disabled {
+ color: #ccc;
+ cursor: default;
+ }
+ }
+ }
+ }
+}
+
+#plugin_announcements_dialog {
+ .unread {
+ font-weight: bold;
+ }
+
+ article {
+ &.read {
+ opacity: 0.5;
+ }
+
+ &.read:hover {
+ opacity: 1;
+ }
+
+ .actions {
+ background-color: #f5f5f5;
+ border-radius: 2px;
+ padding: 2px 5px;
+ margin-top: 5px;
+
+ .markread {
+ float: right;
+ }
+
+ a {
+ color: black;
+ }
+ }
+ }
+}
diff --git a/src/octoprint/plugins/announcements/templates/announcements.jinja2 b/src/octoprint/plugins/announcements/templates/announcements.jinja2
new file mode 100644
index 00000000..cbea05ff
--- /dev/null
+++ b/src/octoprint/plugins/announcements/templates/announcements.jinja2
@@ -0,0 +1,45 @@
+
diff --git a/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 b/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2
new file mode 100644
index 00000000..8f91e47e
--- /dev/null
+++ b/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 b/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2
new file mode 100644
index 00000000..d9851abe
--- /dev/null
+++ b/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2
@@ -0,0 +1,34 @@
+{{ _('Configured Channels') }}
+
+
+
+
+ | {{ _('Name') }} |
+ {{ _('Actions') }} |
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py
index ce203919..2bbc7bd3 100644
--- a/src/octoprint/settings.py
+++ b/src/octoprint/settings.py
@@ -209,13 +209,13 @@ default_settings = {
"defaultLanguage": "_default",
"components": {
"order": {
- "navbar": ["settings", "systemmenu", "login"],
+ "navbar": ["settings", "systemmenu", "login", "plugin_announcements"],
"sidebar": ["connection", "state", "files"],
"tab": ["temperature", "control", "gcodeviewer", "terminal", "timelapse"],
"settings": [
"section_printer", "serial", "printerprofiles", "temperatures", "terminalfilters", "gcodescripts",
"section_features", "features", "webcam", "accesscontrol", "api",
- "section_octoprint", "server", "folders", "appearance", "logs", "plugin_pluginmanager", "plugin_softwareupdate"
+ "section_octoprint", "server", "folders", "appearance", "logs", "plugin_pluginmanager", "plugin_softwareupdate", "plugin_announcements"
],
"usersettings": ["access", "interface"],
"about": ["about", "supporters", "authors", "changelog", "license", "thirdparty", "plugin_pluginmanager"],