instiki 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README +172 -0
- data/app/controllers/wiki.rb +389 -0
- data/app/models/author.rb +4 -0
- data/app/models/chunks/category.rb +31 -0
- data/app/models/chunks/chunk.rb +20 -0
- data/app/models/chunks/engines.rb +38 -0
- data/app/models/chunks/include.rb +29 -0
- data/app/models/chunks/literal.rb +19 -0
- data/app/models/chunks/match.rb +19 -0
- data/app/models/chunks/nowiki.rb +31 -0
- data/app/models/chunks/test.rb +18 -0
- data/app/models/chunks/uri.rb +97 -0
- data/app/models/chunks/wiki.rb +82 -0
- data/app/models/page.rb +86 -0
- data/app/models/page_lock.rb +24 -0
- data/app/models/page_set.rb +64 -0
- data/app/models/revision.rb +90 -0
- data/app/models/web.rb +89 -0
- data/app/models/wiki_content.rb +105 -0
- data/app/models/wiki_service.rb +83 -0
- data/app/models/wiki_words.rb +25 -0
- data/app/views/bottom.rhtml +4 -0
- data/app/views/markdown_help.rhtml +16 -0
- data/app/views/navigation.rhtml +19 -0
- data/app/views/rdoc_help.rhtml +16 -0
- data/app/views/static_style_sheet.rhtml +199 -0
- data/app/views/textile_help.rhtml +28 -0
- data/app/views/top.rhtml +49 -0
- data/app/views/wiki/authors.rhtml +13 -0
- data/app/views/wiki/edit.rhtml +31 -0
- data/app/views/wiki/edit_web.rhtml +138 -0
- data/app/views/wiki/export.rhtml +14 -0
- data/app/views/wiki/feeds.rhtml +10 -0
- data/app/views/wiki/list.rhtml +57 -0
- data/app/views/wiki/locked.rhtml +14 -0
- data/app/views/wiki/login.rhtml +11 -0
- data/app/views/wiki/new.rhtml +27 -0
- data/app/views/wiki/new_system.rhtml +78 -0
- data/app/views/wiki/new_web.rhtml +64 -0
- data/app/views/wiki/page.rhtml +81 -0
- data/app/views/wiki/print.rhtml +16 -0
- data/app/views/wiki/published.rhtml +10 -0
- data/app/views/wiki/recently_revised.rhtml +30 -0
- data/app/views/wiki/revision.rhtml +81 -0
- data/app/views/wiki/rollback.rhtml +31 -0
- data/app/views/wiki/rss_feed.rhtml +22 -0
- data/app/views/wiki/search.rhtml +15 -0
- data/app/views/wiki/tex.rhtml +23 -0
- data/app/views/wiki/tex_web.rhtml +35 -0
- data/app/views/wiki/web_list.rhtml +13 -0
- data/app/views/wiki_words_help.rhtml +8 -0
- data/instiki +67 -0
- data/libraries/action_controller_servlet.rb +177 -0
- data/libraries/diff/diff.rb +475 -0
- data/libraries/erb.rb +490 -0
- data/libraries/madeleine_service.rb +68 -0
- data/libraries/rdocsupport.rb +156 -0
- data/libraries/redcloth_for_tex.rb +869 -0
- data/libraries/view_helper.rb +33 -0
- data/libraries/web_controller_server.rb +81 -0
- metadata +142 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
<%
|
2
|
+
@title = "Rollback to #{@page.plain_name} Rev ##{@revision.number}"
|
3
|
+
@content_width = 720
|
4
|
+
@hide_navigation = true
|
5
|
+
%><%= sub_template "top" %>
|
6
|
+
|
7
|
+
<%= "<p style='color:red'>Please correct the error that caused this error in rendering:<br/><small>#{@params["msg"]}</small></p>" if @params["msg"] %>
|
8
|
+
|
9
|
+
<form id="editForm" action="../save/<%= @page.name %>" method="post" onSubmit="cleanAuthorName();">
|
10
|
+
<p>
|
11
|
+
<textarea name="content" style="font-size: 12px; width: 450px; height: 500px"><%= @revision.content %></textarea>
|
12
|
+
</p>
|
13
|
+
<p>
|
14
|
+
<input type="submit" value="Update"> as
|
15
|
+
<input type="text" name="author" id="authorName" value="<%= @author %>"
|
16
|
+
onClick="this.value == 'AnonymousCoward' ? this.value = '' : true">
|
17
|
+
| <a href="../cancel_edit/<%= @page.name %>">Cancel</a> <small>(unlocks page)</small>
|
18
|
+
</p>
|
19
|
+
</form>
|
20
|
+
|
21
|
+
<%= render_markup_help %>
|
22
|
+
|
23
|
+
<script language="JavaScript1.2">
|
24
|
+
function cleanAuthorName() {
|
25
|
+
if (document.getElementById('authorName').value == "") {
|
26
|
+
document.getElementById('authorName').value = 'AnonymousCoward';
|
27
|
+
}
|
28
|
+
}
|
29
|
+
</script>
|
30
|
+
|
31
|
+
<%= sub_template "bottom" %>
|
@@ -0,0 +1,22 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
2
|
+
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
3
|
+
<channel>
|
4
|
+
<title><%= @web.name %></title>
|
5
|
+
<link><%= "#{@web_url}/show/HomePage" %></link>
|
6
|
+
<description>An Instiki wiki</description>
|
7
|
+
<language>en-us</language>
|
8
|
+
<ttl>40</ttl>
|
9
|
+
<% for page in @pages_by_revision %>
|
10
|
+
<item>
|
11
|
+
<title><%= page.plain_name %></title>
|
12
|
+
<% unless @hide_description %>
|
13
|
+
<description><%= CGI.escapeHTML(page.display_content) %></description>
|
14
|
+
<% end %>
|
15
|
+
<pubDate><%= page.created_at.strftime "%a, %e %b %Y %H:%M:%S %Z" %></pubDate>
|
16
|
+
<guid><%= "#{@web_url}/show/#{page.name}" %></guid>
|
17
|
+
<link><%= "#{@web_url}/show/#{page.name}" %></link>
|
18
|
+
<dc:creator><%= WikiWords.separate(page.author) %></dc:creator>
|
19
|
+
</item>
|
20
|
+
<% end %>
|
21
|
+
</channel>
|
22
|
+
</rss>
|
@@ -0,0 +1,15 @@
|
|
1
|
+
<% @title = @results.length > 0 ? "#{@results.length} pages contains \"#{@params["query"]}\"" : "No pages contains \"#{@query}\"" %><%= sub_template "top" %>
|
2
|
+
|
3
|
+
<% if @results.length > 0 %>
|
4
|
+
<ul>
|
5
|
+
<% for page in @results %>
|
6
|
+
<li><a href="../show/<%= page.name %>"><%= page.plain_name %></a></li>
|
7
|
+
<% end %>
|
8
|
+
</ul>
|
9
|
+
<% else %>
|
10
|
+
<p>Perhaps you should try expanding your query. Remember that Instiki searches for entire phrases, so if you search for "all that jazz" it will not match pages that contain these words in separation—only as a sentence fragment.</p>
|
11
|
+
|
12
|
+
<p>If you're a high-tech computer wizard, you might even want try constructing a regular expression. That's actually what Instiki uses, so go right ahead and flex your "[a-z]*Leet?RegExpSkill(s|z)"</p>
|
13
|
+
<% end %>
|
14
|
+
|
15
|
+
<%= sub_template "bottom" %>
|
@@ -0,0 +1,23 @@
|
|
1
|
+
\documentclass[12pt,titlepage]{article}
|
2
|
+
|
3
|
+
\usepackage[danish]{babel} %danske tekster
|
4
|
+
\usepackage[OT1]{fontenc} %rigtige danske bogstaver...
|
5
|
+
\usepackage{a4}
|
6
|
+
\usepackage{graphicx}
|
7
|
+
\usepackage{ucs}
|
8
|
+
\usepackage[utf8]{inputenc}
|
9
|
+
\input epsf
|
10
|
+
|
11
|
+
%-------------------------------------------------------------------
|
12
|
+
|
13
|
+
\begin{document}
|
14
|
+
|
15
|
+
\sloppy
|
16
|
+
|
17
|
+
%-------------------------------------------------------------------
|
18
|
+
|
19
|
+
\section*{<%= @page.name %>}
|
20
|
+
|
21
|
+
<%= @tex_content %>
|
22
|
+
|
23
|
+
\end{document}
|
@@ -0,0 +1,35 @@
|
|
1
|
+
\documentclass[12pt,titlepage]{article}
|
2
|
+
|
3
|
+
\usepackage{fancyhdr}
|
4
|
+
\pagestyle{fancy}
|
5
|
+
|
6
|
+
\fancyhead[LE,RO]{}
|
7
|
+
\fancyhead[LO,RE]{\nouppercase{\bfseries \leftmark}}
|
8
|
+
\fancyfoot[C]{\thepage}
|
9
|
+
|
10
|
+
\usepackage[danish]{babel} %danske tekster
|
11
|
+
\usepackage{a4}
|
12
|
+
\usepackage{graphicx}
|
13
|
+
\usepackage{ucs}
|
14
|
+
\usepackage[utf8]{inputenc}
|
15
|
+
\input epsf
|
16
|
+
|
17
|
+
|
18
|
+
%-------------------------------------------------------------------
|
19
|
+
|
20
|
+
\title{<%= @web_name %>}
|
21
|
+
|
22
|
+
\begin{document}
|
23
|
+
|
24
|
+
\maketitle
|
25
|
+
|
26
|
+
\tableofcontents
|
27
|
+
\pagebreak
|
28
|
+
|
29
|
+
\sloppy
|
30
|
+
|
31
|
+
%-------------------------------------------------------------------
|
32
|
+
|
33
|
+
<%= @tex_content %>
|
34
|
+
|
35
|
+
\end{document}
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<% @title = "Wiki webs" %><%= sub_template "top" %>
|
2
|
+
|
3
|
+
<ul>
|
4
|
+
<% for web in @webs %>
|
5
|
+
<li>
|
6
|
+
<a href="/<%= web.address %>/show/HomePage"><%= web.name %></a>
|
7
|
+
(<%= web.pages.length %> pages by <%= web.authors.length %> authors)
|
8
|
+
<% if web.published then %>(<a href="/<%= web.address %>/published/HomePage">published</a>)<% end %>
|
9
|
+
</li>
|
10
|
+
<% end %>
|
11
|
+
</ul>
|
12
|
+
|
13
|
+
<%= sub_template "bottom" %>
|
@@ -0,0 +1,8 @@
|
|
1
|
+
<h3>Wiki words</h3>
|
2
|
+
<p style="border-top: 1px dotted #ccc; margin-top: 0px">
|
3
|
+
Two or more uppercase words stuck together (camel case) or any phrase surrounded by doubble brackets is a wiki word. A camel-case wiki word can be escaped by putting \ in front of it.
|
4
|
+
</p>
|
5
|
+
<p>
|
6
|
+
Wiki words: <i>HomePage, ThreeWordsTogether, [[C++]], [[Let's play again!]]</i><br/>
|
7
|
+
Not wiki words: <i>IBM, School</i>
|
8
|
+
</p>
|
data/instiki
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
#!/usr/local/bin/ruby
|
2
|
+
|
3
|
+
if RUBY_VERSION < "1.8.1"
|
4
|
+
puts "Instiki requires Ruby 1.8.1+"
|
5
|
+
exit
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'optparse'
|
9
|
+
require 'fileutils'
|
10
|
+
|
11
|
+
cdir = File.expand_path(File.dirname(__FILE__))
|
12
|
+
%w( /libraries/ /app/models /app/controllers ).each { |dir| $:.unshift(cdir + dir) }
|
13
|
+
%w( web_controller_server action_controller_servlet wiki_service wiki ).each { |lib| require lib }
|
14
|
+
|
15
|
+
fork_available = true
|
16
|
+
begin
|
17
|
+
exit unless fork
|
18
|
+
rescue NotImplementedError
|
19
|
+
fork_available = false
|
20
|
+
end
|
21
|
+
|
22
|
+
begin
|
23
|
+
pdflatex_available = system "pdflatex -version"
|
24
|
+
rescue Errno::ENOENT
|
25
|
+
pdflatex_available = false
|
26
|
+
end
|
27
|
+
|
28
|
+
OPTIONS = {
|
29
|
+
:server_type => fork_available ? Daemon : SimpleServer,
|
30
|
+
:port => 2500,
|
31
|
+
:storage => "#{File.expand_path(FileUtils.pwd)}/storage",
|
32
|
+
:pdflatex => pdflatex_available
|
33
|
+
}
|
34
|
+
|
35
|
+
ARGV.options do |opts|
|
36
|
+
script_name = File.basename($0)
|
37
|
+
opts.banner = "Usage: ruby #{script_name} [options]"
|
38
|
+
|
39
|
+
opts.separator ""
|
40
|
+
|
41
|
+
opts.on("-p", "--port=port", Integer,
|
42
|
+
"Runs Instiki on the specified port.",
|
43
|
+
"Default: 2500") { |OPTIONS[:port]| }
|
44
|
+
opts.on("-s", "--simple", "--simple-server",
|
45
|
+
"Forces Instiki not to run as a Daemon if fork is available."
|
46
|
+
) { OPTIONS[:server_type] = SimpleServer }
|
47
|
+
opts.on("-t", "--storage=storage", String,
|
48
|
+
"Makes Instiki use the specified directory for storage.",
|
49
|
+
"Default: ./storage/[port]") { |OPTIONS[:storage]| }
|
50
|
+
|
51
|
+
opts.separator ""
|
52
|
+
|
53
|
+
opts.on("-h", "--help",
|
54
|
+
"Show this help message.") { puts opts; exit }
|
55
|
+
|
56
|
+
opts.parse!
|
57
|
+
end
|
58
|
+
|
59
|
+
Socket.do_not_reverse_lookup = true
|
60
|
+
|
61
|
+
storage_dir = OPTIONS[:storage] + "/" + OPTIONS[:port].to_s
|
62
|
+
FileUtils.mkdir_p(storage_dir)
|
63
|
+
WikiService.storage_path = storage_dir
|
64
|
+
|
65
|
+
WikiController.template_root = "#{cdir}/app/views/"
|
66
|
+
|
67
|
+
WebControllerServer.new(OPTIONS[:port], OPTIONS[:server_type], "#{cdir}/app/controllers/")
|
@@ -0,0 +1,177 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'cgi'
|
3
|
+
require 'webrick'
|
4
|
+
include WEBrick
|
5
|
+
|
6
|
+
require 'view_helper'
|
7
|
+
|
8
|
+
class ActionControllerServlet < HTTPServlet::AbstractServlet
|
9
|
+
@@template_root = "./views"
|
10
|
+
def self.template_root() @@template_root end
|
11
|
+
def self.template_root=(template_root) @@template_root = template_root end
|
12
|
+
|
13
|
+
include ViewHelper
|
14
|
+
|
15
|
+
def do_POST(req, res) do_GET(req, res) end
|
16
|
+
|
17
|
+
def do_GET(req, res)
|
18
|
+
@req, @res = req, res
|
19
|
+
|
20
|
+
@res['Content-Type'] = "text/html; charset=utf-8"
|
21
|
+
@res['Pragma'] = "no-cache"
|
22
|
+
@res['Cache-Control'] = "no-cache"
|
23
|
+
|
24
|
+
@params = @req.query
|
25
|
+
@assigns = {}
|
26
|
+
@performed_render = @performed_redirect = false
|
27
|
+
|
28
|
+
@logger.info "Performing #{action_name}"
|
29
|
+
@logger.info " Parameters: #{@params.inspect}"
|
30
|
+
@logger.info " Cookies: #{@req.cookies.collect { |c| "#{c.name} => #{c.value}" }.join(", ") }"
|
31
|
+
|
32
|
+
perform_action
|
33
|
+
@res
|
34
|
+
end
|
35
|
+
|
36
|
+
protected
|
37
|
+
def template_root() self.class.template_root end
|
38
|
+
|
39
|
+
# Issues a HTTP 302 (location) redirect to the specified <tt>path</tt>. Query string
|
40
|
+
# parameters can be specified in the <tt>params</tt> hash and are automatically URL escaped.
|
41
|
+
# An <tt>anchor</tt> can also be specified (as a string).
|
42
|
+
def redirect_path(path, params = nil, anchor = nil)
|
43
|
+
path << build_query_string(params) unless params.nil?
|
44
|
+
path << "\##{anchor}" unless anchor.nil?
|
45
|
+
@res.set_redirect WEBrick::HTTPStatus::MovedPermanently, path
|
46
|
+
@performed_redirect = true
|
47
|
+
end
|
48
|
+
|
49
|
+
# Redirects the browser to another action within the current controller, so redirect_path "pages"
|
50
|
+
# within a controller called WikiController would take the user to "http://www.example.com/wiki/pages"
|
51
|
+
def redirect_action(action, params = nil, anchor = nil)
|
52
|
+
redirect_path "/#{controller_name}/#{action}", params, anchor
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
# Compiles the template response and adds it to the response. If no template_name has been
|
57
|
+
# supplied, the action parameter parsed to the controller is used. So invoking
|
58
|
+
# render on a object initialized with myController.new("show_person") will compile the template
|
59
|
+
# located at "../views/show_person.rhtml". Notice that the ".rhtml" extension is postfixed
|
60
|
+
# to the template_name regardless of one was passed or that the action parameter was used.
|
61
|
+
def render(template_name = "#{controller_name}/#{action_name}")
|
62
|
+
@performed_render = true
|
63
|
+
@logger.info "Rendering: #{template_name}"
|
64
|
+
add_instance_variables_to_assigns
|
65
|
+
@res.body = template_result "#{template_root}/#{template_name}.rhtml"
|
66
|
+
end
|
67
|
+
|
68
|
+
def render_text(text)
|
69
|
+
@performed_render = true
|
70
|
+
@logger.info "Rendering in text"
|
71
|
+
@res.body = text
|
72
|
+
end
|
73
|
+
|
74
|
+
# Wrapper around render that presumes the current controller is used as a base for the action,
|
75
|
+
# so render_action("page") in WikiController will be equal to render("wiki/page")
|
76
|
+
def render_action(action_name)
|
77
|
+
render "#{controller_name}/#{action_name}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def sub_template(template_name)
|
81
|
+
template_result "#{template_root}/#{template_name}.rhtml"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Returns the value of the cookie matching +name+ (or nil if none is found).
|
85
|
+
def read_cookie(key)
|
86
|
+
cookies = @req.cookies.select { |cookie| cookie.name == key }
|
87
|
+
cookies.empty? ? nil : cookies.first.value
|
88
|
+
end
|
89
|
+
|
90
|
+
def write_cookie(key, value, permanent = false)
|
91
|
+
@logger.info "Writing cookie: #{key} => #{value}"
|
92
|
+
|
93
|
+
cookie = WEBrick::Cookie.new(key, value)
|
94
|
+
cookie.path = "/"
|
95
|
+
cookie.expires = Time.local(2030) if permanent
|
96
|
+
@res.cookies << cookie
|
97
|
+
end
|
98
|
+
|
99
|
+
|
100
|
+
# Returns the last part of the uri path ("page" in "/wiki/page") by default, but
|
101
|
+
# can be overwritten to implement mod_rewrite-like behaviour, such as "page" in "/wiki/page/home"
|
102
|
+
def action_name
|
103
|
+
@req.path.to_s.split(/\//).last
|
104
|
+
end
|
105
|
+
|
106
|
+
# Returns an array with each of the parts in the request as an element. So /something/cool/dude
|
107
|
+
# returns ["something", "cool", "dude"]
|
108
|
+
def request_path
|
109
|
+
request_path_parts = @req.path.to_s.split(/\//)
|
110
|
+
request_path_parts.length > 1 ? request_path_parts[1..-1] : []
|
111
|
+
end
|
112
|
+
|
113
|
+
# Can be overwritten by a controller class to implement shared behaviour for all the actions in
|
114
|
+
# the class, such as security measures
|
115
|
+
def before_action() end
|
116
|
+
|
117
|
+
|
118
|
+
private
|
119
|
+
# Wraps around all action calls to ensure the session is properly updated and closed.
|
120
|
+
# Figures out whether an action, such as "list", is implemented as show_list or do_list.
|
121
|
+
# The show_* type has precedence and automatically calls render after execution.
|
122
|
+
def perform_action
|
123
|
+
if before_action == false then return end
|
124
|
+
|
125
|
+
if action_methods.include?(action_name)
|
126
|
+
send(action_name)
|
127
|
+
render unless @performed_render || @performed_redirect || !@res.body.empty?
|
128
|
+
elsif template_exists_for_action
|
129
|
+
render
|
130
|
+
else
|
131
|
+
raise "No action responded to #{action_name}", caller
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def add_instance_variables_to_assigns
|
136
|
+
instance_variables.each { |var|
|
137
|
+
next if protected_instance_variables.include?(var)
|
138
|
+
@assigns[var[1..-1]] = instance_variable_get(var)
|
139
|
+
}
|
140
|
+
end
|
141
|
+
|
142
|
+
def protected_instance_variables
|
143
|
+
[ "@assigns", "@performed_redirect", "@performed_render" ]
|
144
|
+
end
|
145
|
+
|
146
|
+
def action_methods
|
147
|
+
methods - Object.instance_methods
|
148
|
+
end
|
149
|
+
|
150
|
+
# Returns true if a template exists in the controller directory for the specified <tt>action_name</tt>,
|
151
|
+
# which would mean true if called for WikiController#changes and "/views/wiki/changes.rhtml" existed.
|
152
|
+
def template_exists_for_action
|
153
|
+
File.exist? action_template_path
|
154
|
+
end
|
155
|
+
|
156
|
+
def action_template_path(action = action_name)
|
157
|
+
"#{template_root}/#{controller_name}/#{action}.rhtml"
|
158
|
+
end
|
159
|
+
|
160
|
+
def template_result(template_path)
|
161
|
+
@assigns.each { |key, value| instance_variable_set "@#{key}", value }
|
162
|
+
ERB.new(IO.readlines(template_path).join).result(binding)
|
163
|
+
end
|
164
|
+
|
165
|
+
# Converts the class name from something like "OneModule::TwoModule::NeatController"
|
166
|
+
# to "neat".
|
167
|
+
def controller_name
|
168
|
+
self.class.to_s.split("::").last.sub(/Controller/, "").downcase
|
169
|
+
end
|
170
|
+
|
171
|
+
# Returns a query string with escaped keys and values from the passed hash.
|
172
|
+
def build_query_string(hash)
|
173
|
+
elements = []
|
174
|
+
hash.each { |key, value| elements << "#{CGI.escape(key)}=#{CGI.escape(value.to_s)}" }
|
175
|
+
"?" + elements.join("&")
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,475 @@
|
|
1
|
+
# heavily based off difflib.py - see that file for documentation
|
2
|
+
# ported from Python by Bill Atkins
|
3
|
+
|
4
|
+
# This does not support all features offered by difflib; it
|
5
|
+
# implements only the subset of features necessary
|
6
|
+
# to support a Ruby version of HTML Differ. You're welcome to finish this off.
|
7
|
+
|
8
|
+
# By default, String#each iterates by line. This isn't really appropriate
|
9
|
+
# for diff, so often a string will be split by // to get an array of one-
|
10
|
+
# character strings.
|
11
|
+
|
12
|
+
# Some methods in Diff are untested and are not guaranteed to work. The
|
13
|
+
# methods in HTMLDiff and any methods it calls should work quite well.
|
14
|
+
|
15
|
+
# changes by DenisMertz
|
16
|
+
# * main change:
|
17
|
+
# ** get the tag soup away
|
18
|
+
# the tag soup problem was first reported with <p> tags, but it appeared also with
|
19
|
+
# <li>, <ul> etc... tags
|
20
|
+
# this version should mostly fix these problems
|
21
|
+
# ** added a Builder class to manage the creation of the final htmldiff
|
22
|
+
# * minor changes:
|
23
|
+
# ** use symbols instead of string to represent opcodes
|
24
|
+
# ** small fix to html2list
|
25
|
+
#
|
26
|
+
|
27
|
+
module Enumerable
|
28
|
+
def reduce(init)
|
29
|
+
result = init
|
30
|
+
each { |item| result = yield(result, item) }
|
31
|
+
result
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
module Diff
|
36
|
+
|
37
|
+
class SequenceMatcher
|
38
|
+
def initialize(a=[''], b=[''], isjunk=nil, byline=false)
|
39
|
+
a = (!byline and a.kind_of? String) ? a.split(//) : a
|
40
|
+
b = (!byline and b.kind_of? String) ? b.split(//) : b
|
41
|
+
@isjunk = isjunk || proc {}
|
42
|
+
set_seqs a, b
|
43
|
+
end
|
44
|
+
|
45
|
+
def set_seqs(a, b)
|
46
|
+
set_seq_a a
|
47
|
+
set_seq_b b
|
48
|
+
end
|
49
|
+
|
50
|
+
def set_seq_a(a)
|
51
|
+
@a = a
|
52
|
+
@matching_blocks = @opcodes = nil
|
53
|
+
end
|
54
|
+
|
55
|
+
def set_seq_b(b)
|
56
|
+
@b = b
|
57
|
+
@matching_blocks = @opcodes = nil
|
58
|
+
chain_b
|
59
|
+
end
|
60
|
+
|
61
|
+
def chain_b
|
62
|
+
@fullbcount = nil
|
63
|
+
@b2j = {}
|
64
|
+
pophash = {}
|
65
|
+
junkdict = {}
|
66
|
+
|
67
|
+
@b.each_with_index do |elt, i|
|
68
|
+
if @b2j.has_key? elt
|
69
|
+
indices = @b2j[elt]
|
70
|
+
if @b.length >= 200 and indices.length * 100 > @b.length
|
71
|
+
pophash[elt] = 1
|
72
|
+
indices.clear
|
73
|
+
else
|
74
|
+
indices.push i
|
75
|
+
end
|
76
|
+
else
|
77
|
+
@b2j[elt] = [i]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
pophash.each_key { |elt| @b2j.delete elt }
|
82
|
+
|
83
|
+
junkdict = {}
|
84
|
+
|
85
|
+
unless @isjunk.nil?
|
86
|
+
[pophash, @b2j].each do |d|
|
87
|
+
d.each_key do |elt|
|
88
|
+
if @isjunk.call(elt)
|
89
|
+
junkdict[elt] = 1
|
90
|
+
d.delete elt
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
@isbjunk = junkdict.method(:has_key?)
|
97
|
+
@isbpopular = junkdict.method(:has_key?)
|
98
|
+
end
|
99
|
+
|
100
|
+
def find_longest_match(alo, ahi, blo, bhi)
|
101
|
+
besti, bestj, bestsize = alo, blo, 0
|
102
|
+
|
103
|
+
j2len = {}
|
104
|
+
|
105
|
+
(alo..ahi).step do |i|
|
106
|
+
newj2len = {}
|
107
|
+
(@b2j[@a[i]] || []).each do |j|
|
108
|
+
if j < blo
|
109
|
+
next
|
110
|
+
end
|
111
|
+
if j >= bhi
|
112
|
+
break
|
113
|
+
end
|
114
|
+
|
115
|
+
k = newj2len[j] = (j2len[j - 1] || 0) + 1
|
116
|
+
if k > bestsize
|
117
|
+
besti, bestj, bestsize = i - k + 1, j - k + 1, k
|
118
|
+
end
|
119
|
+
end
|
120
|
+
j2len = newj2len
|
121
|
+
end
|
122
|
+
|
123
|
+
while besti > alo and bestj > blo and
|
124
|
+
not @isbjunk.call(@b[bestj-1]) and
|
125
|
+
@a[besti-1] == @b[bestj-1]
|
126
|
+
besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
|
127
|
+
end
|
128
|
+
|
129
|
+
while besti+bestsize < ahi and bestj+bestsize < bhi and
|
130
|
+
not @isbjunk.call(@b[bestj+bestsize]) and
|
131
|
+
@a[besti+bestsize] == @b[bestj+bestsize]
|
132
|
+
bestsize += 1
|
133
|
+
end
|
134
|
+
|
135
|
+
while besti > alo and bestj > blo and
|
136
|
+
@isbjunk.call(@b[bestj-1]) and
|
137
|
+
@a[besti-1] == @b[bestj-1]
|
138
|
+
besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
|
139
|
+
end
|
140
|
+
|
141
|
+
while besti+bestsize < ahi and bestj+bestsize < bhi and
|
142
|
+
@isbjunk.call(@b[bestj+bestsize]) and
|
143
|
+
@a[besti+bestsize] == @b[bestj+bestsize]
|
144
|
+
bestsize += 1
|
145
|
+
end
|
146
|
+
|
147
|
+
[besti, bestj, bestsize]
|
148
|
+
end
|
149
|
+
|
150
|
+
def get_matching_blocks
|
151
|
+
return @matching_blocks unless @matching_blocks.nil? or
|
152
|
+
@matching_blocks.empty?
|
153
|
+
|
154
|
+
@matching_blocks = []
|
155
|
+
la, lb = @a.length, @b.length
|
156
|
+
match_block_helper(0, la, 0, lb, @matching_blocks)
|
157
|
+
@matching_blocks.push [la, lb, 0]
|
158
|
+
end
|
159
|
+
|
160
|
+
def match_block_helper(alo, ahi, blo, bhi, answer)
|
161
|
+
i, j, k = x = find_longest_match(alo, ahi, blo, bhi)
|
162
|
+
if not k.zero?
|
163
|
+
if alo < i and blo < j
|
164
|
+
match_block_helper(alo, i, blo, j, answer)
|
165
|
+
end
|
166
|
+
answer.push x
|
167
|
+
if i + k < ahi and j + k < bhi
|
168
|
+
match_block_helper(i + k, ahi, j + k, bhi, answer)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def get_opcodes
|
174
|
+
unless @opcodes.nil? or @opcodes.empty?
|
175
|
+
return @opcodes
|
176
|
+
end
|
177
|
+
|
178
|
+
i = j = 0
|
179
|
+
@opcodes = answer = []
|
180
|
+
get_matching_blocks.each do |ai, bj, size|
|
181
|
+
tag = if i < ai and j < bj
|
182
|
+
:replace
|
183
|
+
elsif i < ai
|
184
|
+
:delete
|
185
|
+
elsif j < bj
|
186
|
+
:insert
|
187
|
+
end
|
188
|
+
|
189
|
+
answer.push [tag, i, ai, j, bj] if tag
|
190
|
+
|
191
|
+
i, j = ai + size, bj + size
|
192
|
+
|
193
|
+
answer.push [:equal, ai, i, bj, j] unless size.zero?
|
194
|
+
|
195
|
+
end
|
196
|
+
return answer
|
197
|
+
end
|
198
|
+
|
199
|
+
# XXX: untested
|
200
|
+
def get_grouped_opcodes(n=3)
|
201
|
+
codes = get_opcodes
|
202
|
+
if codes[0][0] == :equal
|
203
|
+
tag, i1, i2, j1, j2 = codes[0]
|
204
|
+
codes[0] = tag, [i1, i2 - n].max, i2, [j1, j2-n].max, j2
|
205
|
+
end
|
206
|
+
|
207
|
+
if codes[-1][0] == :equal
|
208
|
+
tag, i1, i2, j1, j2 = codes[-1]
|
209
|
+
codes[-1] = tag, i1, min(i2, i1+n), j1, min(j2, j1+n)
|
210
|
+
end
|
211
|
+
nn = n + n
|
212
|
+
group = []
|
213
|
+
codes.each do |tag, i1, i2, j1, j2|
|
214
|
+
if tag == :equal and i2-i1 > nn
|
215
|
+
group.push [tag, i1, [i2, i1 + n].min, j1, [j2, j1 + n].min]
|
216
|
+
yield group
|
217
|
+
group = []
|
218
|
+
i1, j1 = [i1, i2-n].max, [j1, j2-n].max
|
219
|
+
group.push [tag, i1, i2, j1 ,j2]
|
220
|
+
end
|
221
|
+
end
|
222
|
+
if group and group.length != 1 and group[0][0] == :equal
|
223
|
+
yield group
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def ratio
|
228
|
+
matches = get_matching_blocks.reduce(0) do |sum, triple|
|
229
|
+
sum + triple[-1]
|
230
|
+
end
|
231
|
+
Diff.calculate_ratio(matches, @a.length + @b.length)
|
232
|
+
end
|
233
|
+
|
234
|
+
def quick_ratio
|
235
|
+
if @fullbcount.nil? or @fullbcount.empty?
|
236
|
+
@fullbcount = {}
|
237
|
+
@b.each do |elt|
|
238
|
+
@fullbcount[elt] = (@fullbcount[elt] || 0) + 1
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
avail = {}
|
243
|
+
matches = 0
|
244
|
+
@a.each do |elt|
|
245
|
+
if avail.has_key? elt
|
246
|
+
numb = avail[elt]
|
247
|
+
else
|
248
|
+
numb = @fullbcount[elt] || 0
|
249
|
+
end
|
250
|
+
avail[elt] = numb - 1
|
251
|
+
if numb > 0
|
252
|
+
matches += 1
|
253
|
+
end
|
254
|
+
end
|
255
|
+
Diff.calculate_ratio matches, @a.length + @b.length
|
256
|
+
end
|
257
|
+
|
258
|
+
def real_quick_ratio
|
259
|
+
la, lb = @a.length, @b.length
|
260
|
+
Diff.calculate_ratio([la, lb].min, la + lb)
|
261
|
+
end
|
262
|
+
|
263
|
+
protected :chain_b, :match_block_helper
|
264
|
+
end # end class SequenceMatcher
|
265
|
+
|
266
|
+
def self.calculate_ratio(matches, length)
|
267
|
+
return 1.0 if length.zero?
|
268
|
+
2.0 * matches / length
|
269
|
+
end
|
270
|
+
|
271
|
+
# XXX: untested
|
272
|
+
def self.get_close_matches(word, possibilities, n=3, cutoff=0.6)
|
273
|
+
unless n > 0
|
274
|
+
raise "n must be > 0: #{n}"
|
275
|
+
end
|
276
|
+
unless 0.0 <= cutoff and cutoff <= 1.0
|
277
|
+
raise "cutoff must be in (0.0..1.0): #{cutoff}"
|
278
|
+
end
|
279
|
+
|
280
|
+
result = []
|
281
|
+
s = SequenceMatcher.new
|
282
|
+
s.set_seq_b word
|
283
|
+
possibilities.each do |x|
|
284
|
+
s.set_seq_a x
|
285
|
+
if s.real_quick_ratio >= cutoff and
|
286
|
+
s.quick_ratio >= cutoff and
|
287
|
+
s.ratio >= cutoff
|
288
|
+
result.push [s.ratio, x]
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
unless result.nil? or result.empty?
|
293
|
+
result.sort
|
294
|
+
result.reverse!
|
295
|
+
result = result[-n..-1]
|
296
|
+
end
|
297
|
+
result.collect { |score, x| x }
|
298
|
+
end
|
299
|
+
|
300
|
+
def self.count_leading(line, ch)
|
301
|
+
i, n = 0, line.length
|
302
|
+
while i < n and line[i].chr == ch
|
303
|
+
i += 1
|
304
|
+
end
|
305
|
+
i
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
|
310
|
+
module HTMLDiff
|
311
|
+
include Diff
|
312
|
+
class Builder
|
313
|
+
VALID_METHODS = [:replace, :insert, :delete, :equal]
|
314
|
+
def initialize(a, b)
|
315
|
+
@a = a
|
316
|
+
@b = b
|
317
|
+
@content = []
|
318
|
+
end
|
319
|
+
|
320
|
+
def do_op(opcode)
|
321
|
+
@opcode = opcode
|
322
|
+
op = @opcode[0]
|
323
|
+
VALID_METHODS.include?(op) or raise(NameError, "Invalid opcode #{op}")
|
324
|
+
self.method(op).call
|
325
|
+
end
|
326
|
+
|
327
|
+
def result
|
328
|
+
@content.join('')
|
329
|
+
end
|
330
|
+
|
331
|
+
#this methods have to be called via do_op(opcode) so that @opcode is set properly
|
332
|
+
private
|
333
|
+
|
334
|
+
def replace
|
335
|
+
delete("diffmod")
|
336
|
+
insert("diffmod")
|
337
|
+
end
|
338
|
+
|
339
|
+
def insert(tagclass="diffins")
|
340
|
+
op_helper("ins", tagclass, @b[@opcode[3]...@opcode[4]])
|
341
|
+
end
|
342
|
+
|
343
|
+
def delete(tagclass="diffdel")
|
344
|
+
op_helper("del", tagclass, @a[@opcode[1]...@opcode[2]])
|
345
|
+
end
|
346
|
+
|
347
|
+
def equal
|
348
|
+
@content += @b[@opcode[3]...@opcode[4]]
|
349
|
+
end
|
350
|
+
|
351
|
+
# using this as op_helper would be equivalent to the first version of diff.rb by Bill Atkins
|
352
|
+
def op_helper_simple(tagname, tagclass, to_add)
|
353
|
+
@content << "<#{tagname} class=\"#{tagclass}\">"
|
354
|
+
@content += to_add
|
355
|
+
@content << "</#{tagname}>"
|
356
|
+
end
|
357
|
+
|
358
|
+
# this tries to put <p> tags or newline chars before the opening diff tags (<ins> or <del>)
|
359
|
+
# or after the ending diff tags
|
360
|
+
# as a result the diff tags should be the "more inside" possible.
|
361
|
+
# this seems to work nice with html containing only paragraphs
|
362
|
+
# but not sure it works if there are other tags (div, span ... ? ) around
|
363
|
+
def op_helper(tagname, tagclass, to_add)
|
364
|
+
@content << to_add.shift while ( HTMLDiff.is_newline(to_add.first) or
|
365
|
+
HTMLDiff.is_p_close_tag(to_add.first) or
|
366
|
+
HTMLDiff.is_p_open_tag(to_add.first) )
|
367
|
+
@content << "<#{tagname} class=\"#{tagclass}\">"
|
368
|
+
@content += to_add
|
369
|
+
last_tags = []
|
370
|
+
last_tags.unshift(@content.pop) while ( HTMLDiff.is_newline(@content.last) or
|
371
|
+
HTMLDiff.is_p_close_tag(@content.last) or
|
372
|
+
HTMLDiff.is_p_open_tag(@content.last) )
|
373
|
+
last_tags.unshift "</#{tagname}>"
|
374
|
+
@content += last_tags
|
375
|
+
remove_empty_diff(tagname, tagclass)
|
376
|
+
end
|
377
|
+
|
378
|
+
def remove_empty_diff(tagname, tagclass)
|
379
|
+
if @content[-2] == "<#{tagname} class=\"#{tagclass}\">" and @content[-1] == "</#{tagname}>" then
|
380
|
+
@content.pop
|
381
|
+
@content.pop
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
end
|
386
|
+
|
387
|
+
def self.is_newline(x)
|
388
|
+
(x == "\n") or (x == "\r") or (x == "\t")
|
389
|
+
end
|
390
|
+
|
391
|
+
def self.is_p_open_tag(x)
|
392
|
+
x =~ /\A<(p|li|ul|ol|dir|dt|dl)/
|
393
|
+
end
|
394
|
+
|
395
|
+
def self.is_p_close_tag(x)
|
396
|
+
x =~ %r!\A</(p|li|ul|ol|dir|dt|dl)!
|
397
|
+
end
|
398
|
+
|
399
|
+
def self.diff(a, b)
|
400
|
+
a, b = a.split(//), b.split(//) if a.kind_of? String and b.kind_of? String
|
401
|
+
a, b = html2list(a), html2list(b)
|
402
|
+
|
403
|
+
out = Builder.new(a, b)
|
404
|
+
s = SequenceMatcher.new(a, b)
|
405
|
+
|
406
|
+
s.get_opcodes.each do |opcode|
|
407
|
+
out.do_op(opcode)
|
408
|
+
end
|
409
|
+
|
410
|
+
out.result
|
411
|
+
end
|
412
|
+
|
413
|
+
def self.html2list(x, b=false)
|
414
|
+
mode = 'char'
|
415
|
+
cur = ''
|
416
|
+
out = []
|
417
|
+
|
418
|
+
x = x.split(//) if x.kind_of? String
|
419
|
+
|
420
|
+
x.each do |c|
|
421
|
+
if mode == 'tag'
|
422
|
+
if c == '>'
|
423
|
+
if b
|
424
|
+
cur += ']'
|
425
|
+
else
|
426
|
+
cur += c
|
427
|
+
end
|
428
|
+
out.push(cur)
|
429
|
+
cur = ''
|
430
|
+
mode = 'char'
|
431
|
+
else
|
432
|
+
cur += c
|
433
|
+
end
|
434
|
+
elsif mode == 'char'
|
435
|
+
if c == '<'
|
436
|
+
out.push cur
|
437
|
+
if b
|
438
|
+
cur = '['
|
439
|
+
else
|
440
|
+
cur = c
|
441
|
+
end
|
442
|
+
mode = 'tag'
|
443
|
+
elsif /\s/.match c
|
444
|
+
out.push cur + c
|
445
|
+
cur = ''
|
446
|
+
else
|
447
|
+
cur += c
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
out.push cur
|
453
|
+
# TODO: make something better here
|
454
|
+
out.each{|x| x.chomp! unless is_newline(x)}
|
455
|
+
out.find_all { |x| x != '' }
|
456
|
+
end
|
457
|
+
|
458
|
+
|
459
|
+
end
|
460
|
+
|
461
|
+
if __FILE__ == $0
|
462
|
+
|
463
|
+
require 'pp'
|
464
|
+
# a = "<p>this is the original string</p>" # \n<p>but around the world</p>"
|
465
|
+
# b = "<p>this is the original </p><p>other parag</p><p>string</p>"
|
466
|
+
a = "<ul>\n\t<li>one</li>\n\t<li>two</li>\n</ul>"
|
467
|
+
b = "<ul>\n\t<li>one</li>\n\t<li>two\n\t<ul><li>abc</li></ul></li>\n</ul>"
|
468
|
+
puts a
|
469
|
+
pp HTMLDiff.html2list(a)
|
470
|
+
puts
|
471
|
+
puts b
|
472
|
+
pp HTMLDiff.html2list(b)
|
473
|
+
puts
|
474
|
+
puts HTMLDiff.diff(a, b)
|
475
|
+
end
|