Merge branch 'maintenance' into devel

Conflicts:
	THIRDPARTYLICENSES.md
	setup.py
	src/octoprint/server/__init__.py
	src/octoprint/server/views.py
	src/octoprint/settings.py
	src/octoprint/static/css/octoprint.css
This commit is contained in:
Gina Häußge 2016-05-03 10:48:26 +02:00
commit 76b4a45495
31 changed files with 793 additions and 37 deletions

2
.gitignore vendored
View file

@ -1,6 +1,6 @@
src/octoprint/templates/_data/AUTHORS.md
src/octoprint/templates/_data/CHANGELOG.md
src/octoprint/templates/_data/SPONSORS.md
src/octoprint/templates/_data/SUPPORTERS.md
src/octoprint/templates/_data/THIRDPARTYLICENSES.md
devtools

View file

@ -10,6 +10,9 @@ The documentation is located at [docs.octoprint.org](http://docs.octoprint.org).
The official plugin repository can be reached at [plugins.octoprint.org](http://plugins.octoprint.org).
OctoPrint's development wouldn't be possible without the [financial support by its community](http://octoprint.org/support-octoprint/).
If you enjoy OctoPrint, please consider becoming a regular supporter!
![Screenshot](http://i.imgur.com/dF3noFp.png)
You are currently looking at the source code repository of OctoPrint. If you already installed it
@ -26,7 +29,7 @@ Contributions of all kinds are welcome, not only in the form of code but also wi
[official documentation](http://docs.octoprint.org/) or [the public wiki](https://github.com/foosel/OctoPrint/wiki), support
of other users in the [bug tracker](https://github.com/foosel/OctoPrint/issues),
[the Mailinglist](https://groups.google.com/group/octoprint) or
[the G+ Community](https://plus.google.com/communities/102771308349328485741).
[the G+ Community](https://plus.google.com/communities/102771308349328485741) and also [financially](http://octoprint.org/support-octoprint/).
If you think something is bad as it is about OctoPrint or its documentation the way it is, please help
in any way to make it better instead of just complaining about it -- this is an Open Source Project

View file

@ -1,4 +1,4 @@
# Sponsors
# Supporters
Development of this version of OctoPrint wouldn't have been possible without
[financial support by the community](http://octoprint.org/support-octoprint/) -
@ -14,8 +14,8 @@ thanks to everyone who contributed!
* E3D BigBox
* Erik de Bruijn
* Ernesto Martinez
* Exovite
* georgeroblesjr
* Exovite
* georgeroblesjr
* Gregor Luetolf
* Kale Stedman
* Makespace Madrid
@ -26,6 +26,6 @@ thanks to everyone who contributed!
* Samer Najia
* Stefan Krister
* Sven Mueller
* Tom
* Tom
and 321 more wonderful people pledging on the [Patreon campaign](https://patreon.com/foosel)!
and 321 more wonderful people pledging on the [Patreon campaign](https://patreon.com/foosel)!

View file

@ -34,10 +34,11 @@
* [Awesome-Slugify](https://pypi.python.org/pypi/awesome-slugify): GPLv3
* [Click](http://click.pocoo.org/): BSD
* [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

View file

@ -36,7 +36,8 @@ INSTALL_REQUIRES = [
"semantic_version>=2.4.2,<2.5",
"psutil>=3.2.1,<3.3",
"Click>=6.2,<6.3",
"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
@ -114,7 +115,7 @@ def get_cmdclass():
"octoprint/templates/_data": [
"AUTHORS.md",
"CHANGELOG.md",
"SPONSORS.md",
"SUPPORTERS.md",
"THIRDPARTYLICENSES.md",
]
}, cmdclass["build_py"] if "build_py" in cmdclass else _build_py)

View file

@ -0,0 +1,286 @@
# 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 calendar
import codecs
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=1449446400),
_releases=dict(name="OctoPrint Release Announcements",
priority=2,
type="rss",
url="http://octoprint.org/feeds/releases.xml",
read_until=1458121176),
_spotlight=dict(name="OctoPrint Community Spotlights",
priority=2,
type="rss",
url="http://octoprint.org/feeds/spotlight.xml",
read_until=1447953971),
_octopi=dict(name="OctoPi Announcements",
priority=2,
type="rss",
url="http://octoprint.org/feeds/octopi.xml",
read_until=1462200600),
_plugins=dict(name="New Plugins in the Repository",
priority=2,
type="rss",
url="http://plugins.octoprint.org/feed.xml",
read_until=1461628800)),
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 = calendar.timegm(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>&lt;img src='foo.jpg'&gt;")
u"Hello world&lt;img src='foo.jpg'&gt;"
>>> _strip_tags(u"&#62; &#x3E; Foo")
u'&#62; &#x3E; 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()

View file

@ -0,0 +1 @@
table td.settings_plugin_announcements_channels_name,table th.settings_plugin_announcements_channels_name{text-overflow:ellipsis;text-align:left}table td.settings_plugin_announcements_channels_actions,table th.settings_plugin_announcements_channels_actions{text-align:center;width:80px}table td.settings_plugin_announcements_channels_actions a,table th.settings_plugin_announcements_channels_actions a{text-decoration:none;color:#000}table td.settings_plugin_announcements_channels_actions a.disabled,table th.settings_plugin_announcements_channels_actions a.disabled{color:#ccc;cursor:default}#plugin_announcements_dialog .unread{font-weight:700}#plugin_announcements_dialog article{padding-right:20px}#plugin_announcements_dialog article.read{opacity:.5}#plugin_announcements_dialog article.read:hover{opacity:1}#plugin_announcements_dialog article .actions{background-color:#f5f5f5;border-radius:2px;padding:2px 5px;margin-top:5px}#plugin_announcements_dialog article .actions .markread{float:right}#plugin_announcements_dialog article .actions a{color:#000}

View file

@ -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, "<p>")) {
text = text.substr("<p>".length);
}
if (_.endsWith(text, "</p>")) {
text = text.substr(0, text.length - "</p>".length);
}
return text.replace(/<\/p>\s*<p>/ig, "<br>");
};
_.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 = 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 += "<li><a href='" + item.link + "' target='_blank' rel='noreferrer noopener'>" + 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.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"]
]);
});

View file

@ -0,0 +1,56 @@
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 {
padding-right: 20px;
&.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;
}
}
}
}

View file

@ -0,0 +1,45 @@
<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">&times;</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>
<a data-toggle="tab" target="_blank" data-bind="text: $data.channel + ' (' + $data.unread + '/' + $data.count + ')', 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="attr: {id: 'plugin_announcements_dialog_channel_' + $data.key}">
<!-- ko foreach: $data.data -->
<article data-bind="css: {read: $data.read}">
<hr data-bind="visible: $index() > 0">
<h3><a data-bind="text: $data.title, attr: {href: $data.link}" target="_blank" rel="noreferrer noopener"></a> <small data-bind="text: formatTimeAgo($data.published), attr: {title: formatDate($data.published)}"></small></h3>
<div class="content" data-bind="html: $data.summary"></div>
<div class="actions">
<span class="articlelink"><small><a data-bind="text: $data.link, attr: {href: $data.link}" target="_blank" rel="noreferrer noopener"></a></small></span>
<span class="markread"><small><a href="javascript:void(0)" data-bind="click: function() { $root.markRead($parent.key, $data.published) }"><i class="icon-eye-open"></i> {{ _('Mark as last read') }}</a></small></span>
</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>

View file

@ -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>

View file

@ -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">&nbsp;</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>

View file

@ -2,7 +2,7 @@
<ul>
{% for plugin in plugin_pluginmanager_thirdparty %}
<li><a href="{{ plugin.url }}">{{ plugin.name }}</a>: {{ plugin.license }}</li>
<li>{% if plugin.url %}<a href="{{ plugin.url }}" target="_blank" rel="noreferrer noopener">{% endif %}{{ plugin.name }}{% if plugin.url %}</a>{% endif %}: {% if plugin.license %}{{ plugin.license }}{% else %}-{% endif %}</li>
{% endfor %}
</ul>

View file

@ -35,7 +35,7 @@
<div data-bind="css: {muted: !enabled}"><span data-bind="text: name"></span> <span data-bind="visible: version">(<span data-bind="text: version"></span>)</span> <i title="{{ _('Bundled with OctoPrint') }}" class="icon-th-large" data-bind="visible: bundled"></i> <i class="icon-lock" title="{{ _('Cannot be uninstalled through OctoPrint') }}" data-bind="visible: !managable"></i> <i title="{{ _('Restart of OctoPrint needed for changes to take effect') }}" class="icon-refresh" data-bind="visible: pending_enable || pending_disable || pending_install || pending_uninstall"></i> <i title="{{ _('Pending install') }}" class="icon-plus" data-bind="visible: pending_install"></i> <i title="{{ _('Pending uninstall') }}" class="icon-minus" data-bind="visible: pending_uninstall"></i></div>
<div><small class="muted" data-bind="text: description">&nbsp;</small></div>
<div data-bind="css: {muted: !enabled}">
<small data-bind="visible: url"><i class="icon-home"></i> <a data-bind="attr: {href: url}">{{ _('Homepage') }}</a></small>
<small data-bind="visible: url"><i class="icon-home"></i> <a data-bind="attr: {href: url}" target="_blank" rel="noreferrer noopener">{{ _('Homepage') }}</a></small>
<small data-bind="visible: license"><i class="icon-legal"></i> <span data-bind="text: license"></span></small>
<small data-bind="visible: author"><i class="icon-user"></i> <span data-bind="text: author"></span></small>
<small>&nbsp;</small>

View file

@ -11,7 +11,7 @@
<!-- ko foreach: availableAndPossible -->
<li>
<span class="name" data-bind="text: fullNameRemote, attr: {title: fullNameRemote}"></span>
<div class="releaseNotes" data-bind="visible: releaseNotes"><a data-bind="attr: {href: releaseNotes}">{{ _('Release Notes') }}</a></div>
<div class="releaseNotes" data-bind="visible: releaseNotes"><a data-bind="attr: {href: releaseNotes}" target="_blank" rel="noreferrer noopener">{{ _('Release Notes') }}</a></div>
</li>
<!-- /ko -->
</ul>

View file

@ -33,7 +33,7 @@
<small class="muted">
{{ _('Installed:') }} <span data-bind="text: information.local.name"></span><br>
{{ _('Available:') }} <span data-bind="text: information.remote.name"></span><br>
<span data-bind="visible: releaseNotes">{{ _('Release Notes:') }} <a data-bind="attr: {href: releaseNotes}, text: releaseNotes" target="_blank"></a></span>
<span data-bind="visible: releaseNotes">{{ _('Release Notes:') }} <a data-bind="attr: {href: releaseNotes}, text: releaseNotes" target="_blank" rel="noreferrer noopener"></a></span>
</small>
</td>
</tr>

View file

@ -668,9 +668,24 @@ class Server(object):
return "{hashs} {content}".format(hashs="#" * number, content=match.group("content"))
return markdown_header_regex.sub(repl, s)
html_link_regex = re.compile("<(?P<tag>a.*?)>(?P<content>.*?)</a>")
def externalize_links(text):
def repl(match):
tag = match.group("tag")
if not u"href" in tag:
return match.group(0)
if not u"target=" in tag and not u"rel=" in tag:
tag += u" target=\"_blank\" rel=\"noreferrer noopener\""
content = match.group("content")
return u"<{tag}>{content}</a>".format(tag=tag, content=content)
return html_link_regex.sub(repl, text)
app.jinja_env.filters["regex_replace"] = regex_replace
app.jinja_env.filters["offset_html_headers"] = offset_html_headers
app.jinja_env.filters["offset_markdown_headers"] = offset_markdown_headers
app.jinja_env.filters["externalize_links"] = externalize_links
# configure additional template folders for jinja2
import jinja2
@ -682,7 +697,7 @@ class Server(object):
loaders = [app.jinja_loader, filesystem_loader]
if octoprint.util.is_running_from_source():
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
allowed = ["AUTHORS.md", "CHANGELOG.md", "SPONSORS.md", "THIRDPARTYLICENSES.md"]
allowed = ["AUTHORS.md", "CHANGELOG.md", "SUPPORTERS.md", "THIRDPARTYLICENSES.md"]
files = {"_data/" + name: os.path.join(root, name) for name in allowed}
loaders.append(octoprint.util.jinja.SelectedFilesLoader(files))

View file

@ -442,7 +442,7 @@ def _process_templates():
thirdparty=(gettext("Third Party Licenses"), dict(template="dialogs/about/thirdparty.jinja2", _div="about_thirdparty", custom_bindings=False)),
authors=(gettext("Authors"), dict(template="dialogs/about/authors.jinja2", _div="about_authors", custom_bindings=False)),
changelog=(gettext("Changelog"), dict(template="dialogs/about/changelog.jinja2", _div="about_changelog", custom_bindings=False)),
sponsors = (gettext("Sponsors"), dict(template="dialogs/about/sponsors.jinja2", _div="about_sponsors", custom_bindings=False))
supporters=(gettext("Supporters"), dict(template="dialogs/about/supporters.jinja2", _div="about_sponsors", custom_bindings=False))
)
# extract data from template plugins

View file

@ -214,17 +214,17 @@ 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"],
"wizard": ["access"],
"about": ["about", "sponsors", "authors", "changelog", "license", "thirdparty", "plugin_pluginmanager"],
"about": ["about", "supporters", "authors", "changelog", "license", "thirdparty", "plugin_pluginmanager"],
"generic": []
},
"disabled": {

View file

@ -31,6 +31,10 @@ $(function() {
self.aboutContent.scrollTop(0);
});
};
self.showTab = function(tab) {
$("a[href=#" + tab + "]", self.aboutTabs).tab("show");
};
}
OCTOPRINT_VIEWMODELS.push([

View file

@ -5,6 +5,7 @@ $(function() {
self.loginState = parameters[0];
self.users = parameters[1];
self.printerProfiles = parameters[2];
self.about = parameters[3];
self.allViewModels = [];
@ -799,7 +800,7 @@ $(function() {
OCTOPRINT_VIEWMODELS.push([
SettingsViewModel,
["loginStateViewModel", "usersViewModel", "printerProfilesViewModel"],
["loginStateViewModel", "usersViewModel", "printerProfilesViewModel", "aboutViewModel"],
["#settings_dialog", "#navbar_settings"]
]);
});

View file

@ -694,6 +694,10 @@ ul.dropdown-menu li a {
.acl_decision {
margin-top: 1em;
}
.aboutlink {
float: left;
}
}

View file

@ -5,17 +5,18 @@
<p>Version <span class="version">{{ display_version }}</span></p>
<ul>
<li>Website: <a href="http://octoprint.org" target="_blank">octoprint.org</a></li>
<li>Source Code: <a href="https://github.com/foosel/OctoPrint" target="_blank">github.com/foosel/OctoPrint</a></li>
<li>Documentation: <a href="http://docs.octoprint.org" target="_blank">docs.octoprint.org</a></li>
<li>Website: <a href="http://octoprint.org" target="_blank" rel="noreferrer noopener">octoprint.org</a></li>
<li>Source Code: <a href="https://github.com/foosel/OctoPrint" target="_blank" rel="noreferrer noopener">github.com/foosel/OctoPrint</a></li>
<li>Documentation: <a href="http://docs.octoprint.org" target="_blank" rel="noreferrer noopener">docs.octoprint.org</a></li>
</ul>
<p>
<strong>OctoPrint is sponsored by a lot of awesome people. Please see "Sponsors" to the left.</strong>
<strong>Development of OctoPrint wouldn't be possible without <a href="javascript:void(0)" data-bind="click: function() { showTab('{{ templates.about.entries.supporters[1]._div }}'); }">its supporters</a>.</strong><br>
If you enjoy OctoPrint, please consider <a href="http://octoprint.org/support-octoprint/" target="_blank" rel="noreferrer noopener">supporting its ongoing development</a>.
</p>
<p>
&copy; 2012-{{ now.strftime("%Y") }} The OctoPrint Authors
&copy; 2012-{{ now.strftime("%Y") }} <a href="javascript:void(0)" data-bind="click: function() { showTab('{{ templates.about.entries.authors[1]._div }}'); }">The OctoPrint Authors</a>
</p>
<p>
@ -33,16 +34,16 @@
</p>
<p>
For a copy of the GNU Affero General Public License, see "OctoPrint License"
For a copy of the GNU Affero General Public License, see <a href="javascript:void(0)" data-bind="click: function() { showTab('{{ templates.about.entries.license[1]._div }}'); }">{{ templates.about.entries.license[0] }}</a>
to the left.
</p>
<p>
OctoPrint also utilizes various dependencies under terms of their
respective licenses, which can be found under "Third Party Licenses" to
respective licenses, which can be found under <a href="javascript:void(0)" data-bind="click: function() { showTab('{{ templates.about.entries.thirdparty[1]._div }}'); }">{{ templates.about.entries.thirdparty[0] }}</a> to
the left. Copyright of those dependencies lies with their respective authors
</p>
<p><small>
"OctoPrint" is a registered trademark
"OctoPrint" is a registered trademark.
</small></p>

View file

@ -1 +1 @@
{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/AUTHORS.md" ignore missing %}{% endfilter %}{% endfilter %}
{% filter externalize_links %}{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/AUTHORS.md" ignore missing %}{% endfilter %}{% endfilter %}{% endfilter %}

View file

@ -1 +1 @@
{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/CHANGELOG.md" ignore missing %}{% endfilter %}{% endfilter %}
{% filter externalize_links %}{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/CHANGELOG.md" ignore missing %}{% endfilter %}{% endfilter %}{% endfilter %}

View file

@ -1 +1 @@
{% include "_data/agpl.html" %}
{% filter externalize_links %}{% include "_data/agpl.html" %}{% endfilter %}

View file

@ -1 +0,0 @@
{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/SPONSORS.md" ignore missing %}{% endfilter %}{% endfilter %}

View file

@ -0,0 +1 @@
{% filter externalize_links %}{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/SUPPORTERS.md" ignore missing %}{% endfilter %}{% endfilter %}{% endfilter %}

View file

@ -1 +1 @@
{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/THIRDPARTYLICENSES.md" ignore missing %}{% endfilter %}{% endfilter %}
{% filter externalize_links %}{% filter markdown %}{% filter offset_markdown_headers(2) %}{% include "_data/THIRDPARTYLICENSES.md" ignore missing %}{% endfilter %}{% endfilter %}{% endfilter %}

View file

@ -48,6 +48,7 @@
</div>
</div>
<div class="modal-footer">
<button class="btn aboutlink" data-bind="click: about.show"><i class="icon-info-sign"></i> {{ _('About OctoPrint') }}</button>
<button class="btn" data-dismiss="modal" aria-hidden="true">{{ _('Cancel') }}</button>
<button class="btn btn-primary" data-bind="click: function() { saveData(undefined, $root.hide) }, enable: !exchanging(), css: {disabled: exchanging()}"><i class="icon-spinner icon-spin" data-bind="visible: sending"></i> {{ _('Save') }}</button>
</div>

View file

@ -115,10 +115,10 @@
<li><small>{{ _('Version') }}: <span class="version">{{ display_version|e }}</span></small></li>
</ul>
<ul class="pull-right">
<li><a href="http://octoprint.org"><i class="icon-home"></i> {{ _('Homepage') }}</a></li>
<li><a href="https://github.com/foosel/OctoPrint/"><i class="icon-github"></i> {{ _('Sourcecode') }}</a></li>
<li><a href="http://docs.octoprint.org"><i class="icon-book"></i> {{ _('Documentation') }}</a></li>
<li><a href="https://github.com/foosel/OctoPrint/issues"><i class="icon-flag"></i> {{ _('Bugs and Requests') }}</a></li>
<li><a href="http://octoprint.org" target="_blank" rel="noreferrer noopener"><i class="icon-home"></i> {{ _('Homepage') }}</a></li>
<li><a href="https://github.com/foosel/OctoPrint/" target="_blank" rel="noreferrer noopener"><i class="icon-github"></i> {{ _('Sourcecode') }}</a></li>
<li><a href="http://docs.octoprint.org" target="_blank" rel="noreferrer noopener"><i class="icon-book"></i> {{ _('Documentation') }}</a></li>
<li><a href="https://github.com/foosel/OctoPrint/issues" target="_blank" rel="noreferrer noopener"><i class="icon-flag"></i> {{ _('Bugs and Requests') }}</a></li>
<li id="footer_about"><a href="javascript:void(0)" data-bind="click: show"><i class="icon-info-sign"></i> {{ _('About') }}</a></li>
</ul>
</div>