WIP: Announcement plugin
This commit is contained in:
parent
73d981ed6b
commit
652158d17a
9 changed files with 665 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
setup.py
3
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
|
||||
|
|
|
|||
273
src/octoprint/plugins/announcements/__init__.py
Normal file
273
src/octoprint/plugins/announcements/__init__.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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"),
|
||||
_releases=dict(name="OctoPrint Release Announcements",
|
||||
priority=2,
|
||||
type="rss",
|
||||
url="http://octoprint.org/feeds/releases.xml"),
|
||||
_plugins=dict(name="New Plugins in the Repository",
|
||||
priority=2,
|
||||
type="rss",
|
||||
url="http://octoprint.org/rss/important.xml")),
|
||||
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/<channel>", 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'<img.*?/?>')
|
||||
def _strip_images(text):
|
||||
return _image_tag_re.sub('', text)
|
||||
|
||||
|
||||
def _strip_tags(text):
|
||||
"""
|
||||
>>> _strip_tags(u"<a href='test.html'>Hello world</a><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()
|
||||
268
src/octoprint/plugins/announcements/static/js/announcements.js
Normal file
268
src/octoprint/plugins/announcements/static/js/announcements.js
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
$(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 unread = 0;
|
||||
var channels = [];
|
||||
_.each(data, function(value, key) {
|
||||
value.key = key;
|
||||
value.last = value.data.length ? value.data[0].published : undefined;
|
||||
unread += value.unread;
|
||||
channels.push(value);
|
||||
});
|
||||
self.channels.updateItems(channels);
|
||||
self.unread(unread);
|
||||
|
||||
self.displayAnnouncements(channels);
|
||||
};
|
||||
|
||||
self.showAnnouncementDialog = function() {
|
||||
//self.aboutContent.scrollTop(0);
|
||||
self.announcementDialog.modal({
|
||||
minHeight: function() { return Math.max($.fn.modal.defaults.maxHeight() - 80, 250); }
|
||||
}).css({
|
||||
width: 'auto',
|
||||
'margin-left': function() { return -($(this).width() /2); }
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
self.showChannel = function(channel) {
|
||||
self.showAnnouncementDialog();
|
||||
$("a[href=#plugin_announcements_dialog_channel_" + channel + "]", 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;
|
||||
};
|
||||
|
||||
_.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 = "<ul>";
|
||||
_.each(displayedItems, function(item) {
|
||||
var limitedSummary = 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 += "<li><a href='" + item.link + "' target='_blank'>" + cutAfterNewline(item.title) + "</a><br><small>" + formatTimeAgo(item.published) + "</small><p>" + limitedSummary + "</p></li>";
|
||||
});
|
||||
text += "</ul>";
|
||||
|
||||
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.showChannel(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"]
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: right;
|
||||
|
||||
a {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<div id="plugin_announcements_dialog" class="modal hide fade large" tabindex="-1" role="dialog" aria-labelledby="announcements_dialog_label" aria-hidden="true">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
<h3 id="announcements_dialog_label">{{ _('Announcements') }}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="full-sized-box">
|
||||
<div class="tabbable row-fluid">
|
||||
<div class="span3 scrollable" id="plugin_announcements_dialog_menu">
|
||||
<ul class="nav nav-list" id="plugin_announcements_dialog_tabs">
|
||||
<!-- ko foreach: {data: channels.items, afterAdd: setupTabLink} -->
|
||||
<!-- ko if: $data.enabled || $data.forced -->
|
||||
<li data-bind="css: {active: $index() == 0}">
|
||||
<a data-toggle="tab" target="_blank" data-bind="text: $data.channel + ' (' + $data.unread + ')', attr: {href: '#plugin_announcements_dialog_channel_' + $data.key}, css: {unread: $data.unread}"></a>
|
||||
</li>
|
||||
<!-- /ko -->
|
||||
<!-- /ko -->
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tab-content span9 scrollable" id="plugin_announcements_dialog_content">
|
||||
<!-- ko foreach: channels.items -->
|
||||
<!-- ko if: $data.enabled || $data.forced -->
|
||||
<div class="tab-pane" data-bind="css: {active: $index() == 0}, attr: {id: 'plugin_announcements_dialog_channel_' + $data.key}">
|
||||
<!-- ko foreach: $data.data -->
|
||||
<hr data-bind="visible: $index() > 0">
|
||||
<article data-bind="css: {muted: $data.read}">
|
||||
<h3><a data-bind="text: $data.title, attr: {href: $data.url}"></a> <small data-bind="text: formatTimeAgo($data.published)"></small></h3>
|
||||
<div data-bind="html: $data.summary"></div>
|
||||
<div class="actions">
|
||||
<small><a data-bind="click: function() { $root.markRead($parent.key, $data.published) }"><i class="icon-eye-open"></i> {{ _('Last read') }}</a></small>
|
||||
</div>
|
||||
</article>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-block" data-dismiss="modal" aria-hidden="true">{{ _('Close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<a id="navbar_show_announcements" class="pull-right" href="javascript:void(0)" data-bind="click: function() { showAnnouncementDialog(); }" title="{{ _('Announcements') }}">
|
||||
<i data-bind="css: {'icon-bell': !unread(), 'icon-bell-alt': unread()}"></i>
|
||||
</a>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<h3>{{ _('Configured Channels') }}</h3>
|
||||
|
||||
<table class="table table-striped table-hover table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="settings_plugin_announcements_channels_name">{{ _('Name') }}</th>
|
||||
<th class="settings_plugin_announcements_channels_actions">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="foreach: channels.paginatedItems">
|
||||
<tr>
|
||||
<td class="settings_plugin_announcements_channels_name">
|
||||
<div data-bind="text: channel, css: {muted: !enabled}"></div>
|
||||
<div><small class="muted" data-bind="text: url"> </small></div>
|
||||
</td>
|
||||
<td class="settings_plugin_announcements_channels_actions">
|
||||
<a href="javascript:void(0)" data-bind="css: $root.toggleButtonCss($data), attr: {title: $root.toggleButtonTitle($data)}, enable: $root.enableToggle($data), click: function() { $root.toggleChannel($data.key) }"></a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination pagination-mini pagination-centered">
|
||||
<ul>
|
||||
<li data-bind="css: {disabled: channels.currentPage() === 0}"><a href="javascript:void(0)" data-bind="click: channels.prevPage">«</a></li>
|
||||
</ul>
|
||||
<ul data-bind="foreach: channels.pages">
|
||||
<li data-bind="css: { active: $data.number === $root.channels.currentPage(), disabled: $data.number === -1 }"><a href="javascript:void(0)" data-bind="text: $data.text, click: function() { $root.channels.changePage($data.number); }"></a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li data-bind="css: {disabled: channels.currentPage() === channels.lastPage()}"><a href="javascript:void(0)" data-bind="click: channels.nextPage">»</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-block" data-bind="click: $root.showAnnouncementDialog">{{ _('Show Announcements...') }}</button>
|
||||
|
|
@ -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", "sponsors", "authors", "changelog", "license", "thirdparty", "plugin_pluginmanager"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue