commit e085eb821ca7c9e74bc0f2688cb6b7ae06b6bf80 Author: Alexander Tsvyashchenko Date: Mon Jan 11 14:38:44 2010 +0200 - Initial commit. diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt new file mode 100644 index 0000000..804d65c --- /dev/null +++ b/COPYRIGHT.txt @@ -0,0 +1,20 @@ +Redmine Wiki External Filter plugin that allows to filter macro blocks +arguments using external program and display results on wiki. + +Copyright (C) 2010 Alexander Tsvyashchenko, http://www.ndl.kiev.ua +Based on wiki_latex_plugin by Nils Israel +Based on wiki_graphviz_plugin by tckz + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..283bd73 --- /dev/null +++ b/README.txt @@ -0,0 +1,212 @@ + Redmine Wiki External Filter Plugin + =================================== + Copyright (C) 2010 Alexander Tsvyashchenko, + http://www.ndl.kiev.ua - see COPYRIGHT.txt + +Overview +======== + +This plugin allows defining macros that process macro argument using +external filter program and render its result in Redmine wiki. + +For every filter two macros are defined: and _include. +The first one directly processes its argument using filter, while the +second one assumes its argument is wiki page name, so it reads that wiki page +content and processes it using the filter. + +Macros already bundled with current release are listed below, but adding new +ones is typically as easy as adding several lines in plugin config file. + +Installation +============ + +See [Installing a plugin](http://www.redmine.org/wiki/redmine/Plugins) on +Redmine site. + +Additionally, copy wiki_external_filter.yml from config folder of plugin +directory to the config folder of your redmine installation. + +After installation it's **strongly recommended** to go to plugin settings and +configure caching. Note that RoR file-based caching suggested by default does +not implement proper cache expiration: you should either setup a cron task to +clean cache or do it manually from time to time. + +To successfully use macros with complex argument expressions, it's necessary +to patch core Redmine components as follows: + * [Change MACROS_RE](http://www.redmine.org/issues/3061) regexp not to stop + too early - in the issue description textile wiki formatter is mentioned, + but in fact this change should be done for whatever wiki formatter you use. + * [Change arguments parsing](http://www.redmine.org/boards/3/topics/4987#message-9854) - again, should be done for whatever wiki formatter you use. + * Additionally, for some of the formatters escaping should be avoided for + macro arguments. + +Specific filters installation instructions are below. + +Prefedined Macros +================= + +plantuml +-------- + +[PlantUML](http://plantuml.sourceforge.net/) is a tool to render UML diagrams +from their textual representation. It's assumed that it can be invoked via +wrapper /usr/bin/plantuml, here's its example content: + + #!/bin/bash + /usr/bin/java -Djava.io.tmpdir=/var/tmp -jar /usr/share/plantuml/lib/plantuml.jar ${@} + +Result is rendered as PNG file. SVG support seems to be under development for +PlantUML but so far looks like it's still unusable. + +Example of usage: + + {{plantuml( + Alice -> Bob: Authentication Request + alt successful case + Bob -> Alice: Authentication Accepted + else some kind of failure + Bob -> Alice: Authentication Failure + opt + loop 1000 times + Alice -> Bob: DNS Attack + end + end + else Another type of failure + Bob -> Alice: Please repeat + end + )}} + +graphviz +-------- +[Graphviz](http://www.graphviz.org/) is a tool for graph-like structures +visualization. It's assumed that it can be called as /usr/bin/dot. + +Result is rendered as SVG file. + +Example of usage: + + {{graphviz( + digraph finite_state_machine { + rankdir=LR; + size="8,5" + node [shape = doublecircle]; LR_0 LR_3 LR_4 LR_8; + node [shape = circle]; + LR_0 -> LR_2 [ label = "SS(B)" ]; + LR_0 -> LR_1 [ label = "SS(S)" ]; + LR_1 -> LR_3 [ label = "S($end)" ]; + LR_2 -> LR_6 [ label = "SS(b)" ]; + LR_2 -> LR_5 [ label = "SS(a)" ]; + LR_2 -> LR_4 [ label = "S(A)" ]; + LR_5 -> LR_7 [ label = "S(b)" ]; + LR_5 -> LR_5 [ label = "S(a)" ]; + LR_6 -> LR_6 [ label = "S(b)" ]; + LR_6 -> LR_5 [ label = "S(a)" ]; + LR_7 -> LR_8 [ label = "S(b)" ]; + LR_7 -> LR_5 [ label = "S(a)" ]; + LR_8 -> LR_6 [ label = "S(b)" ]; + LR_8 -> LR_5 [ label = "S(a)" ]; + } + )}} + +ritex +----- + +Combination of [Ritex: a Ruby WebTeX to MathML converter](http://ritex.rubyforge.org/) and [SVGMath](http://www.grigoriev.ru/svgmath/) that takes WebTeX +formula specification as input and produces SVG file as output. + +Both ritex and SVGMath require some patches/wrappers. + +Additionally working installation of xmllint from libxml2 with configured +MathML catalog is required: for Gentoo use [this ebuild](http://bugs.gentoo.org/194501). + +Example of usage: + + {{ritex( + G(y) = \left\{\array{ 1 - e^{-\lambda x} & \text{ if } y \geq 0 \\ 0 & \text{ if } y < 0 }\right. + )}} + +fortune +------- + +[Fortune](http://en.wikipedia.org/wiki/Fortune_(Unix)) is a simple program +that displays a random message from a database of quotations. + +Not strictly a filter on its own (as it does not require any input), but it +plays nice with external filtering approach and is fun to use, hence it's here +;-) + +Example of usage: + + {{fortune}} + +Writing new macros +================== + +New macros can easily be added via wiki_external_filter.yml config file. + +Wiki external filter uses standard Unix approach for filtering: input is fed +to the command via stdin and output is read on stdout. If command return +status is zero, content type is assumed to be of content_type specified in +config, otherwise it's assumed it's plain error text. + +You can use prolog/epilog config parameters to add standard text before/after +actual macro content passed to filter. + +Additionally, cache_seconds parameter specifies the number of seconds command +output result should be cached, use zero to disable caching for this +particular command. + +Macro argument is de-escaped via CGI.unescapeHTML call prior to being fed to +filter. + +The way filter output is visualized is controlled via +app/views/wiki_external_filter/macro_*.html.erb files. + +By default all 'image/*' content types are rendered as , 'text/plain' +is included in resulting HTML page with escaping, all '*/*xml*' +and '*/*html*' content types are included directly into output page without +escaping, all other content types are embedded as . + +Current bugs/issues +=================== + +1. Redmine core requires patching to get things work. In fact, the whole + wiki formatting design as of now seems to be quite messy. +2. FireFox has broken implementation of SVG rendering: tag for SVG + inclusion does not work: see the [related bug](https://bugzilla.mozilla.org/show_bug.cgi?id=276431) dated back to 2004 (sic!). + WebKit browsers, on the other hand, are too fragile with SVG + embedding, having problems with resulting image size. + Considering I do not use FireFox but use WebKit-based browsers - guess + which route I've chosen ;-) + If you insist on making it work for FireFox (and breaking things for + WebKit-based browsers) - copy embedding code in the views + templates under the `when /image\/svg\+xml/ then` clause (put it before + generic 'image' case). + Alternatively, you can use some JavaScript-based trickery to use different + embedding ways depending on browser version. + Of course, IE does not support SVG at all, but who really uses it these + days anyway? ;-) +3. For formula support, theoretically ritex alone is sufficient if you have + MathML-capable browser, however in practice there are too many issues with + this approach: for example Firefox (actually the onlt MathML-capable + browser so far, it seems) requires specific DOCTYPE additions that Redmine + currently lacks; additionally, Redmine emits text/html, while Firefox + expects text/xml in order to parse MathML. Changing content type alone is + not sufficient as Redmine HTML output does not pass more strict checks + required for XML output. Hence, the double conversion (WebTeX to MathML + and then MathML to SVG) is necessary. Once (if ever?) MathML support + matures in other browser, possibly this can be revisited. +4. SVGs could have been embedded into HTML page directly (thus allowing to use + redmine links there) but I'm afraid there are similar problems + as with MathML embedding attempts. +5. RoR caching support is a mess: no way to expire old files from file-based + cache??? Are you joking??? + +Additional info +=============== + +1. Somewhat similar plugins (although with narrower scope) are [graphviz plugin](http://github.com/tckz/redmine-wiki_graphviz_plugin) and [latex plugin](http://www.redmine.org/boards/3/topics/4987). + Graphviz functionality is mostly covered by current version of + wiki_external_filter. Latex is not, but only due to the fact I do not have + latex installed nor currently have a need in that: adding macro that + performs latex filtering should be trivial. diff --git a/app/controllers/wiki_external_filter_controller.rb b/app/controllers/wiki_external_filter_controller.rb new file mode 100644 index 0000000..23c03dc --- /dev/null +++ b/app/controllers/wiki_external_filter_controller.rb @@ -0,0 +1,19 @@ + +class WikiExternalFilterController < ApplicationController + + include WikiExternalFilterHelper + + def filter + name = params[:name] + macro = params[:macro] + config = load_config + cache_key = self.construct_cache_key(macro, name) + content = read_fragment cache_key + + if (content) + send_data content, :type => config[macro]['content_type'], :disposition => 'inline' + else + render_404 + end + end +end diff --git a/app/helpers/wiki_external_filter_helper.rb b/app/helpers/wiki_external_filter_helper.rb new file mode 100644 index 0000000..931b77a --- /dev/null +++ b/app/helpers/wiki_external_filter_helper.rb @@ -0,0 +1,120 @@ +require 'digest/sha2' + +module WikiExternalFilterHelper + + def load_config + unless @config + config_file = "#{RAILS_ROOT}/config/wiki_external_filter.yml" + unless File.exists?(config_file) + raise "Config not found: #{config_file}" + end + @config = YAML.load_file(config_file)[RAILS_ENV] + end + @config + end + + def has_macro(macro) + config = load_config + config.key?(macro) + end + + module_function :load_config, :has_macro + + def construct_cache_key(macro, name) + ['wiki_external_filter', macro, name].join("/") + end + + def build(text, macro, info) + + name = Digest::SHA256.hexdigest(text) + result = {} + content = nil + cache_key = nil + expires = 0 + + if info.key?('cache_seconds') + expires = info['cache_seconds'] + else + expires = Setting.plugin_wiki_external_filter['cache_seconds'].to_i + end + + if expires > 0 + cache_key = self.construct_cache_key(macro, name) + content = read_fragment cache_key, :expires_in => expires.seconds + end + + if content + result[:source] = text + result[:content] = content + result[:content_type] = info['content_type'] + RAILS_DEFAULT_LOGGER.debug "from cache: #{name}" + else + result = self.build_forced(text, info) + if result[:status] + if expires > 0 + write_fragment cache_key, result[:content], :expires_in => expires.seconds + RAILS_DEFAULT_LOGGER.debug "cache saved: #{name}" + end + else + raise "Error applying external filter: #{result[:content]}" + end + end + + result[:name] = name + result[:macro] = macro + + return result + end + + def build_forced(text, info) + + result = {} + + RAILS_DEFAULT_LOGGER.debug "executing command: #{info['command']}" + + content = IO.popen(info['command'], 'r+b') { |f| + f.write info[:prolog] if info.key?(:prolog) + f.write CGI.unescapeHTML(text) + f.write info[:epilog] if info.key?(:epilog) + f.close_write + f.read + } + + RAILS_DEFAULT_LOGGER.info("child status: sig=#{$?.termsig}, exit=#{$?.exitstatus}") + + result[:content] = content + result[:content_type] = info['content_type'] + result[:source] = text + result[:status] = $?.exitstatus == 0 + + return result + end + + def render_tag(result) + render_to_string :template => 'wiki_external_filter/macro_inline', :layout => false, :locals => result + end + + def render_block(result, wiki_name) + result = result.dup + result[:wiki_name] = wiki_name + render_to_string :template => 'wiki_external_filter/macro_block', :layout => false, :locals => result + end + + class Macro + def initialize(view, source, macro, info) + @view = view + @view.controller.extend(WikiExternalFilterHelper) + source.gsub!(/
/, "") + source.gsub!(/<\/?p>/, "") + @result = @view.controller.build(source, macro, info) + end + + def render() + @view.controller.render_tag(@result) + end + + def render_block(wiki_name) + @view.controller.render_block(@result, wiki_name) + end + end +end diff --git a/app/views/wiki_external_filter/_settings.html.erb b/app/views/wiki_external_filter/_settings.html.erb new file mode 100644 index 0000000..3ba45d2 --- /dev/null +++ b/app/views/wiki_external_filter/_settings.html.erb @@ -0,0 +1,21 @@ +
+ Caching Settings +

+ <%= text_field_tag 'settings[cache_seconds]', @settings['cache_seconds'] %> seconds +

+

+ Enter zero to disable caching. Make sure to configure fragment_cache_store if you use the value > 0. +

+

+ Configuration example for ActiveSupport::Cache::FileStore, config/environments/production.rb file:
+
+ ...
+ config.action_controller.fragment_cache_store = :file_store, "#{RAILS_ROOT}/cache"
+ ... +

+

+ Current cache settings are:
+ ActionController::Base.cache_configured? = <%= h ActionController::Base.cache_configured? ? "true" : "false" %>
+ ActionController::Base.fragment_cache_store = <%= h ActionController::Base.fragment_cache_store.inspect %> +

+
diff --git a/app/views/wiki_external_filter/macro_block.html.erb b/app/views/wiki_external_filter/macro_block.html.erb new file mode 100644 index 0000000..a72ab02 --- /dev/null +++ b/app/views/wiki_external_filter/macro_block.html.erb @@ -0,0 +1,29 @@ +
+<% + case content_type + when /image\// then +%> + 'filter', :macro => macro, :name => name) %>' alt="<%= h source %>" /> +<% + when /text\/plain/ then +%> +
<%= h content %>
+<% + when /\/.*(x|ht)ml/ then +%> +
<%= content %>
+<% + else +%> + 'filter', :macro => macro, :name => name) %>' type='<%= content_type %>' title="<%= h source %>"> + 'filter', :macro => macro, :name => name) %>' type='<%= content_type %>' title="<%= h source %>" /> + +<% + end +%> +
+ Goto source: [[<%= wiki_name %>]] +
+<% content_for :header_tags do %> + <%= stylesheet_link_tag "wiki_external_filter.css", :plugin => "wiki_external_filter", :media => :all %> +<% end %> diff --git a/app/views/wiki_external_filter/macro_inline.html.erb b/app/views/wiki_external_filter/macro_inline.html.erb new file mode 100644 index 0000000..32d1264 --- /dev/null +++ b/app/views/wiki_external_filter/macro_inline.html.erb @@ -0,0 +1,25 @@ +<% + case content_type + when /image\// then +%> + 'filter', :macro => macro, :name => name) %>' alt="<%= h source %>" /> +<% + when /text\/plain/ then +%> +
<%= h content %>
+<% + when /\/.*(x|ht)ml/ then +%> +
<%= content %>
+<% + else +%> + 'filter', :macro => macro, :name => name) %>' type='<%= content_type %>' title="<%= h source %>"> + 'filter', :macro => macro, :name => name) %>' type='<%= content_type %>' title="<%= h source %>" /> + +<% + end +%> +<% content_for :header_tags do %> + <%= stylesheet_link_tag "wiki_external_filter.css", :plugin => "wiki_external_filter", :media => :all %> +<% end %> diff --git a/assets/stylesheets/wiki_external_filter.css b/assets/stylesheets/wiki_external_filter.css new file mode 100644 index 0000000..9e7eda3 --- /dev/null +++ b/assets/stylesheets/wiki_external_filter.css @@ -0,0 +1,18 @@ +.externalfilterinline{ + position: relative; + bottom: -0.2em; +} + +.externalfilterblock img{ + display: block; +} + +.externalfilterblock .wiki_page{ + font-size: 80%; + border-top: 1px solid #ccc; + color: #aaa; +} + +.externalfilterblock .wiki_page a{ + color: #999; +} diff --git a/config/wiki_external_filter.yml b/config/wiki_external_filter.yml new file mode 100644 index 0000000..e1e7472 --- /dev/null +++ b/config/wiki_external_filter.yml @@ -0,0 +1,31 @@ +development: &development + plantuml: + description: "Constructs UML diagram image from its textual description in PlantUML language, see http://plantuml.sourceforge.net" + command: "/usr/bin/plantuml -pipe" + content_type: "image/png" + prolog: "@startuml" + epilog: "@enduml" + graphviz: + description: "Constructs graph image from its textual description in DOT language, see http://www.graphviz.org" + command: "/usr/bin/dot -Tsvg" + content_type: "image/svg+xml" + ritex: + description: "Converts WebTeX expression to MathML, see http://ritex.rubyforge.org/" + command: "(echo ''; /usr/bin/ritex) | xmllint --noent --nonet --catalogs --loaddtd - | /usr/bin/math2svg" + content_type: "image/svg+xml" +# For MathML-compliant browsers and when Redmine is fully XML-compliant. +# ritex: +# description: "Converts WebTeX expression to MathML, see http://ritex.rubyforge.org/" +# command: "/usr/bin/ritex" +# content_type: "application/xhtml+xml" + fortune: + description: "Prints a random, hopefully interesting, adage, see http://en.wikipedia.org/wiki/Fortune_(Unix)" + command: "/usr/bin/fortune" + cache_seconds: 0 + content_type: "text/plain" + +test: + type: mock + +production: + <<: *development diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..46d36a6 --- /dev/null +++ b/init.rb @@ -0,0 +1,43 @@ +require 'redmine' + +RAILS_DEFAULT_LOGGER.info 'Starting wiki_external_filter plugin for Redmine' + +Redmine::Plugin.register :wiki_external_filter do + name 'Wiki External Filter Plugin' + author 'Alexander Tsvyashchenko' + description 'Processes given text using external command and renders its output' + author_url 'http://www.ndl.kiev.ua' + version '0.0.1' + settings :default => {'cache_seconds' => '0'}, :partial => 'wiki_external_filter/settings' + + config = WikiExternalFilterHelper.load_config + RAILS_DEFAULT_LOGGER.debug "Config: #{config.inspect}" + + config.keys.each do |name| + RAILS_DEFAULT_LOGGER.info "Registering #{name} macro with wiki_external_filter" + Redmine::WikiFormatting::Macros.register do + info = config[name] + desc info['description'] + macro name do |wiki_content_obj, args| + m = WikiExternalFilterHelper::Macro.new(self, args.to_s, name, info) + m.render + end + + # code borrowed from wiki latex plugin + # code borrowed from wiki template macro + desc info['description'] + macro (name + "_include").to_sym do |obj, args| + page = Wiki.find_page(args.to_s, :project => @project) + raise 'Page not found' if page.nil? || !User.current.allowed_to?(:view_wiki_pages, page.wiki.project) + + @included_wiki_pages ||= [] + raise 'Circular inclusion detected' if @included_wiki_pages.include?(page.title) + @included_wiki_pages << page.title + m = WikiExternalFilterHelper::Macro.new(self, page.content.text, name, info) + @included_wiki_pages.pop + m.render_block(args.to_s) + end + end + end + +end diff --git a/routes.rb b/routes.rb new file mode 100644 index 0000000..c8d80e2 --- /dev/null +++ b/routes.rb @@ -0,0 +1,2 @@ + +connect 'wiki_external_filter/:macro/:name', :controller => 'wiki_external_filter', :action => 'filter', :macro => /\S+/