'
+ end
+ end
+
+ # Performs HTML escaping on text, but keeps linefeeds intact (by replacing them with )
+ def escape_preserving_linefeeds(text)
+ h(text).gsub(/\n/, ' ')
+ end
+
+ def format_date(date, include_time = true)
+ # Must use DateTime because Time doesn't support %e on at least some platforms
+ if include_time
+ DateTime.new(date.year, date.mon, date.day, date.hour, date.min, date.sec).strftime("%B %e, %Y %H:%M:%S")
+ else
+ DateTime.new(date.year, date.mon, date.day).strftime("%B %e, %Y")
+ end
+ end
+
+ def rendered_content(page)
+ PageRenderer.new(page.revisions.last).display_content
+ end
+
+end
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
new file mode 100644
index 00000000..a5b97f29
--- /dev/null
+++ b/app/helpers/wiki_helper.rb
@@ -0,0 +1,89 @@
+module WikiHelper
+
+ def navigation_menu_for_revision
+ menu = []
+ menu << forward
+ menu << back_for_revision if @revision_number > 1
+ menu << current_revision
+ menu << see_or_hide_changes_for_revision if @revision_number > 1
+ menu << rollback
+ menu
+ end
+
+ def navigation_menu_for_page
+ menu = []
+ menu << edit_page
+ menu << edit_web if @page.name == "HomePage"
+ if @page.revisions.length > 1
+ menu << back_for_page
+ menu << see_or_hide_changes_for_page
+ end
+ menu
+ end
+
+ def edit_page
+ link_text = (@page.name == "HomePage" ? 'Edit Page' : 'Edit')
+ link_to(link_text, {:web => @web.address, :action => 'edit', :id => @page.name},
+ {:class => 'navlink', :accesskey => 'E', :name => 'edit'})
+ end
+
+ def edit_web
+ link_to('Edit Web', {:web => @web.address, :action => 'edit_web'},
+ {:class => 'navlink', :accesskey => 'W', :name => 'edit_web'})
+ end
+
+ def forward
+ if @revision_number < @page.revisions.length - 1
+ link_to('Forward in time',
+ {:web => @web.address, :action => 'revision', :id => @page.name, :rev => @revision_number + 1},
+ {:class => 'navlink', :accesskey => 'F', :name => 'to_next_revision'}) +
+ " (#{@revision.page.revisions.length - @revision_number} more) "
+ else
+ link_to('Forward in time', {:web => @web.address, :action => 'show', :id => @page.name},
+ {:class => 'navlink', :accesskey => 'F', :name => 'to_next_revision'}) +
+ " (to current)"
+ end
+ end
+
+ def back_for_revision
+ link_to('Back in time',
+ {:web => @web.address, :action => 'revision', :id => @page.name, :rev => @revision_number - 1},
+ {:class => 'navlink', :name => 'to_previous_revision'}) +
+ " (#{@revision_number - 1} more)"
+ end
+
+ def back_for_page
+ link_to('Back in time',
+ {:web => @web.address, :action => 'revision', :id => @page.name,
+ :rev => @page.revisions.length - 1},
+ {:class => 'navlink', :accesskey => 'B', :name => 'to_previous_revision'}) +
+ " (#{@page.revisions.length - 1} #{@page.revisions.length - 1 == 1 ? 'revision' : 'revisions'})"
+ end
+
+ def current_revision
+ link_to('See current', {:web => @web.address, :action => 'show', :id => @page.name},
+ {:class => 'navlink', :name => 'to_current_revision'})
+ end
+
+ def see_or_hide_changes_for_revision
+ link_to(@show_diff ? 'Hide changes' : 'See changes',
+ {:web => @web.address, :action => 'revision', :id => @page.name, :rev => @revision_number,
+ :mode => (@show_diff ? nil : 'diff') },
+ {:class => 'navlink', :accesskey => 'C', :name => 'see_changes'})
+ end
+
+ def see_or_hide_changes_for_page
+ link_to(@show_diff ? 'Hide changes' : 'See changes',
+ {:web => @web.address, :action => 'show', :id => @page.name, :mode => (@show_diff ? nil : 'diff') },
+ {:class => 'navlink', :accesskey => 'C', :name => 'see_changes'})
+ end
+
+ def rollback
+ link_to('Rollback',
+ {:web => @web.address, :action => 'rollback', :id => @page.name, :rev => @revision_number},
+ {:class => 'navlink', :name => 'rollback'})
+ end
+
+
+
+end
\ No newline at end of file
diff --git a/app/models/author.rb b/app/models/author.rb
new file mode 100644
index 00000000..be8a5cf7
--- /dev/null
+++ b/app/models/author.rb
@@ -0,0 +1,18 @@
+class Author < String
+ attr_accessor :ip
+ attr_reader :name
+ def initialize(name, ip = nil)
+ @ip = ip
+ super(name)
+ end
+
+ def name=(value)
+ self.gsub!(/.+/, value)
+ end
+
+ alias_method :name, :to_s
+
+ def <=>(other)
+ name <=> other.to_s
+ end
+end
\ No newline at end of file
diff --git a/app/models/page.rb b/app/models/page.rb
new file mode 100644
index 00000000..c5f48d43
--- /dev/null
+++ b/app/models/page.rb
@@ -0,0 +1,121 @@
+class Page < ActiveRecord::Base
+ belongs_to :web
+ has_many :revisions, :order => 'id'
+ has_many :wiki_references, :order => 'referenced_name'
+ has_one :current_revision, :class_name => 'Revision', :order => 'id DESC'
+
+ def revise(content, time, author, renderer)
+ revisions_size = new_record? ? 0 : revisions.size
+ if (revisions_size > 0) and content == current_revision.content
+ raise Instiki::ValidationError.new(
+ "You have tried to save page '#{name}' without changing its content")
+ end
+
+ author = Author.new(author.to_s) unless author.is_a?(Author)
+
+ # Try to render content to make sure that markup engine can take it,
+ renderer.revision = Revision.new(
+ :page => self, :content => content, :author => author, :revised_at => time)
+ renderer.display_content(update_references = true)
+
+ # A user may change a page, look at it and make some more changes - several times.
+ # Not to record every such iteration as a new revision, if the previous revision was done
+ # by the same author, not more than 30 minutes ago, then update the last revision instead of
+ # creating a new one
+ if (revisions_size > 0) && continous_revision?(time, author)
+ current_revision.update_attributes(:content => content, :revised_at => time)
+ else
+ revisions.create(:content => content, :author => author, :revised_at => time)
+ end
+ save
+ self
+ end
+
+ def rollback(revision_number, time, author_ip, renderer)
+ roll_back_revision = self.revisions[revision_number]
+ if roll_back_revision.nil?
+ raise Instiki::ValidationError.new("Revision #{revision_number} not found")
+ end
+ author = Author.new(roll_back_revision.author.name, author_ip)
+ revise(roll_back_revision.content, time, author, renderer)
+ end
+
+ def revisions?
+ revisions.size > 1
+ end
+
+ def previous_revision(revision)
+ revision_index = revisions.each_with_index do |rev, index|
+ if rev.id == revision.id
+ break index
+ else
+ nil
+ end
+ end
+ if revision_index.nil? or revision_index == 0
+ nil
+ else
+ revisions[revision_index - 1]
+ end
+ end
+
+ def references
+ web.select.pages_that_reference(name)
+ end
+
+ def wiki_words
+ wiki_references.select { |ref| ref.wiki_word? }.map { |ref| ref.referenced_name }
+ end
+
+ def linked_from
+ web.select.pages_that_link_to(name)
+ end
+
+ def included_from
+ web.select.pages_that_include(name)
+ end
+
+ # Returns the original wiki-word name as separate words, so "MyPage" becomes "My Page".
+ def plain_name
+ web.brackets_only? ? name : WikiWords.separate(name)
+ end
+
+ LOCKING_PERIOD = 30.minutes
+
+ def lock(time, locked_by)
+ update_attributes(:locked_at => time, :locked_by => locked_by)
+ end
+
+ def lock_duration(time)
+ ((time - locked_at) / 60).to_i unless locked_at.nil?
+ end
+
+ def unlock
+ update_attribute(:locked_at, nil)
+ end
+
+ def locked?(comparison_time)
+ locked_at + LOCKING_PERIOD > comparison_time unless locked_at.nil?
+ end
+
+ def to_param
+ name
+ end
+
+ private
+
+ def continous_revision?(time, author)
+ (current_revision.author == author) && (revised_at + 30.minutes > time)
+ end
+
+ # Forward method calls to the current revision, so the page responds to all revision calls
+ def method_missing(method_id, *args, &block)
+ method_name = method_id.to_s
+ # Perform a hand-off to AR::Base#method_missing
+ if @attributes.include?(method_name) or md = /(=|\?|_before_type_cast)$/.match(method_name)
+ super(method_id, *args, &block)
+ else
+ current_revision.send(method_id)
+ end
+ end
+end
diff --git a/app/models/page_observer.rb b/app/models/page_observer.rb
new file mode 100644
index 00000000..aab72fa8
--- /dev/null
+++ b/app/models/page_observer.rb
@@ -0,0 +1,15 @@
+# This class maintains the state of wiki references for newly created or newly deleted pages
+class PageObserver < ActiveRecord::Observer
+
+ def after_create(page)
+ WikiReference.update_all("link_type = '#{WikiReference::LINKED_PAGE}'",
+ ['referenced_name = ?', page.name])
+ end
+
+ def before_destroy(page)
+ WikiReference.delete_all ['page_id = ?', page.id]
+ WikiReference.update_all("link_type = '#{WikiReference::WANTED_PAGE}'",
+ ['referenced_name = ?', page.name])
+ end
+
+end
\ No newline at end of file
diff --git a/app/models/page_set.rb b/app/models/page_set.rb
new file mode 100644
index 00000000..4ac08c00
--- /dev/null
+++ b/app/models/page_set.rb
@@ -0,0 +1,92 @@
+# Container for a set of pages with methods for manipulation.
+
+class PageSet < Array
+ attr_reader :web
+
+ def initialize(web, pages = nil, condition = nil)
+ @web = web
+ # if pages is not specified, make a list of all pages in the web
+ if pages.nil?
+ super(web.pages)
+ # otherwise use specified pages and condition to produce a set of pages
+ elsif condition.nil?
+ super(pages)
+ else
+ super(pages.select { |page| condition[page] })
+ end
+ end
+
+ def most_recent_revision
+ self.map { |page| page.revised_at }.max || Time.at(0)
+ end
+
+ def by_name
+ PageSet.new(@web, sort_by { |page| page.name })
+ end
+
+ alias :sort :by_name
+
+ def by_revision
+ PageSet.new(@web, sort_by { |page| page.revised_at }).reverse
+ end
+
+ def pages_that_reference(page_name)
+ all_referring_pages = WikiReference.pages_that_reference(page_name)
+ self.select { |page| all_referring_pages.include?(page.name) }
+ end
+
+ def pages_that_link_to(page_name)
+ all_linking_pages = WikiReference.pages_that_link_to(page_name)
+ self.select { |page| all_linking_pages.include?(page.name) }
+ end
+
+ def pages_that_include(page_name)
+ all_including_pages = WikiReference.pages_that_include(page_name)
+ self.select { |page| all_including_pages.include?(page.name) }
+ end
+
+ def pages_authored_by(author)
+ all_pages_authored_by_the_author =
+ Page.connection.select_all(sanitize_sql([
+ "SELECT page_id FROM revision WHERE author = '?'", author]))
+ self.select { |page| page.authors.include?(author) }
+ end
+
+ def characters
+ self.inject(0) { |chars,page| chars += page.content.size }
+ end
+
+ # Returns all the orphaned pages in this page set. That is,
+ # pages in this set for which there is no reference in the web.
+ # The HomePage and author pages are always assumed to have
+ # references and so cannot be orphans
+ # Pages that refer to themselves and have no links from outside are oprphans.
+ def orphaned_pages
+ never_orphans = web.authors + ['HomePage']
+ self.select { |page|
+ if never_orphans.include? page.name
+ false
+ else
+ references = pages_that_reference(page.name)
+ references.empty? or references == [page]
+ end
+ }
+ end
+
+ # Returns all the wiki words in this page set for which
+ # there are no pages in this page set's web
+ def wanted_pages
+ wiki_words - web.select.names
+ end
+
+ def names
+ self.map { |page| page.name }
+ end
+
+ def wiki_words
+ self.inject([]) { |wiki_words, page|
+ wiki_words + page.wiki_words
+ }.flatten.uniq.sort
+ end
+
+end
diff --git a/app/models/revision.rb b/app/models/revision.rb
new file mode 100644
index 00000000..3af2384d
--- /dev/null
+++ b/app/models/revision.rb
@@ -0,0 +1,4 @@
+class Revision < ActiveRecord::Base
+ belongs_to :page
+ composed_of :author, :mapping => [ %w(author name), %w(ip ip) ]
+end
diff --git a/app/models/system.rb b/app/models/system.rb
new file mode 100644
index 00000000..7ac1ad08
--- /dev/null
+++ b/app/models/system.rb
@@ -0,0 +1,4 @@
+class System < ActiveRecord::Base
+ set_table_name 'system'
+ validates_presence_of :password
+end
\ No newline at end of file
diff --git a/app/models/web.rb b/app/models/web.rb
new file mode 100644
index 00000000..d1d50268
--- /dev/null
+++ b/app/models/web.rb
@@ -0,0 +1,147 @@
+class Web < ActiveRecord::Base
+ has_many :pages
+ has_many :wiki_files
+
+ def wiki
+ Wiki.new
+ end
+
+ def settings_changed?(markup, safe_mode, brackets_only)
+ self.markup != markup ||
+ self.safe_mode != safe_mode ||
+ self.brackets_only != brackets_only
+ end
+
+ def add_page(name, content, time, author, renderer)
+ page = page(name) || Page.new(:web => self, :name => name)
+ page.revise(content, time, author, renderer)
+ end
+
+ def authors
+ connection.select_all(
+ 'SELECT DISTINCT r.author AS author ' +
+ 'FROM revisions r ' +
+ 'JOIN pages p ON p.id = r.page_id ' +
+ 'WHERE p.web_id = ' + self.id.to_s +
+ 'ORDER by 1 '
+ ).collect { |row| row['author'] }
+ end
+
+ def categories
+ select.map { |page| page.categories }.flatten.uniq.sort
+ end
+
+ def page(name)
+ pages.find(:first, :conditions => ['name = ?', name])
+ end
+
+ def last_page
+ return Page.find(:first, :order => 'id desc', :conditions => ['web_id = ?', self.id])
+ end
+
+ def has_page?(name)
+ Page.count(['web_id = ? AND name = ?', id, name]) > 0
+ end
+
+ def has_file?(file_name)
+ WikiFile.find_by_file_name(file_name) != nil
+ end
+
+ def markup
+ read_attribute('markup').to_sym
+ end
+
+ def page_names_by_author
+ connection.select_all(
+ 'SELECT DISTINCT r.author AS author, p.name AS page_name ' +
+ 'FROM revisions r ' +
+ 'JOIN pages p ON r.page_id = p.id ' +
+ "WHERE p.web_id = #{self.id} " +
+ 'ORDER by p.name'
+ ).inject({}) { |result, row|
+ author, page_name = row['author'], row['page_name']
+ result[author] = [] unless result.has_key?(author)
+ result[author] << page_name
+ result
+ }
+ end
+
+ def remove_pages(pages_to_be_removed)
+ pages_to_be_removed.each { |p| p.destroy }
+ end
+
+ def revised_at
+ select.most_recent_revision
+ end
+
+ def select(&condition)
+ PageSet.new(self, pages, condition)
+ end
+
+ def select_all
+ PageSet.new(self, pages, nil)
+ end
+
+ def to_param
+ address
+ end
+
+ def create_files_directory
+ return unless allow_uploads == 1
+ dummy_file = self.wiki_files.build(:file_name => '0', :description => '0', :content => '0')
+ dir = File.dirname(dummy_file.content_path)
+ begin
+ require 'fileutils'
+ FileUtils.mkdir_p dir
+ dummy_file.save
+ dummy_file.destroy
+ rescue => e
+ logger.error("Failed create files directory for #{self.address}: #{e}")
+ raise "Instiki could not create directory to store uploaded files. " +
+ "Please make sure that Instiki is allowed to create directory " +
+ "#{File.expand_path(dir)} and add files to it."
+ end
+ end
+
+ private
+
+ # Returns an array of all the wiki words in any current revision
+ def wiki_words
+ pages.inject([]) { |wiki_words, page| wiki_words << page.wiki_words }.flatten.uniq
+ end
+
+ # Returns an array of all the page names on this web
+ def page_names
+ pages.map { |p| p.name }
+ end
+
+ protected
+ before_save :sanitize_markup
+ after_save :create_files_directory
+ before_validation :validate_address
+ validates_uniqueness_of :address
+ validates_length_of :color, :in => 3..6
+
+ def sanitize_markup
+ self.markup = markup.to_s
+ end
+
+ def validate_address
+ unless address == CGI.escape(address)
+ self.errors.add(:address, 'should contain only valid URI characters')
+ raise Instiki::ValidationError.new("#{self.class.human_attribute_name('address')} #{errors.on(:address)}")
+ end
+ end
+
+ def default_web?
+ defined? DEFAULT_WEB and self.address == DEFAULT_WEB
+ end
+
+ def files_path
+ if default_web?
+ "#{RAILS_ROOT}/public/files"
+ else
+ "#{RAILS_ROOT}/public/#{self.address}/files"
+ end
+ end
+end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
new file mode 100644
index 00000000..46073065
--- /dev/null
+++ b/app/models/wiki.rb
@@ -0,0 +1,92 @@
+class Wiki
+
+ cattr_accessor :storage_path, :logger
+ self.storage_path = "#{RAILS_ROOT}/storage/"
+ self.logger = RAILS_DEFAULT_LOGGER
+
+ def authenticate(password)
+ password == (system.password || 'instiki')
+ end
+
+ def create_web(name, address, password = nil)
+ @webs = nil
+ Web.create(:name => name, :address => address, :password => password)
+ end
+
+ def delete_web(address)
+ web = Web.find_by_address(address)
+ unless web.nil?
+ web.destroy
+ @webs = nil
+ end
+ end
+
+ def edit_web(old_address, new_address, name, markup, color, additional_style, safe_mode = false,
+ password = nil, published = false, brackets_only = false, count_pages = false,
+ allow_uploads = true, max_upload_size = nil)
+
+ if not (web = Web.find_by_address(old_address))
+ raise Instiki::ValidationError.new("Web with address '#{old_address}' does not exist")
+ end
+
+ web.update_attributes(:address => new_address, :name => name, :markup => markup, :color => color,
+ :additional_style => additional_style, :safe_mode => safe_mode, :password => password, :published => published,
+ :brackets_only => brackets_only, :count_pages => count_pages, :allow_uploads => allow_uploads, :max_upload_size => max_upload_size)
+ @webs = nil
+ raise Instiki::ValidationError.new("There is already a web with address '#{new_address}'") unless web.errors.on(:address).nil?
+ web
+ end
+
+ def read_page(web_address, page_name)
+ self.class.logger.debug "Reading page '#{page_name}' from web '#{web_address}'"
+ web = Web.find_by_address(web_address)
+ if web.nil?
+ self.class.logger.debug "Web '#{web_address}' not found"
+ return nil
+ else
+ page = web.pages.find(:first, :conditions => ['name = ?', page_name])
+ self.class.logger.debug "Page '#{page_name}' #{page.nil? ? 'not' : ''} found"
+ return page
+ end
+ end
+
+ def remove_orphaned_pages(web_address)
+ web = Web.find_by_address(web_address)
+ web.remove_pages(web.select.orphaned_pages)
+ end
+
+ def revise_page(web_address, page_name, content, revised_at, author, renderer)
+ page = read_page(web_address, page_name)
+ page.revise(content, revised_at, author, renderer)
+ end
+
+ def rollback_page(web_address, page_name, revision_number, time, author_id = nil)
+ page = read_page(web_address, page_name)
+ page.rollback(revision_number, time, author_id)
+ end
+
+ def setup(password, web_name, web_address)
+ system.update_attribute(:password, password)
+ create_web(web_name, web_address)
+ end
+
+ def system
+ @system ||= (System.find(:first) || System.create)
+ end
+
+ def setup?
+ Web.count > 0
+ end
+
+ def webs
+ @webs ||= Web.find(:all).inject({}) { |webs, web| webs.merge(web.address => web) }
+ end
+
+ def storage_path
+ self.class.storage_path
+ end
+
+ def write_page(web_address, page_name, content, written_on, author, renderer)
+ Web.find_by_address(web_address).add_page(page_name, content, written_on, author, renderer)
+ end
+end
\ No newline at end of file
diff --git a/app/models/wiki_file.rb b/app/models/wiki_file.rb
new file mode 100644
index 00000000..ba122662
--- /dev/null
+++ b/app/models/wiki_file.rb
@@ -0,0 +1,64 @@
+class WikiFile < ActiveRecord::Base
+ belongs_to :web
+
+ before_save :write_content_to_file
+ before_destroy :delete_content_file
+
+ validates_presence_of %w( web file_name )
+ validates_length_of :file_name, :within=>1..50
+ validates_length_of :description, :maximum=>255
+
+ def self.find_by_file_name(file_name)
+ find(:first, :conditions => ['file_name = ?', file_name])
+ end
+
+ SANE_FILE_NAME = /^[a-zA-Z0-9\-_\. ]*$/
+ def validate
+ if file_name
+ if file_name !~ SANE_FILE_NAME
+ errors.add("file_name", "is invalid. Only latin characters, digits, dots, underscores, " +
+ "dashes and spaces are accepted")
+ elsif file_name == '.' or file_name == '..'
+ errors.add("file_name", "cannot be '.' or '..'")
+ end
+ end
+
+ if @web and @content
+ if (@content.size > @web.max_upload_size.kilobytes)
+ errors.add("content", "size (#{(@content.size / 1024.0).round} kilobytes) exceeds " +
+ "the maximum (#{web.max_upload_size} kilobytes) set for this wiki")
+ end
+ end
+
+ errors.add("content", "is empty") if @content.nil? or @content.empty?
+ end
+
+ def content=(content)
+ if content.respond_to? :read
+ @content = content.read
+ else
+ @content = content
+ end
+ end
+
+ def content
+ @content ||= ( File.open(content_path, 'rb') { |f| f.read } )
+ end
+
+ def content_path
+ web.files_path + '/' + file_name
+ end
+
+ def write_content_to_file
+ web.create_files_directory unless File.exists?(web.files_path)
+ File.open(self.content_path, 'wb') { |f| f.write(@content) }
+ end
+
+ def delete_content_file
+ require 'fileutils'
+ FileUtils.rm_f(content_path) if File.exists?(content_path)
+ end
+
+
+
+end
diff --git a/app/models/wiki_reference.rb b/app/models/wiki_reference.rb
new file mode 100644
index 00000000..c326e8ad
--- /dev/null
+++ b/app/models/wiki_reference.rb
@@ -0,0 +1,82 @@
+class WikiReference < ActiveRecord::Base
+
+ LINKED_PAGE = 'L'
+ WANTED_PAGE = 'W'
+ INCLUDED_PAGE = 'I'
+ CATEGORY = 'C'
+ AUTHOR = 'A'
+ FILE = 'F'
+ WANTED_FILE = 'E'
+
+ belongs_to :page
+ validates_inclusion_of :link_type, :in => [LINKED_PAGE, WANTED_PAGE, INCLUDED_PAGE, CATEGORY, AUTHOR, FILE, WANTED_FILE]
+
+ # FIXME all finders below MUST restrict their results to pages belonging to a particular web
+
+ def self.link_type(web, page_name)
+ web.has_page?(page_name) ? LINKED_PAGE : WANTED_PAGE
+ end
+
+ def self.pages_that_reference(page_name)
+ query = 'SELECT name FROM pages JOIN wiki_references ON pages.id = wiki_references.page_id ' +
+ 'WHERE wiki_references.referenced_name = ?' +
+ "AND wiki_references.link_type in ('#{LINKED_PAGE}', '#{WANTED_PAGE}', '#{INCLUDED_PAGE}')"
+ names = connection.select_all(sanitize_sql([query, page_name])).map { |row| row['name'] }
+ end
+
+ def self.pages_that_link_to(page_name)
+ query = 'SELECT name FROM pages JOIN wiki_references ON pages.id = wiki_references.page_id ' +
+ 'WHERE wiki_references.referenced_name = ? ' +
+ "AND wiki_references.link_type in ('#{LINKED_PAGE}', '#{WANTED_PAGE}')"
+ names = connection.select_all(sanitize_sql([query, page_name])).map { |row| row['name'] }
+ end
+
+ def self.pages_that_include(page_name)
+ query = 'SELECT name FROM pages JOIN wiki_references ON pages.id = wiki_references.page_id ' +
+ 'WHERE wiki_references.referenced_name = ? ' +
+ "AND wiki_references.link_type = '#{INCLUDED_PAGE}'"
+ names = connection.select_all(sanitize_sql([query, page_name])).map { |row| row['name'] }
+ end
+
+ def self.pages_in_category(category)
+ query =
+ 'SELECT name FROM pages JOIN wiki_references ON pages.id = wiki_references.page_id ' +
+ 'WHERE wiki_references.referenced_name = ? ' +
+ "AND wiki_references.link_type = '#{CATEGORY}'"
+ names = connection.select_all(sanitize_sql([query, category])).map { |row| row['name'] }
+ end
+
+ def self.list_categories
+ query = "SELECT DISTINCT referenced_name FROM wiki_references WHERE link_type = '#{CATEGORY}'"
+ connection.select_all(query).map { |row| row['referenced_name'] }
+ end
+
+ def wiki_word?
+ linked_page? or wanted_page?
+ end
+
+ def wiki_link?
+ linked_page? or wanted_page? or file? or wanted_file?
+ end
+
+ def linked_page?
+ link_type == LINKED_PAGE
+ end
+
+ def wanted_page?
+ link_type == WANTED_PAGE
+ end
+
+ def included_page?
+ link_type == INCLUDED_PAGE
+ end
+
+ def file?
+ link_type == FILE
+ end
+
+ def wanted_file?
+ link_type == WANTED_FILE
+ end
+
+end
diff --git a/app/views/admin/create_system.rhtml b/app/views/admin/create_system.rhtml
new file mode 100644
index 00000000..f5d5ff7f
--- /dev/null
+++ b/app/views/admin/create_system.rhtml
@@ -0,0 +1,86 @@
+<% @title = "Instiki Setup"; @content_width = 500 %>
+
+
+ Congratulations on succesfully installing and starting Instiki.
+ Since this is the first time Instiki has been run on this port,
+ you'll need to do a brief one-time setup.
+
+ The name of the web is included in the title on all pages.
+ The address is the base path that all pages within the web live beneath.
+ Ex: the address "rails" gives URLs like /rails/show/HomePage.
+ The address can only consist of letters and digits.
+
+
+ Name:
+
+ Address:
+
+
+
+
+
Password for creating and changing webs
+
+ Administrative access allows you to make new webs and change existing ones.
+
+
Everyone with this password will be able to do this, so pick it carefully!
+
+ Password:
+
+ Verify:
+
+
+
+
+
+
+
+<%= end_form_tag %>
+
+
diff --git a/app/views/admin/create_web.rhtml b/app/views/admin/create_web.rhtml
new file mode 100644
index 00000000..5b9e3f7e
--- /dev/null
+++ b/app/views/admin/create_web.rhtml
@@ -0,0 +1,72 @@
+<% @title = "New Wiki Web"; @content_width = 500 %>
+
+
+ Each web serves as an isolated name space for wiki pages,
+ so different subjects or projects can write about different MuppetShows.
+
+ The name of the web is included in the title on all pages.
+ The address is the base path that all pages within the web live beneath.
+ Ex: the address "rails" gives URLs like /rails/show/HomePage.
+ The address can only consist of letters and digits.
+
+ The name of the web is included in the title on all pages.
+ The address is the base path that all pages within the web live beneath.
+ Ex: the address "rails" gives URLs like /rails/show/HomePage.
+
+
+
+ Name:
+ Address:
+ (Letters and digits only)
+
+
+
Specialize
+
+ Markup:
+
+
+
+
+ Color:
+
+
+
+
+ />
+ Safe mode
+ - strip HTML tags and stylesheet options from the content of all pages
+
+ />
+ Brackets only
+ - require all wiki words to be as [[wiki word]], WikiWord links won't be created
+
+ />
+ Count pages
+
+
+ />
+ Allow uploads of no more than
+
+ kbytes
+ -
+ allow users to upload pictures and other files and include them on wiki pages
+
+
+
+
+
+
+ Stylesheet tweaks >>
+
+ - add or change styles used by this web; styles defined here take precedence over
+ instiki.css. Hint: View HTML source of a page you want to style to find ID names on individual
+ tags.
+
+
+
+
+
Password protection for this web (<%= @web.name %>)
+
+ This is the password that visitors need to view and edit this web.
+ Setting the password to nothing will remove the password protection.
+
+
+ Password:
+
+ Verify:
+
+
+
Publish read-only version of this web (<%= @web.name %>)
+
+ You can turn on a read-only version of this web that's accessible even when the regular web
+ is password protected.
+ The published version is accessible through URLs like /wiki/published/HomePage.
+
+
+ />
+ Publish this web
+
+
+
+
+ Enter system password
+
+ and
+
+
+ ...or forget changes and <%= link_to 'create a new web', :action => 'create_web' %>
+
+
+ Content of <%= h @file_name %> to upload (required):
+
+
+
+
+ Please note that the file you are uploadng will be named <%= h @file_name %> on the wiki -
+ regardless of how it is named on your computer. To change the wiki name of the file, please go
+ <%= link_to :back %> and edit the wiki page that refers to the file.
+
+
+<%= end_form_tag %>
\ No newline at end of file
diff --git a/app/views/file/import.rhtml b/app/views/file/import.rhtml
new file mode 100644
index 00000000..910ccef4
--- /dev/null
+++ b/app/views/file/import.rhtml
@@ -0,0 +1,23 @@
+
+ <% if @action_name != 'published' then %>
+ <%= list_item 'Home Page', {:action => 'show', :id => 'HomePage'}, 'Home, Sweet Home', 'H' %> |
+ <%= list_item 'All Pages', {:action => 'list'}, 'Alphabetically sorted list of pages', 'A' %> |
+ <%= list_item 'Recently Revised', {:action =>'recently_revised'}, 'Pages sorted by when they were last changed', 'U' %> |
+ <%= list_item 'Authors', {:action => 'authors'}, 'Who wrote what' %> |
+ <%= list_item 'Feeds', {:action => 'feeds'}, 'Subscribe to changes by RSS' %> |
+ <%= list_item 'Export', {:action => 'export'}, 'Download a zip with all the pages in this wiki', 'X' %> |
+ <%= form_tag({ :controller => 'wiki', :action => 'search', :web => @web.address},
+ {'id' => 'navigationSearchForm', 'method' => 'get', 'accept-charset' => 'utf-8' }) %>
+
+ <%= end_form_tag %>
+ <% else %>
+ <%= list_item 'Home Page', {:action => 'published', :id => 'HomePage'}, 'Home, Sweet Home', 'H' %> |
+ <% end%>
+
\ No newline at end of file
diff --git a/app/views/rdoc_help.rhtml b/app/views/rdoc_help.rhtml
new file mode 100644
index 00000000..1afaff5c
--- /dev/null
+++ b/app/views/rdoc_help.rhtml
@@ -0,0 +1,12 @@
+
diff --git a/app/views/wiki/locked.rhtml b/app/views/wiki/locked.rhtml
new file mode 100644
index 00000000..ee0cf814
--- /dev/null
+++ b/app/views/wiki/locked.rhtml
@@ -0,0 +1,23 @@
+<% @title = "#{@page.plain_name} is locked" %>
+
+
+ <%= link_to_page(@page.locked_by) %>
+ <% if @page.lock_duration(Time.now) == 0 %>
+ just started editing this page.
+ <% else %>
+ has been editing this page for <%= @page.lock_duration(Time.now) %> minutes.
+ <% end %>
+
+ This web is password-protected. Please enter the password.
+ <% if @web.published? %>
+ If you don't have the password, you can view this wiki as a <%= link_to 'read-only version', :action => 'published', :id => 'HomePage' %>.
+ <% end %>
+
+<% end %>
+
+<% if (@results + @title_results).empty? %>
+
No pages contain "<%= h @params["query"] %>"
+
+ 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.
+
+
+ If you're a high-tech computer wizard, you might even want try constructing a Ruby regular
+ expression. That's actually what Instiki uses, so go right ahead and flex your
+ "[a-z]*Leet?RegExpSkill(s|z)"
+
+<% end %>
diff --git a/app/views/wiki/tex.rhtml b/app/views/wiki/tex.rhtml
new file mode 100644
index 00000000..ea9a06c6
--- /dev/null
+++ b/app/views/wiki/tex.rhtml
@@ -0,0 +1,23 @@
+\documentclass[12pt,titlepage]{article}
+
+\usepackage[danish]{babel} %danske tekster
+\usepackage[OT1]{fontenc} %rigtige danske bogstaver...
+\usepackage{a4}
+\usepackage{graphicx}
+\usepackage{ucs}
+\usepackage[utf8x]{inputenc}
+\input epsf
+
+%-------------------------------------------------------------------
+
+\begin{document}
+
+\sloppy
+
+%-------------------------------------------------------------------
+
+\section*{<%= @page.name %>}
+
+<%= @tex_content %>
+
+\end{document}
\ No newline at end of file
diff --git a/app/views/wiki/tex_web.rhtml b/app/views/wiki/tex_web.rhtml
new file mode 100644
index 00000000..45953c52
--- /dev/null
+++ b/app/views/wiki/tex_web.rhtml
@@ -0,0 +1,35 @@
+\documentclass[12pt,titlepage]{article}
+
+\usepackage{fancyhdr}
+\pagestyle{fancy}
+
+\fancyhead[LE,RO]{}
+\fancyhead[LO,RE]{\nouppercase{\bfseries \leftmark}}
+\fancyfoot[C]{\thepage}
+
+\usepackage[danish]{babel} %danske tekster
+\usepackage{a4}
+\usepackage{graphicx}
+\usepackage{ucs}
+\usepackage[utf8]{inputenc}
+\input epsf
+
+
+%-------------------------------------------------------------------
+
+\title{<%= @web_name %>}
+
+\begin{document}
+
+\maketitle
+
+\tableofcontents
+\pagebreak
+
+\sloppy
+
+%-------------------------------------------------------------------
+
+<%= @tex_content %>
+
+\end{document}
\ No newline at end of file
diff --git a/app/views/wiki/web_list.rhtml b/app/views/wiki/web_list.rhtml
new file mode 100644
index 00000000..4f9608b0
--- /dev/null
+++ b/app/views/wiki/web_list.rhtml
@@ -0,0 +1,25 @@
+<% @title = "Wiki webs" %>
+
+
+<% @webs.each do |web| %>
+
+ <% if web.password %>
+ <%= web.pages.length %> page<% if web.pages.length != 1 %>s<% end %> by <%= web.authors.length %> author<% if web.authors.length != 1 %>s<% end %>
+ - Last Update: <%= web.last_page.nil? ? format_date(web.created_at) : format_date(web.last_page.revised_at) %>
+ <% if ! web.last_page.nil? %>
+ Last Document: <%= link_to_page(web.last_page.name,web) %>
+ <%= web.last_page.revisions? ? "Revised" : "Created" %> by <%= author_link(web.last_page) %> (<%= web.last_page.current_revision.ip %>)
+ <% end %>
+
+
+
+<% end %>
+
diff --git a/app/views/wiki_words_help.rhtml b/app/views/wiki_words_help.rhtml
new file mode 100644
index 00000000..b283b407
--- /dev/null
+++ b/app/views/wiki_words_help.rhtml
@@ -0,0 +1,9 @@
+
Wiki words
+
+ Two or more uppercase words stuck together (camel case) or any phrase surrounded by double
+ brackets is a wiki word. A camel-case wiki word can be escaped by putting \ in front of it.
+
+
+ Wiki words: HomePage, ThreeWordsTogether, [[C++]], [[Let's play again!]]
+ Not wiki words: IBM, School
+
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100644
index 00000000..b829644d
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,17 @@
+unless defined?(RAILS_ROOT)
+ root_path = File.join(File.dirname(__FILE__), '..')
+ unless RUBY_PLATFORM =~ /mswin32/
+ require 'pathname'
+ root_path = Pathname.new(root_path).cleanpath.to_s
+ end
+ RAILS_ROOT = root_path
+end
+
+if File.directory?("#{RAILS_ROOT}/vendor/rails")
+ require "#{RAILS_ROOT}/vendor/rails/railties/lib/initializer"
+else
+ require 'rubygems'
+ require 'initializer'
+end
+
+Rails::Initializer.run(:set_load_path)
\ No newline at end of file
diff --git a/config/database.yml b/config/database.yml
new file mode 100644
index 00000000..015390e1
--- /dev/null
+++ b/config/database.yml
@@ -0,0 +1,105 @@
+
+# "Out of the box", Instiki stores it's data in sqlite3 database. Other options are listed below.
+
+development:
+ adapter: sqlite3
+ database: db/development.db.sqlite3
+
+test:
+ adapter: sqlite3
+ database: db/test.db.sqlite3
+
+production:
+ adapter: sqlite3
+ database: db/production.db.sqlite3
+
+# MySQL (default setup). Versions 4.1 and 5.0 are recommended.
+#
+# Install the MySQL driver:
+# gem install mysql
+# On MacOS X:
+# gem install mysql -- --include=/usr/local/lib
+# On Windows:
+# There is no gem for Windows. Install mysql.so from RubyForApache.
+# http://rubyforge.org/projects/rubyforapache
+#
+# And be sure to use new-style password hashing:
+# http://dev.mysql.com/doc/refman/5.0/en/old-client.html
+
+
+# Get the fast C bindings:
+# gem install mysql
+# (on OS X: gem install mysql -- --include=/usr/local/lib)
+# And be sure to use new-style password hashing:
+# http://dev.mysql.com/doc/refman/5.0/en/old-client.html
+
+mysql_example:
+ adapter: mysql
+ database: instiki_development
+ username: root
+ password:
+ socket: /path/to/your/mysql.sock
+
+# Connect on a TCP socket. If omitted, the adapter will connect on the
+# domain socket given by socket instead.
+#host: localhost
+#port: 3306
+
+# Warning: The database defined as 'test' will be erased and
+# re-generated from your development database when you run 'rake'.
+# Do not set this db to the same as development or production.
+mysql_example:
+ adapter: mysql
+ database: instiki_test
+ username: root
+ password:
+ socket: /path/to/your/mysql.sock
+
+# PostgreSQL versions 7.4 - 8.2
+#
+# Get the C bindings:
+# gem install postgres
+# or use the pure-Ruby bindings (the only know way on Windows):
+# gem install postgres-pr
+
+postgresql_example:
+ adapter: postgresql
+ database: instiki_development
+ username: instiki
+ password:
+
+ # Connect on a TCP socket. Omitted by default since the client uses a
+ # domain socket that doesn't need configuration.
+ #host: remote-database
+ #port: 5432
+
+ # Schema search path. The server defaults to $user,public
+ #schema_search_path: myapp,sharedapp,public
+
+ # Character set encoding. The server defaults to sql_ascii.
+ #encoding: UTF8
+
+ # Minimum log levels, in increasing order:
+ # debug5, debug4, debug3, debug2, debug1,
+ # info, notice, warning, error, log, fatal, or panic
+ # The server defaults to notice.
+ #min_messages: warning
+
+
+# SQLite version 2.x
+# gem install sqlite-ruby
+sqlite_example:
+ adapter: sqlite
+ database: db/development.sqlite2
+
+
+# SQLite version 3.x
+# gem install sqlite3-ruby
+sqlite3_example:
+ adapter: sqlite3
+ database: db/development.sqlite3
+
+# In-memory SQLite 3 database. Useful for tests.
+sqlite3_in_memory_example:
+ adapter: sqlite3
+ database: ":memory:"
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 00000000..24e89a88
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,29 @@
+# Bootstrap the Rails environment, frameworks, and default configuration
+require File.join(File.dirname(__FILE__), 'boot')
+
+Rails::Initializer.run do |config|
+ # Skip frameworks you're not going to use
+ config.frameworks -= [ :action_web_service, :action_mailer ]
+
+ # Use the database for sessions instead of the file system
+ # (create the session table with 'rake create_sessions_table')
+ config.action_controller.session_store = :active_record_store
+
+ # Enable page/fragment caching by setting a file-based store
+ # (remember to create the caching directory and make it readable to the application)
+ #config.action_controller.fragment_cache_store = :file_store, "#{RAILS_ROOT}/cache"
+
+ # Activate observers that should always be running
+ config.active_record.observers = :page_observer
+
+ # Use Active Record's schema dumper instead of SQL when creating the test database
+ # (enables use of different database adapters for development and test environments)
+ config.active_record.schema_format = :ruby
+
+ config.load_paths << "#{RAILS_ROOT}/vendor/plugins/sqlite3-ruby"
+end
+
+# Instiki-specific configuration below
+require_dependency 'instiki_errors'
+
+require 'jcode'
diff --git a/config/environments/development.rb b/config/environments/development.rb
new file mode 100644
index 00000000..a151c3ba
--- /dev/null
+++ b/config/environments/development.rb
@@ -0,0 +1,17 @@
+# In the development environment your application's code is reloaded on
+# every request. This slows down response time but is perfect for development
+# since you don't have to restart the webserver when you make code changes.
+config.cache_classes = false
+
+# Log error messages when you accidentally call methods on nil.
+config.whiny_nils = true
+
+# Enable the breakpoint server that script/breakpointer connects to
+config.breakpoint_server = true
+
+# Show full error reports and disable caching
+config.action_controller.consider_all_requests_local = true
+config.action_controller.perform_caching = false
+
+# Don't care if the mailer can't send
+config.action_mailer.raise_delivery_errors = false
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100644
index 00000000..f2b9ed68
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,17 @@
+# The production environment is meant for finished, "live" apps.
+# Code is not reloaded between requests
+config.cache_classes = true
+
+# Use a different logger for distributed setups
+# config.logger = SyslogLogger.new
+
+
+# Full error reports are disabled and caching is turned on
+config.action_controller.consider_all_requests_local = false
+config.action_controller.perform_caching = true
+
+# Enable serving of images, stylesheets, and javascripts from an asset server
+# config.action_controller.asset_host = "http://assets.example.com"
+
+# Disable delivery errors if you bad email addresses should just be ignored
+# config.action_mailer.raise_delivery_errors = false
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100644
index 00000000..66b823dc
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,23 @@
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+config.cache_classes = true
+
+# Log error messages when you accidentally call methods on nil.
+config.whiny_nils = true
+
+# Show full error reports and disable caching
+config.action_controller.consider_all_requests_local = true
+config.action_controller.perform_caching = false
+
+# Tell ActionMailer not to deliver emails to the real world.
+# The :test delivery method accumulates sent emails in the
+# ActionMailer::Base.deliveries array.
+config.action_mailer.delivery_method = :test
+
+# Overwrite the default settings for fixtures in tests. See Fixtures
+# for more details about these settings.
+# config.transactional_fixtures = true
+# config.instantiated_fixtures = false
+# config.pre_loaded_fixtures = false
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 00000000..a5d49bab
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,38 @@
+# Create a route to DEFAULT_WEB, if such is specified; also register a generic route
+def connect_to_web(map, generic_path, generic_routing_options)
+ if defined? DEFAULT_WEB
+ explicit_path = generic_path.gsub(/:web\/?/, '')
+ explicit_routing_options = generic_routing_options.merge(:web => DEFAULT_WEB)
+ map.connect(explicit_path, explicit_routing_options)
+ end
+ map.connect(generic_path, generic_routing_options)
+end
+
+ActionController::Routing::Routes.draw do |map|
+ map.connect 'create_system', :controller => 'admin', :action => 'create_system'
+ map.connect 'create_web', :controller => 'admin', :action => 'create_web'
+ map.connect 'remove_orphaned_pages', :controller => 'admin', :action => 'remove_orphaned_pages'
+ map.connect 'delete_web', :controller => 'admin', :action => 'delete_web'
+ map.connect 'web_list', :controller => 'wiki', :action => 'web_list'
+
+ connect_to_web map, ':web/edit_web', :controller => 'admin', :action => 'edit_web'
+ connect_to_web map, ':web/files/:id', :controller => 'file', :action => 'file'
+ connect_to_web map, ':web/import/:id', :controller => 'file', :action => 'import'
+ connect_to_web map, ':web/login', :controller => 'wiki', :action => 'login'
+ connect_to_web map, ':web/web_list', :controller => 'wiki', :action => 'web_list'
+ connect_to_web map, ':web/show/diff/:id', :controller => 'wiki', :action => 'show', :mode => 'diff'
+ connect_to_web map, ':web/revision/diff/:id', :controller => 'wiki', :action => 'revision', :mode => 'diff'
+ connect_to_web map, ':web/list', :controller => 'wiki', :action => 'list'
+ connect_to_web map, ':web/list/:category', :controller => 'wiki', :action => 'list'
+ connect_to_web map, ':web/recently_revised', :controller => 'wiki', :action => 'recently_revised'
+ connect_to_web map, ':web/recently_revised/:category', :controller => 'wiki', :action => 'recently_revised'
+ connect_to_web map, ':web/:action/:id', :controller => 'wiki'
+ connect_to_web map, ':web/:action', :controller => 'wiki'
+ connect_to_web map, ':web', :controller => 'wiki', :action => 'index'
+
+ if defined? DEFAULT_WEB
+ map.connect '', :controller => 'wiki', :web => DEFAULT_WEB, :action => 'index'
+ else
+ map.connect '', :controller => 'wiki', :action => 'index'
+ end
+end
diff --git a/config/spam_patterns.txt b/config/spam_patterns.txt
new file mode 100644
index 00000000..5c12addc
--- /dev/null
+++ b/config/spam_patterns.txt
@@ -0,0 +1,97 @@
+.*\[\/link\]
+.*\[\/url\]
+51wisdom
+acupuncturealliance
+acyclovir
+Adipex
+adultfriend
+airline
+allegra
+ampicill
+anafranil
+atenolol
+attacke\.ch
+autocorp
+awardspace
+blogspot\.com
+bravehost\.com
+butalbital
+buy cheap
+buy computer
+buy-online
+calling-phone-cards
+casino
+celexa
+cialis
+computer-exchange\.com
+Cool website!
+debt\s*consolidation
+diazepam
+display:\s*none
+domaindlx\.com
+equity\s*loan
+Erectol
+Feel free to visit my page
+fortunecity
+fuck
+funpic\.de
+gambling
+Good job man
+gucci
+guestbook
+hamburger
+hold-em
+holdem
+home\s*loan
+hoodia
+http://[A-Za-z0-9_\.]+\.cn
+hydrocodone
+I am really excited
+I really like your site
+igotfree
+ketoconazole
+lust cartoon
+mijneigenweblog
+Mortage
+my homepage
+myspace
+naked
+netfirms\.com
+nice site
+overflow:\s*auto
+paxil
+pbwiki\.com
+penis
+Phentermine
+phpbbforfree
+pochta\.ru
+poker
+porn
+prohosting
+protonix
+rapidforum
+replica
+ringtone
+rolex
+serotonin
+singtaotor
+slot\s*machin
+soma
+super site
+texas
+thepussies
+tits
+Tramadol
+versace
+viagra
+vuitton
+websamba\.com
+xanax
+xoomer
+xrumer
+Your site is great!
+zoloft
+\.iwarp\.
+\.tripod\.com
+\[link\=
+\[url\=
diff --git a/db/migrate/001_beta1_schema.rb b/db/migrate/001_beta1_schema.rb
new file mode 100644
index 00000000..8985aa95
--- /dev/null
+++ b/db/migrate/001_beta1_schema.rb
@@ -0,0 +1,56 @@
+class Beta1Schema < ActiveRecord::Migration
+ def self.up
+ create_table "pages", :force => true do |t|
+ t.column "created_at", :datetime, :null => false
+ t.column "updated_at", :datetime, :null => false
+ t.column "web_id", :integer, :default => 0, :null => false
+ t.column "locked_by", :string, :limit => 60
+ t.column "name", :string, :limit => 60
+ t.column "locked_at", :datetime
+ end
+
+ create_table "revisions", :force => true do |t|
+ t.column "created_at", :datetime, :null => false
+ t.column "updated_at", :datetime, :null => false
+ t.column "revised_at", :datetime, :null => false
+ t.column "page_id", :integer, :default => 0, :null => false
+ t.column "content", :text, :default => "", :null => false
+ t.column "author", :string, :limit => 60
+ t.column "ip", :string, :limit => 60
+ end
+
+ create_table "system", :force => true do |t|
+ t.column "password", :string, :limit => 60
+ end
+
+ create_table "webs", :force => true do |t|
+ t.column "created_at", :datetime, :null => false
+ t.column "updated_at", :datetime, :null => false
+ t.column "name", :string, :limit => 60, :default => "", :null => false
+ t.column "address", :string, :limit => 60, :default => "", :null => false
+ t.column "password", :string, :limit => 60
+ t.column "additional_style", :string
+ t.column "allow_uploads", :integer, :default => 1
+ t.column "published", :integer, :default => 0
+ t.column "count_pages", :integer, :default => 0
+ t.column "markup", :string, :limit => 50, :default => "textile"
+ t.column "color", :string, :limit => 6, :default => "008B26"
+ t.column "max_upload_size", :integer, :default => 100
+ t.column "safe_mode", :integer, :default => 0
+ t.column "brackets_only", :integer, :default => 0
+ end
+
+ create_table "wiki_references", :force => true do |t|
+ t.column "created_at", :datetime, :null => false
+ t.column "updated_at", :datetime, :null => false
+ t.column "page_id", :integer, :default => 0, :null => false
+ t.column "referenced_name", :string, :limit => 60, :default => "", :null => false
+ t.column "link_type", :string, :limit => 1, :default => "", :null => false
+ end
+ end
+
+ def self.down
+ raise 'Initial schema - cannot be further reverted'
+ end
+
+end
diff --git a/db/migrate/002_beta2_changes_bulk.rb b/db/migrate/002_beta2_changes_bulk.rb
new file mode 100644
index 00000000..b0b94486
--- /dev/null
+++ b/db/migrate/002_beta2_changes_bulk.rb
@@ -0,0 +1,36 @@
+class Beta2ChangesBulk < ActiveRecord::Migration
+ def self.up
+ add_index "revisions", "page_id"
+ add_index "revisions", "created_at"
+ add_index "revisions", "author"
+
+ create_table "sessions", :force => true do |t|
+ t.column "session_id", :string
+ t.column "data", :text
+ t.column "updated_at", :datetime
+ end
+ add_index "sessions", "session_id"
+
+ create_table "wiki_files", :force => true do |t|
+ t.column "created_at", :datetime, :null => false
+ t.column "updated_at", :datetime, :null => false
+ t.column "web_id", :integer, :null => false
+ t.column "file_name", :string, :null => false
+ t.column "description", :string, :null => false
+ end
+
+ add_index "wiki_references", "page_id"
+ add_index "wiki_references", "referenced_name"
+ end
+
+ def self.down
+ remove_index "wiki_references", "referenced_name"
+ remove_index "wiki_references", "page_id"
+ drop_table "wiki_files"
+ remove_index "sessions", "session_id"
+ drop_table "sessions"
+ remove_index "revisions", "author"
+ remove_index "revisions", "created_at"
+ remove_index "revisions", "page_id"
+ end
+end
diff --git a/instiki b/instiki
new file mode 100755
index 00000000..db95d002
--- /dev/null
+++ b/instiki
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+cd $(dirname $0)
+
+export LD_LIBRARY_PATH=./lib/native/linux-x86:$LD_LIBRARY_PATH
+ruby script/server
+
diff --git a/instiki.cmd b/instiki.cmd
new file mode 100644
index 00000000..00461495
--- /dev/null
+++ b/instiki.cmd
@@ -0,0 +1,2 @@
+set PATH=.\lib\native\win32;%PATH%
+ruby.exe script\server -e production
\ No newline at end of file
diff --git a/instiki.rb b/instiki.rb
new file mode 100755
index 00000000..93f6ea51
--- /dev/null
+++ b/instiki.rb
@@ -0,0 +1,2 @@
+#!/usr/bin/env ruby
+load File.dirname(__FILE__) + '/script/server'
diff --git a/lib/bluecloth_tweaked.rb b/lib/bluecloth_tweaked.rb
new file mode 100644
index 00000000..b91622f1
--- /dev/null
+++ b/lib/bluecloth_tweaked.rb
@@ -0,0 +1,1127 @@
+#!/usr/bin/env ruby
+#
+# Bluecloth is a Ruby implementation of Markdown, a text-to-HTML conversion
+# tool.
+#
+# == Synopsis
+#
+# doc = BlueCloth::new "
+# ## Test document ##
+#
+# Just a simple test.
+# "
+#
+# puts doc.to_html
+#
+# == Authors
+#
+# * Michael Granger
+#
+# == Contributors
+#
+# * Martin Chase - Peer review, helpful suggestions
+# * Florian Gross - Filter options, suggestions
+#
+# == Copyright
+#
+# Original version:
+# Copyright (c) 2003-2004 John Gruber
+#
+# All rights reserved.
+#
+# Ruby port:
+# Copyright (c) 2004 The FaerieMUD Consortium.
+#
+# BlueCloth 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.
+#
+# BlueCloth 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.
+#
+# == To-do
+#
+# * Refactor some of the larger uglier methods that have to do their own
+# brute-force scanning because of lack of Perl features in Ruby's Regexp
+# class. Alternately, could add a dependency on 'pcre' and use most Perl
+# regexps.
+#
+# * Put the StringScanner in the render state for thread-safety.
+#
+# == Version
+#
+# $Id: bluecloth.rb,v 1.3 2004/05/02 15:56:33 webster132 Exp $
+#
+
+require 'digest/md5'
+require 'logger'
+require 'strscan'
+
+
+### BlueCloth is a Ruby implementation of Markdown, a text-to-HTML conversion
+### tool.
+class BlueCloth < String
+
+ ### Exception class for formatting errors.
+ class FormatError < RuntimeError
+
+ ### Create a new FormatError with the given source +str+ and an optional
+ ### message about the +specific+ error.
+ def initialize( str, specific=nil )
+ if specific
+ msg = "Bad markdown format near %p: %s" % [ str, specific ]
+ else
+ msg = "Bad markdown format near %p" % str
+ end
+
+ super( msg )
+ end
+ end
+
+
+ # Release Version
+ Version = '0.0.3'
+
+ # SVN Revision
+ SvnRev = %q$Rev: 37 $
+
+ # SVN Id tag
+ SvnId = %q$Id: bluecloth.rb,v 1.3 2004/05/02 15:56:33 webster132 Exp $
+
+ # SVN URL
+ SvnUrl = %q$URL: svn+ssh://cvs.faeriemud.org/var/svn/BlueCloth/trunk/lib/bluecloth.rb $
+
+
+ # Rendering state struct. Keeps track of URLs, titles, and HTML blocks
+ # midway through a render. I prefer this to the globals of the Perl version
+ # because globals make me break out in hives. Or something.
+ RenderState = Struct::new( "RenderState", :urls, :titles, :html_blocks, :log )
+
+ # Tab width for #detab! if none is specified
+ TabWidth = 4
+
+ # The tag-closing string -- set to '>' for HTML
+ EmptyElementSuffix = "/>";
+
+ # Table of MD5 sums for escaped characters
+ EscapeTable = {}
+ '\\`*_{}[]()#.!'.split(//).each {|char|
+ hash = Digest::MD5::hexdigest( char )
+
+ EscapeTable[ char ] = {
+ :md5 => hash,
+ :md5re => Regexp::new( hash ),
+ :re => Regexp::new( '\\\\' + Regexp::escape(char) ),
+ }
+ }
+
+
+ #################################################################
+ ### I N S T A N C E M E T H O D S
+ #################################################################
+
+ ### Create a new BlueCloth string.
+ def initialize( content="", *restrictions )
+ @log = Logger::new( $deferr )
+ @log.level = $DEBUG ?
+ Logger::DEBUG :
+ ($VERBOSE ? Logger::INFO : Logger::WARN)
+ @scanner = nil
+
+ # Add any restrictions, and set the line-folding attribute to reflect
+ # what happens by default.
+ restrictions.flatten.each {|r| __send__("#{r}=", true) }
+ @fold_lines = true
+
+ super( content )
+
+ @log.debug "String is: %p" % self
+ end
+
+
+ ######
+ public
+ ######
+
+ # Filters for controlling what gets output for untrusted input. (But really,
+ # you're filtering bad stuff out of untrusted input at submission-time via
+ # untainting, aren't you?)
+ attr_accessor :filter_html, :filter_styles
+
+ # RedCloth-compatibility accessor. Line-folding is part of Markdown syntax,
+ # so this isn't used by anything.
+ attr_accessor :fold_lines
+
+
+ ### Render Markdown-formatted text in this string object as HTML and return
+ ### it. The parameter is for compatibility with RedCloth, and is currently
+ ### unused, though that may change in the future.
+ def to_html( lite=false )
+
+ # Create a StringScanner we can reuse for various lexing tasks
+ @scanner = StringScanner::new( '' )
+
+ # Make a structure to carry around stuff that gets placeholdered out of
+ # the source.
+ rs = RenderState::new( {}, {}, {} )
+
+ # Make a copy of the string with normalized line endings, tabs turned to
+ # spaces, and a couple of guaranteed newlines at the end
+ text = self.gsub( /\r\n?/, "\n" ).detab
+ text += "\n\n"
+ @log.debug "Normalized line-endings: %p" % text
+
+ # Filter HTML if we're asked to do so
+ if self.filter_html
+ text.gsub!( "<", "<" )
+ text.gsub!( ">", ">" )
+ @log.debug "Filtered HTML: %p" % text
+ end
+
+ # Simplify blank lines
+ text.gsub!( /^ +$/, '' )
+ @log.debug "Tabs -> spaces/blank lines stripped: %p" % text
+
+ # Replace HTML blocks with placeholders
+ text = hide_html_blocks( text, rs )
+ @log.debug "Hid HTML blocks: %p" % text
+ @log.debug "Render state: %p" % rs
+
+ # Strip link definitions, store in render state
+ text = strip_link_definitions( text, rs )
+ @log.debug "Stripped link definitions: %p" % text
+ @log.debug "Render state: %p" % rs
+
+ # Escape meta-characters
+ text = escape_special_chars( text )
+ @log.debug "Escaped special characters: %p" % text
+
+ # Transform block-level constructs
+ text = apply_block_transforms( text, rs )
+ @log.debug "After block-level transforms: %p" % text
+
+ # Now swap back in all the escaped characters
+ text = unescape_special_chars( text )
+ @log.debug "After unescaping special characters: %p" % text
+
+ return text
+ end
+
+
+ ### Convert tabs in +str+ to spaces.
+ def detab( tabwidth=TabWidth )
+ copy = self.dup
+ copy.detab!( tabwidth )
+ return copy
+ end
+
+
+ ### Convert tabs to spaces in place and return self if any were converted.
+ def detab!( tabwidth=TabWidth )
+ newstr = self.split( /\n/ ).collect {|line|
+ line.gsub( /(.*?)\t/ ) do
+ $1 + ' ' * (tabwidth - $1.length % tabwidth)
+ end
+ }.join("\n")
+ self.replace( newstr )
+ end
+
+
+ #######
+ #private
+ #######
+
+ ### Do block-level transforms on a copy of +str+ using the specified render
+ ### state +rs+ and return the results.
+ def apply_block_transforms( str, rs )
+ # Port: This was called '_runBlockGamut' in the original
+
+ @log.debug "Applying block transforms to:\n %p" % str
+ text = transform_headers( str, rs )
+ text = transform_hrules( text, rs )
+ text = transform_lists( text, rs )
+ text = transform_code_blocks( text, rs )
+ text = transform_block_quotes( text, rs )
+ text = transform_auto_links( text, rs )
+ text = hide_html_blocks( text, rs )
+
+ text = form_paragraphs( text, rs )
+
+ @log.debug "Done with block transforms:\n %p" % text
+ return text
+ end
+
+
+ ### Apply Markdown span transforms to a copy of the specified +str+ with the
+ ### given render state +rs+ and return it.
+ def apply_span_transforms( str, rs )
+ @log.debug "Applying span transforms to:\n %p" % str
+
+ str = transform_code_spans( str, rs )
+ str = encode_html( str )
+ str = transform_images( str, rs )
+ str = transform_anchors( str, rs )
+ str = transform_italic_and_bold( str, rs )
+
+ # Hard breaks
+ str.gsub!( / {2,}\n/, "
+ #
+ # tags for inner block must be indented.
+ #
+ #
+ StrictBlockRegex = %r{
+ ^ # Start of line
+ <(#{BlockTagPattern}) # Start tag: \2
+ \b # word break
+ (.*\n)*? # Any number of lines, minimal match
+ \1> # Matching end tag
+ [ ]* # trailing spaces
+ (?=\n+|\Z) # End of line or document
+ }ix
+
+ # More-liberal block-matching
+ LooseBlockRegex = %r{
+ ^ # Start of line
+ <(#{BlockTagPattern}) # start tag: \2
+ \b # word break
+ (.*\n)*? # Any number of lines, minimal match
+ .*\1> # Anything + Matching end tag
+ [ ]* # trailing spaces
+ (?=\n+|\Z) # End of line or document
+ }ix
+
+ # Special case for .
+ HruleBlockRegex = %r{
+ ( # $1
+ \A\n? # Start of doc + optional \n
+ | # or
+ .*\n\n # anything + blank line
+ )
+ ( # save in $2
+ [ ]* # Any spaces
+ ])*? # Attributes
+ /?> # Tag close
+ (?=\n\n|\Z) # followed by a blank line or end of document
+ )
+ }ix
+
+ ### Replace all blocks of HTML in +str+ that start in the left margin with
+ ### tokens.
+ def hide_html_blocks( str, rs )
+ @log.debug "Hiding HTML blocks in %p" % str
+
+ # Tokenizer proc to pass to gsub
+ tokenize = lambda {|match|
+ key = Digest::MD5::hexdigest( match )
+ rs.html_blocks[ key ] = match
+ @log.debug "Replacing %p with %p" %
+ [ match, key ]
+ "\n\n#{key}\n\n"
+ }
+
+ rval = str.dup
+
+ @log.debug "Finding blocks with the strict regex..."
+ rval.gsub!( StrictBlockRegex, &tokenize )
+
+ @log.debug "Finding blocks with the loose regex..."
+ rval.gsub!( LooseBlockRegex, &tokenize )
+
+ @log.debug "Finding hrules..."
+ rval.gsub!( HruleBlockRegex ) {|match| $1 + tokenize[$2] }
+
+ return rval
+ end
+
+
+ # Link defs are in the form: ^[id]: url "optional title"
+ LinkRegex = %r{
+ ^[ ]*\[(.+)\]: # id = $1
+ [ ]*
+ \n? # maybe *one* newline
+ [ ]*
+ (\S+) # url = $2
+ [ ]*
+ \n? # maybe one newline
+ [ ]*
+ (?:
+ # Titles are delimited by "quotes" or (parens).
+ ["(]
+ (.+?) # title = $3
+ [")] # Matching ) or "
+ [ ]*
+ )? # title is optional
+ (?:\n+|\Z)
+ }x
+
+ ### Strip link definitions from +str+, storing them in the given RenderState
+ ### +rs+.
+ def strip_link_definitions( str, rs )
+ str.gsub( LinkRegex ) {|match|
+ id, url, title = $1, $2, $3
+
+ rs.urls[ id.downcase ] = encode_html( url )
+ unless title.nil?
+ rs.titles[ id.downcase ] = title.gsub( /"/, """ )
+ end
+ ""
+ }
+ end
+
+
+ ### Escape special characters in the given +str+
+ def escape_special_chars( str )
+ @log.debug " Escaping special characters"
+ text = ''
+
+ tokenize_html( str ) {|token, str|
+ @log.debug " Adding %p token %p" % [ token, str ]
+ case token
+
+ # Within tags, encode * and _
+ when :tag
+ text += str.
+ gsub( /\*/, EscapeTable['*'][:md5] ).
+ gsub( /_/, EscapeTable['_'][:md5] )
+
+ # Encode backslashed stuff in regular text
+ when :text
+ text += encode_backslash_escapes( str )
+ else
+ raise TypeError, "Unknown token type %p" % token
+ end
+ }
+
+ @log.debug " Text with escapes is now: %p" % text
+ return text
+ end
+
+
+ ### Swap escaped special characters in a copy of the given +str+ and return
+ ### it.
+ def unescape_special_chars( str )
+ EscapeTable.each {|char, hash|
+ @log.debug "Unescaping escaped %p with %p" %
+ [ char, hash[:md5re] ]
+ str.gsub!( hash[:md5re], char )
+ }
+
+ return str
+ end
+
+
+ ### Return a copy of the given +str+ with any backslashed special character
+ ### in it replaced with MD5 placeholders.
+ def encode_backslash_escapes( str )
+ # Make a copy with any double-escaped backslashes encoded
+ text = str.gsub( /\\\\/, EscapeTable['\\'][:md5] )
+
+ EscapeTable.each_pair {|char, esc|
+ next if char == '\\'
+ text.gsub!( esc[:re], esc[:md5] )
+ }
+
+ return text
+ end
+
+
+ ### Transform any Markdown-style horizontal rules in a copy of the specified
+ ### +str+ and return it.
+ def transform_hrules( str, rs )
+ @log.debug " Transforming horizontal rules"
+ str.gsub( /^( ?[\-\*] ?){3,}$/, "\n\n%s%s>\n} % [
+ list_type,
+ transform_list_items( list, rs ),
+ list_type,
+ ]
+ }
+ end
+
+
+ # Pattern for transforming list items
+ ListItemRegexp = %r{
+ (\n)? # leading line = $1
+ (^[ ]*) # leading whitespace = $2
+ (\*|\d+\.) [ ]+ # list marker = $3
+ ((?m:.+?) # list item text = $4
+ (\n{1,2}))
+ (?= \n* (\z | \2 (\*|\d+\.) [ ]+))
+ }x
+
+ ### Transform list items in a copy of the given +str+ and return it.
+ def transform_list_items( str, rs )
+ @log.debug " Transforming list items"
+
+ # Trim trailing blank lines
+ str = str.sub( /\n{2,}\z/, "\n" )
+
+ str.gsub( ListItemRegexp ) {|line|
+ @log.debug " Found item line %p" % line
+ leading_line, item = $1, $4
+
+ if leading_line or /\n{2,}/.match( item )
+ @log.debug " Found leading line or item has a blank"
+ item = apply_block_transforms( outdent(item), rs )
+ else
+ # Recursion for sub-lists
+ @log.debug " Recursing for sublist"
+ item = transform_lists( outdent(item), rs ).chomp
+ item = apply_span_transforms( item, rs )
+ end
+
+ %{
%s
\n} % item
+ }
+ end
+
+
+ # Pattern for matching codeblocks
+ CodeBlockRegexp = %r{
+ (.?) # $1 = preceding character
+ :\n+ # colon + NL delimiter
+ ( # $2 = the code block
+ (?:
+ (?:[ ]{#{TabWidth}} | \t) # a tab or tab-width of spaces
+ .*\n+
+ )+
+ )
+ ((?=^[ ]{0,#{TabWidth}}\S)|\Z) # Lookahead for non-space at
+ # line-start, or end of doc
+ }x
+
+ ### Transform Markdown-style codeblocks in a copy of the specified +str+ and
+ ### return it.
+ def transform_code_blocks( str, rs )
+ @log.debug " Transforming code blocks"
+
+ str.gsub( CodeBlockRegexp ) {|block|
+ prevchar, codeblock = $1, $2
+
+ @log.debug " prevchar = %p" % prevchar
+
+ # Generated the codeblock
+ %{%s\n\n
%s\n
\n\n} % [
+ (prevchar.empty? || /\s/ =~ prevchar) ? "" : "#{prevchar}:",
+ encode_code( outdent(codeblock), rs ).rstrip,
+ ]
+ }
+ end
+
+
+ # Pattern for matching Markdown blockquote blocks
+ BlockQuoteRegexp = %r{
+ (?:
+ ^[ ]*>[ ]? # '>' at the start of a line
+ .+\n # rest of the first line
+ (?:.+\n)* # subsequent consecutive lines
+ \n* # blanks
+ )+
+ }x
+
+ ### Transform Markdown-style blockquotes in a copy of the specified +str+
+ ### and return it.
+ def transform_block_quotes( str, rs )
+ @log.debug " Transforming block quotes"
+
+ str.gsub( BlockQuoteRegexp ) {|quote|
+ @log.debug "Making blockquote from %p" % quote
+ quote.gsub!( /^[ ]*>[ ]?/, '' )
+ %{
\n%s\n
\n\n} %
+ apply_block_transforms( quote, rs ).
+ gsub( /^/, " " * TabWidth )
+ }
+ end
+
+
+ AutoAnchorURLRegexp = /<((https?|ftp):[^'">\s]+)>/
+ AutoAnchorEmailRegexp = %r{
+ <
+ (
+ [-.\w]+
+ \@
+ [-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+
+ )
+ >
+ }x
+
+ ### Transform URLs in a copy of the specified +str+ into links and return
+ ### it.
+ def transform_auto_links( str, rs )
+ @log.debug " Transforming auto-links"
+ str.gsub( AutoAnchorURLRegexp, %{\\1}).
+ gsub( AutoAnchorEmailRegexp ) {|addr|
+ encode_email_address( unescape_special_chars($1) )
+ }
+ end
+
+
+ # Encoder functions to turn characters of an email address into encoded
+ # entities.
+ Encoders = [
+ lambda {|char| "%03d;" % char},
+ lambda {|char| "%X;" % char},
+ lambda {|char| char.chr },
+ ]
+
+ ### Transform a copy of the given email +addr+ into an escaped version safer
+ ### for posting publicly.
+ def encode_email_address( addr )
+
+ rval = ''
+ ("mailto:" + addr).each_byte {|b|
+ case b
+ when ?:
+ rval += ":"
+ when ?@
+ rval += Encoders[ rand(2) ][ b ]
+ else
+ r = rand(100)
+ rval += (
+ r > 90 ? Encoders[2][ b ] :
+ r < 45 ? Encoders[1][ b ] :
+ Encoders[0][ b ]
+ )
+ end
+ }
+
+ return %{%s} % [ rval, rval.sub(/.+?:/, '') ]
+ end
+
+
+ # Regex for matching Setext-style headers
+ SetextHeaderRegexp = %r{
+ (.+) # The title text ($1)
+ \n
+ ([\-=])+ # Match a line of = or -. Save only one in $2.
+ [ ]*\n+
+ }x
+
+ # Regexp for matching ATX-style headers
+ AtxHeaderRegexp = %r{
+ ^(\#{1,6}) # $1 = string of #'s
+ [ ]*
+ (.+?) # $2 = Header text
+ [ ]*
+ \#* # optional closing #'s (not counted)
+ \n+
+ }x
+
+ ### Apply Markdown header transforms to a copy of the given +str+ amd render
+ ### state +rs+ and return the result.
+ def transform_headers( str, rs )
+ @log.debug " Transforming headers"
+
+ # Setext-style headers:
+ # Header 1
+ # ========
+ #
+ # Header 2
+ # --------
+ #
+ str.
+ gsub( SetextHeaderRegexp ) {|m|
+ @log.debug "Found setext-style header"
+ title, hdrchar = $1, $2
+ title = apply_span_transforms( title, rs )
+
+ case hdrchar
+ when '='
+ %[
#{title}
\n\n]
+ when '-'
+ %[
#{title}
\n\n]
+ else
+ title
+ end
+ }.
+
+ gsub( AtxHeaderRegexp ) {|m|
+ @log.debug "Found ATX-style header"
+ hdrchars, title = $1, $2
+ title = apply_span_transforms( title, rs )
+
+ level = hdrchars.length
+ %{%s\n\n} % [ level, title, level ]
+ }
+ end
+
+
+ ### Wrap all remaining paragraph-looking text in a copy of +str+ inside
+ ### tags and return it.
+ def form_paragraphs( str, rs )
+ @log.debug " Forming paragraphs"
+ grafs = str.
+ sub( /\A\n+/, '' ).
+ sub( /\n+\z/, '' ).
+ split( /\n{2,}/ )
+
+ rval = grafs.collect {|graf|
+
+ # Unhashify HTML blocks if this is a placeholder
+ if rs.html_blocks.key?( graf )
+ rs.html_blocks[ graf ]
+
+ # Otherwise, wrap in
'
+ end
+ }.join( "\n\n" )
+
+ @log.debug " Formed paragraphs: %p" % rval
+ return rval
+ end
+
+
+ # Pattern to match the linkid part of an anchor tag for reference-style
+ # links.
+ RefLinkIdRegex = %r{
+ [ ]? # Optional leading space
+ (?:\n[ ]*)? # Optional newline + spaces
+ \[
+ (.*?) # Id = $1
+ \]
+ }x
+
+ InlineLinkRegex = %r{
+ \( # Literal paren
+ [ ]* # Zero or more spaces
+ (.*?) # URI = $1
+ [ ]* # Zero or more spaces
+ (?: #
+ ([\"\']) # Opening quote char = $2
+ (.*?) # Title = $3
+ \2 # Matching quote char
+ )? # Title is optional
+ \)
+ }x
+
+ ### Apply Markdown anchor transforms to a copy of the specified +str+ with
+ ### the given render state +rs+ and return it.
+ def transform_anchors( str, rs )
+ @log.debug " Transforming anchors"
+ @scanner.string = str.dup
+ text = ''
+
+ # Scan the whole string
+ until @scanner.empty?
+
+ if @scanner.scan( /\[/ )
+ link = ''; linkid = ''
+ depth = 1
+ startpos = @scanner.pos
+ @log.debug " Found a bracket-open at %d" % startpos
+
+ # Scan the rest of the tag, allowing unlimited nested []s. If
+ # the scanner runs out of text before the opening bracket is
+ # closed, append the text and return (wasn't a valid anchor).
+ while depth.nonzero?
+ linktext = @scanner.scan_until( /\]|\[/ )
+
+ if linktext
+ @log.debug " Found a bracket at depth %d: %p" %
+ [ depth, linktext ]
+ link += linktext
+
+ # Decrement depth for each closing bracket
+ depth += ( linktext[-1, 1] == ']' ? -1 : 1 )
+ @log.debug " Depth is now #{depth}"
+
+ # If there's no more brackets, it must not be an anchor, so
+ # just abort.
+ else
+ @log.debug " Missing closing brace, assuming non-link."
+ link += @scanner.rest
+ @scanner.terminate
+ return text + '[' + link
+ end
+ end
+ link.slice!( -1 ) # Trim final ']'
+ @log.debug " Found leading link %p" % link
+
+ # Look for a reference-style second part
+ if @scanner.scan( RefLinkIdRegex )
+ linkid = @scanner[1]
+ linkid = link.dup if linkid.empty?
+ linkid.downcase!
+ @log.debug " Found a linkid: %p" % linkid
+
+ # If there's a matching link in the link table, build an
+ # anchor tag for it.
+ if rs.urls.key?( linkid )
+ @log.debug " Found link key in the link table: %p" %
+ rs.urls[linkid]
+ url = escape_md( rs.urls[linkid] )
+
+ text += %{#{link}}
+
+ # If the link referred to doesn't exist, just append the raw
+ # source to the result
+ else
+ @log.debug " Linkid %p not found in link table" % linkid
+ @log.debug " Appending original string instead: %p" %
+ @scanner.string[ startpos-1 .. @scanner.pos ]
+ text += @scanner.string[ startpos-1 .. @scanner.pos ]
+ end
+
+ # ...or for an inline style second part
+ elsif @scanner.scan( InlineLinkRegex )
+ url = @scanner[1]
+ title = @scanner[3]
+ @log.debug " Found an inline link to %p" % url
+
+ text += %{#{link}}
+
+ # No linkid part: just append the first part as-is.
+ else
+ @log.debug "No linkid, so no anchor. Appending literal text."
+ text += @scanner.string[ startpos-1 .. @scanner.pos-1 ]
+ end # if linkid
+
+ # Plain text
+ else
+ @log.debug " Scanning to the next link from %p" % @scanner.rest
+ text += @scanner.scan( /[^\[]+/ )
+ end
+
+ end # until @scanner.empty?
+
+ return text
+ end
+
+ # Pattern to match strong emphasis in Markdown text
+ BoldRegexp = %r{ (\*\*|__) (?=\S) (.+?\S) \1 }x
+
+ # Pattern to match normal emphasis in Markdown text
+ ItalicRegexp = %r{ (\*|_) (?=\S) (.+?\S) \1 }x
+
+ ### Transform italic- and bold-encoded text in a copy of the specified +str+
+ ### and return it.
+ def transform_italic_and_bold( str, rs )
+ @log.debug " Transforming italic and bold"
+
+ str.
+ gsub( BoldRegexp, %{\\2} ).
+ gsub( ItalicRegexp, %{\\2} )
+ end
+
+
+ ### Transform backticked spans into spans.
+ def transform_code_spans( str, rs )
+ @log.debug " Transforming code spans"
+
+ # Set up the string scanner and just return the string unless there's at
+ # least one backtick.
+ @scanner.string = str.dup
+ unless @scanner.exist?( /`/ )
+ @scanner.terminate
+ @log.debug "No backticks found for code span in %p" % str
+ return str
+ end
+
+ @log.debug "Transforming code spans in %p" % str
+
+ # Build the transformed text anew
+ text = ''
+
+ # Scan to the end of the string
+ until @scanner.empty?
+
+ # Scan up to an opening backtick
+ if pre = @scanner.scan_until( /.?(?=`)/m )
+ text += pre
+ @log.debug "Found backtick at %d after '...%s'" %
+ [ @scanner.pos, text[-10, 10] ]
+
+ # Make a pattern to find the end of the span
+ opener = @scanner.scan( /`+/ )
+ len = opener.length
+ closer = Regexp::new( opener )
+ @log.debug "Scanning for end of code span with %p" % closer
+
+ # Scan until the end of the closing backtick sequence. Chop the
+ # backticks off the resultant string, strip leading and trailing
+ # whitespace, and encode any enitites contained in it.
+ codespan = @scanner.scan_until( closer ) or
+ raise FormatError::new( @scanner.rest[0,20],
+ "No %p found before end" % opener )
+
+ @log.debug "Found close of code span at %d: %p" %
+ [ @scanner.pos - len, codespan ]
+ codespan.slice!( -len, len )
+ text += "%s" %
+ encode_code( codespan.strip, rs )
+
+ # If there's no more backticks, just append the rest of the string
+ # and move the scan pointer to the end
+ else
+ text += @scanner.rest
+ @scanner.terminate
+ end
+ end
+
+ return text
+ end
+
+
+ # Next, handle inline images: ![alt text](url "optional title")
+ # Don't forget: encode * and _
+ InlineImageRegexp = %r{
+ ( # Whole match = $1
+ !\[ (.*?) \] # alt text = $2
+ \([ ]* (\S+) [ ]* # source url = $3
+ ( # title = $4
+ (["']) # quote char = $5
+ .*?
+ \5 # matching quote
+ [ ]*
+ )? # title is optional
+ \)
+ )
+ }xs #"
+
+
+ # Reference-style images
+ ReferenceImageRegexp = %r{
+ ( # Whole match = $1
+ !\[ (.*?) \] # Alt text = $2
+ [ ]? # Optional space
+ (?:\n[ ]*)? # One optional newline + spaces
+ \[ (.*?) \] # id = $3
+ )
+ }xs
+
+ ### Turn image markup into image tags.
+ def transform_images( str, rs )
+ @log.debug " Transforming images" % str
+
+ # Handle reference-style labeled images: ![alt text][id]
+ str.
+ gsub( ReferenceImageRegexp ) {|match|
+ whole, alt, linkid = $1, $2, $3.downcase
+ @log.debug "Matched %p" % match
+ res = nil
+
+ # for shortcut links like ![this][].
+ linkid = alt.downcase if linkid.empty?
+
+ if rs.urls.key?( linkid )
+ url = escape_md( rs.urls[linkid] )
+ @log.debug "Found url '%s' for linkid '%s' " %
+ [ url, linkid ]
+
+ # Build the tag
+ result = %{}, '>' ).
+ gsub( CodeEscapeRegexp ) {|match| EscapeTable[match][:md5]}
+ end
+
+
+
+ #################################################################
+ ### U T I L I T Y F U N C T I O N S
+ #################################################################
+
+ ### Escape any markdown characters in a copy of the given +str+ and return
+ ### it.
+ def escape_md( str )
+ str.
+ gsub( /\*/, '*' ).
+ gsub( /_/, '_' )
+ end
+
+
+ # Matching constructs for tokenizing X/HTML
+ HTMLCommentRegexp = %r{ }mx
+ XMLProcInstRegexp = %r{ <\? .*? \?> }mx
+ MetaTag = Regexp::union( HTMLCommentRegexp, XMLProcInstRegexp )
+
+ HTMLTagOpenRegexp = %r{ < [a-z/!$] [^<>]* }mx
+ HTMLTagCloseRegexp = %r{ > }x
+ HTMLTagPart = Regexp::union( HTMLTagOpenRegexp, HTMLTagCloseRegexp )
+
+ ### Break the HTML source in +str+ into a series of tokens and return
+ ### them. The tokens are just 2-element Array tuples with a type and the
+ ### actual content. If this function is called with a block, the type and
+ ### text parts of each token will be yielded to it one at a time as they are
+ ### extracted.
+ def tokenize_html( str )
+ depth = 0
+ tokens = []
+ @scanner.string = str.dup
+ type, token = nil, nil
+
+ until @scanner.empty?
+ @log.debug "Scanning from %p" % @scanner.rest
+
+ # Match comments and PIs without nesting
+ if (( token = @scanner.scan(MetaTag) ))
+ type = :tag
+
+ # Do nested matching for HTML tags
+ elsif (( token = @scanner.scan(HTMLTagOpenRegexp) ))
+ tagstart = @scanner.pos
+ @log.debug " Found the start of a plain tag at %d" % tagstart
+
+ # Start the token with the opening angle
+ depth = 1
+ type = :tag
+
+ # Scan the rest of the tag, allowing unlimited nested <>s. If
+ # the scanner runs out of text before the tag is closed, raise
+ # an error.
+ while depth.nonzero?
+
+ # Scan either an opener or a closer
+ chunk = @scanner.scan( HTMLTagPart ) or
+ raise "Malformed tag at character %d: %p" %
+ [ tagstart, token + @scanner.rest ]
+
+ @log.debug " Found another part of the tag at depth %d: %p" %
+ [ depth, chunk ]
+
+ token += chunk
+
+ # If the last character of the token so far is a closing
+ # angle bracket, decrement the depth. Otherwise increment
+ # it for a nested tag.
+ depth += ( token[-1, 1] == '>' ? -1 : 1 )
+ @log.debug " Depth is now #{depth}"
+ end
+
+ # Match text segments
+ else
+ @log.debug " Looking for a chunk of text"
+ type = :text
+
+ # Scan forward, always matching at least one character to move
+ # the pointer beyond any non-tag '<'.
+ token = @scanner.scan_until( /[^<]+/m )
+ end
+
+ @log.debug " type: %p, token: %p" % [ type, token ]
+
+ # If a block is given, feed it one token at a time. Add the token to
+ # the token list to be returned regardless.
+ if block_given?
+ yield( type, token )
+ end
+ tokens << [ type, token ]
+ end
+
+ return tokens
+ end
+
+
+ ### Return a copy of +str+ with angle brackets and ampersands HTML-encoded.
+ def encode_html( str )
+ str.gsub( /&(?!#?[x]?(?:[0-9a-f]+|\w{1,8});)/i, "&" ).
+ gsub( %r{<(?![a-z/?\$!])}i, "<" )
+ end
+
+
+ ### Return one level of line-leading tabs or spaces from a copy of +str+ and
+ ### return it.
+ def outdent( str )
+ str.gsub( /^(\t|[ ]{1,#{TabWidth}})/, '')
+ end
+
+end # class BlueCloth
+
diff --git a/lib/chunks/category.rb b/lib/chunks/category.rb
new file mode 100644
index 00000000..d08d8636
--- /dev/null
+++ b/lib/chunks/category.rb
@@ -0,0 +1,33 @@
+require 'chunks/chunk'
+
+# The category chunk looks for "category: news" on a line by
+# itself and parses the terms after the ':' as categories.
+# Other classes can search for Category chunks within
+# rendered content to find out what categories this page
+# should be in.
+#
+# Category lines can be hidden using ':category: news', for example
+class Category < Chunk::Abstract
+ CATEGORY_PATTERN = /^(:)?category\s*:(.*)$/i
+ def self.pattern() CATEGORY_PATTERN end
+
+ attr_reader :hidden, :list
+
+def initialize(match_data, content)
+ super(match_data, content)
+ @hidden = match_data[1]
+ @list = match_data[2].split(',').map { |c| c.strip }
+ @unmask_text = ''
+ if @hidden
+ @unmask_text = ''
+ else
+ category_urls = @list.map { |category| url(category) }.join(', ')
+ @unmask_text = '
category: ' + category_urls + '
'
+ end
+ end
+
+ # TODO move presentation of page metadata to controller/view
+ def url(category)
+ %{#{category}}
+ end
+end
diff --git a/lib/chunks/chunk.rb b/lib/chunks/chunk.rb
new file mode 100644
index 00000000..18de7d0c
--- /dev/null
+++ b/lib/chunks/chunk.rb
@@ -0,0 +1,79 @@
+require 'uri/common'
+
+# A chunk is a pattern of text that can be protected
+# and interrogated by a renderer. Each Chunk class has a
+# +pattern+ that states what sort of text it matches.
+# Chunks are initalized by passing in the result of a
+# match by its pattern.
+
+module Chunk
+ class Abstract
+
+ # automatically construct the array of derivatives of Chunk::Abstract
+ @derivatives = []
+
+ class << self
+ attr_reader :derivatives
+ end
+
+ def self::inherited( klass )
+ Abstract::derivatives << klass
+ end
+
+ # the class name part of the mask strings
+ def self.mask_string
+ self.to_s.delete(':').downcase
+ end
+
+ # a regexp that matches all chunk_types masks
+ def Abstract::mask_re(chunk_types)
+ chunk_classes = chunk_types.map{|klass| klass.mask_string}.join("|")
+ /chunk(-?\d+)(#{chunk_classes})chunk/
+ end
+
+ attr_reader :text, :unmask_text, :unmask_mode
+
+ def initialize(match_data, content)
+ @text = match_data[0]
+ @content = content
+ @unmask_mode = :normal
+ end
+
+ # Find all the chunks of the given type in content
+ # Each time the pattern is matched, create a new
+ # chunk for it, and replace the occurance of the chunk
+ # in this content with its mask.
+ def self.apply_to(content)
+ content.gsub!( self.pattern ) do |match|
+ new_chunk = self.new($~, content)
+ content.add_chunk(new_chunk)
+ new_chunk.mask
+ end
+ end
+
+ # should contain only [a-z0-9]
+ def mask
+ @mask ||= "chunk#{self.object_id}#{self.class.mask_string}chunk"
+ end
+
+ def unmask
+ @content.sub!(mask, @unmask_text)
+ end
+
+ def rendered?
+ @unmask_mode == :normal
+ end
+
+ def escaped?
+ @unmask_mode == :escape
+ end
+
+ def revert
+ @content.sub!(mask, @text)
+ # unregister
+ @content.delete_chunk(self)
+ end
+
+ end
+
+end
diff --git a/lib/chunks/engines.rb b/lib/chunks/engines.rb
new file mode 100644
index 00000000..3b7b5bbe
--- /dev/null
+++ b/lib/chunks/engines.rb
@@ -0,0 +1,62 @@
+$: << File.dirname(__FILE__) + "../../lib"
+
+require_dependency 'chunks/chunk'
+
+# The markup engines are Chunks that call the one of RedCloth
+# or RDoc to convert text. This markup occurs when the chunk is required
+# to mask itself.
+module Engines
+ class AbstractEngine < Chunk::Abstract
+
+ # Create a new chunk for the whole content and replace it with its mask.
+ def self.apply_to(content)
+ new_chunk = self.new(content)
+ content.replace(new_chunk.mask)
+ end
+
+ private
+
+ # Never create engines by constructor - use apply_to instead
+ def initialize(content)
+ @content = content
+ end
+
+ end
+
+ class Textile < AbstractEngine
+ def mask
+ require_dependency 'redcloth'
+ redcloth = RedCloth.new(@content, [:hard_breaks] + @content.options[:engine_opts])
+ redcloth.filter_html = false
+ redcloth.no_span_caps = false
+ redcloth.to_html(:textile)
+ end
+ end
+
+ class Markdown < AbstractEngine
+ def mask
+ require_dependency 'bluecloth_tweaked'
+ BlueCloth.new(@content, @content.options[:engine_opts]).to_html
+ end
+ end
+
+ class Mixed < AbstractEngine
+ def mask
+ require_dependency 'redcloth'
+ redcloth = RedCloth.new(@content, @content.options[:engine_opts])
+ redcloth.filter_html = false
+ redcloth.no_span_caps = false
+ redcloth.to_html
+ end
+ end
+
+ class RDoc < AbstractEngine
+ def mask
+ require_dependency 'rdocsupport'
+ RDocSupport::RDocFormatter.new(@content).to_html
+ end
+ end
+
+ MAP = { :textile => Textile, :markdown => Markdown, :mixed => Mixed, :rdoc => RDoc }
+ MAP.default = Textile
+end
diff --git a/lib/chunks/include.rb b/lib/chunks/include.rb
new file mode 100644
index 00000000..ac9b9bd8
--- /dev/null
+++ b/lib/chunks/include.rb
@@ -0,0 +1,49 @@
+require 'chunks/wiki'
+
+# Includes the contents of another page for rendering.
+# The include command looks like this: "[[!include PageName]]".
+# It is a WikiReference since it refers to another page (PageName)
+# and the wiki content using this command must be notified
+# of changes to that page.
+# If the included page could not be found, a warning is displayed.
+
+class Include < WikiChunk::WikiReference
+
+ INCLUDE_PATTERN = /\[\[!include\s+(.*?)\]\]\s*/i
+ def self.pattern() INCLUDE_PATTERN end
+
+ def initialize(match_data, content)
+ super
+ @page_name = match_data[1].strip
+ rendering_mode = content.options[:mode] || :show
+ @unmask_text = get_unmask_text_avoiding_recursion_loops(rendering_mode)
+ end
+
+ private
+
+ def get_unmask_text_avoiding_recursion_loops(rendering_mode)
+ if refpage
+ # TODO This way of instantiating a renderer is ugly.
+ renderer = PageRenderer.new(refpage.current_revision)
+ if renderer.wiki_includes.include?(@content.page_name)
+ # this will break the recursion
+ @content.delete_chunk(self)
+ return "Recursive include detected; #{@page_name} --> #{@content.page_name} " +
+ "--> #{@page_name}\n"
+ else
+ included_content =
+ case rendering_mode
+ when :show then renderer.display_content
+ when :publish then renderer.display_published
+ when :export then renderer.display_content_for_export
+ else raise "Unsupported rendering mode #{@mode.inspect}"
+ end
+ @content.merge_chunks(included_content)
+ return included_content.pre_rendered
+ end
+ else
+ return "Could not include #{@page_name}\n"
+ end
+ end
+
+end
diff --git a/lib/chunks/literal.rb b/lib/chunks/literal.rb
new file mode 100644
index 00000000..09da4005
--- /dev/null
+++ b/lib/chunks/literal.rb
@@ -0,0 +1,31 @@
+require 'chunks/chunk'
+
+# These are basic chunks that have a pattern and can be protected.
+# They are used by rendering process to prevent wiki rendering
+# occuring within literal areas such as and
blocks
+# and within HTML tags.
+module Literal
+
+ class AbstractLiteral < Chunk::Abstract
+
+ def initialize(match_data, content)
+ super
+ @unmask_text = @text
+ end
+
+ end
+
+ # A literal chunk that protects 'code' and 'pre' tags from wiki rendering.
+ class Pre < AbstractLiteral
+ PRE_BLOCKS = "a|pre|code"
+ PRE_PATTERN = Regexp.new('<('+PRE_BLOCKS+')\b[^>]*?>.*?\1>', Regexp::MULTILINE)
+ def self.pattern() PRE_PATTERN end
+ end
+
+ # A literal chunk that protects HTML tags from wiki rendering.
+ class Tags < AbstractLiteral
+ TAGS = "a|img|em|strong|div|span|table|td|th|ul|ol|li|dl|dt|dd"
+ TAGS_PATTERN = Regexp.new('<(?:'+TAGS+')[^>]*?>', Regexp::MULTILINE)
+ def self.pattern() TAGS_PATTERN end
+ end
+end
diff --git a/lib/chunks/nowiki.rb b/lib/chunks/nowiki.rb
new file mode 100644
index 00000000..ef99ec0b
--- /dev/null
+++ b/lib/chunks/nowiki.rb
@@ -0,0 +1,28 @@
+require 'chunks/chunk'
+
+# This chunks allows certain parts of a wiki page to be hidden from the
+# rest of the rendering pipeline. It should be run at the beginning
+# of the pipeline in `wiki_content.rb`.
+#
+# An example use of this chunk is to markup double brackets or
+# auto URI links:
+# Here are [[double brackets]] and a URI: www.uri.org
+#
+# The contents of the chunks will not be processed by any other chunk
+# so the `www.uri.org` and the double brackets will appear verbatim.
+#
+# Author: Mark Reid
+# Created: 8th June 2004
+class NoWiki < Chunk::Abstract
+
+ NOWIKI_PATTERN = Regexp.new('(.*?)', Regexp::MULTILINE)
+ def self.pattern() NOWIKI_PATTERN end
+
+ attr_reader :plain_text
+
+ def initialize(match_data, content)
+ super
+ @plain_text = @unmask_text = match_data[1]
+ end
+
+end
diff --git a/lib/chunks/test.rb b/lib/chunks/test.rb
new file mode 100644
index 00000000..73af8142
--- /dev/null
+++ b/lib/chunks/test.rb
@@ -0,0 +1,18 @@
+require 'test/unit'
+
+class ChunkTest < Test::Unit::TestCase
+
+ # Asserts a number of tests for the given type and text.
+ def match(type, test_text, expected)
+ pattern = type.pattern
+ assert_match(pattern, test_text)
+ pattern =~ test_text # Previous assertion guarantees match
+ chunk = type.new($~)
+
+ # Test if requested parts are correct.
+ for method_sym, value in expected do
+ assert_respond_to(chunk, method_sym)
+ assert_equal(value, chunk.method(method_sym).call, "Checking value of '#{method_sym}'")
+ end
+ end
+end
diff --git a/lib/chunks/uri.rb b/lib/chunks/uri.rb
new file mode 100644
index 00000000..1a208535
--- /dev/null
+++ b/lib/chunks/uri.rb
@@ -0,0 +1,182 @@
+require 'chunks/chunk'
+
+# This wiki chunk matches arbitrary URIs, using patterns from the Ruby URI modules.
+# It parses out a variety of fields that could be used by renderers to format
+# the links in various ways (shortening domain names, hiding email addresses)
+# It matches email addresses and host.com.au domains without schemes (http://)
+# but adds these on as required.
+#
+# The heuristic used to match a URI is designed to err on the side of caution.
+# That is, it is more likely to not autolink a URI than it is to accidently
+# autolink something that is not a URI. The reason behind this is it is easier
+# to force a URI link by prefixing 'http://' to it than it is to escape and
+# incorrectly marked up non-URI.
+#
+# I'm using a part of the [ISO 3166-1 Standard][iso3166] for country name suffixes.
+# The generic names are from www.bnoack.com/data/countrycode2.html)
+# [iso3166]: http://geotags.com/iso3166/
+
+class URIChunk < Chunk::Abstract
+ include URI::REGEXP::PATTERN
+
+ # this condition is to get rid of pesky warnings in tests
+ unless defined? URIChunk::INTERNET_URI_REGEXP
+
+ GENERIC = 'aero|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org'
+
+ COUNTRY = 'ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|az|ba|bb|bd|be|' +
+ 'bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cf|cd|cg|ch|ci|ck|cl|' +
+ 'cm|cn|co|cr|cs|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|fi|' +
+ 'fj|fk|fm|fo|fr|fx|ga|gb|gd|ge|gf|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|' +
+ 'hk|hm|hn|hr|ht|hu|id|ie|il|in|io|iq|ir|is|it|jm|jo|jp|ke|kg|kh|ki|km|kn|' +
+ 'kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|mg|mh|mk|ml|mm|' +
+ 'mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nt|' +
+ 'nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|pt|pw|py|qa|re|ro|ru|rw|sa|sb|sc|' +
+ 'sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|' +
+ 'tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|' +
+ 'ws|ye|yt|yu|za|zm|zr|zw'
+ # These are needed otherwise HOST will match almost anything
+ TLDS = "(?:#{GENERIC}|#{COUNTRY})"
+
+ # Redefine USERINFO so that it must have non-zero length
+ USERINFO = "(?:[#{UNRESERVED};:&=+$,]|#{ESCAPED})+"
+
+ # unreserved_no_ending = alphanum | mark, but URI_ENDING [)!] excluded
+ UNRESERVED_NO_ENDING = "-_.~*'(#{ALNUM}"
+
+ # this ensures that query or fragment do not end with URI_ENDING
+ # and enable us to use a much simpler self.pattern Regexp
+
+ # uric_no_ending = reserved | unreserved_no_ending | escaped
+ URIC_NO_ENDING = "(?:[#{UNRESERVED_NO_ENDING}#{RESERVED}]|#{ESCAPED})"
+ # query = *uric
+ QUERY = "#{URIC_NO_ENDING}*"
+ # fragment = *uric
+ FRAGMENT = "#{URIC_NO_ENDING}*"
+
+ # DOMLABEL is defined in the ruby uri library, TLDS is defined above
+ INTERNET_HOSTNAME = "(?:#{DOMLABEL}\\.)+#{TLDS}"
+
+ # Correct a typo bug in ruby 1.8.x lib/uri/common.rb
+ PORT = '\\d*'
+
+ INTERNET_URI =
+ "(?:(#{SCHEME}):/{0,2})?" + # Optional scheme: (\1)
+ "(?:(#{USERINFO})@)?" + # Optional userinfo@ (\2)
+ "(#{INTERNET_HOSTNAME})" + # Mandatory hostname (\3)
+ "(?::(#{PORT}))?" + # Optional :port (\4)
+ "(#{ABS_PATH})?" + # Optional absolute path (\5)
+ "(?:\\?(#{QUERY}))?" + # Optional ?query (\6)
+ "(?:\\#(#{FRAGMENT}))?" + # Optional #fragment (\7)
+ '(?=\.?(?:\s|\)|\z))' # ends only with optional dot + space or ")"
+ # or end of the string
+
+ SUSPICIOUS_PRECEDING_CHARACTER = '(!|\"\:|\"|\\\'|\]\()?' # any of !, ":, ", ', ](
+
+ INTERNET_URI_REGEXP =
+ Regexp.new(SUSPICIOUS_PRECEDING_CHARACTER + INTERNET_URI, Regexp::EXTENDED, 'N')
+
+ end
+
+ def URIChunk.pattern
+ INTERNET_URI_REGEXP
+ end
+
+ attr_reader :user, :host, :port, :path, :query, :fragment, :link_text
+
+ def self.apply_to(content)
+ content.gsub!( self.pattern ) do |matched_text|
+ chunk = self.new($~, content)
+ if chunk.avoid_autolinking?
+ # do not substitute nor register the chunk
+ matched_text
+ else
+ content.add_chunk(chunk)
+ chunk.mask
+ end
+ end
+ end
+
+ def initialize(match_data, content)
+ super
+ @link_text = match_data[0]
+ @suspicious_preceding_character = match_data[1]
+ @original_scheme, @user, @host, @port, @path, @query, @fragment = match_data[2..-1]
+ treat_trailing_character
+ @unmask_text = "#{link_text}"
+ end
+
+ def avoid_autolinking?
+ not @suspicious_preceding_character.nil?
+ end
+
+ def treat_trailing_character
+ # If the last character matched by URI pattern is in ! or ), this may be part of the markup,
+ # not a URL. We should handle it as such. It is possible to do it by a regexp, but
+ # much easier to do programmatically
+ last_char = @link_text[-1..-1]
+ if last_char == ')' or last_char == '!'
+ @trailing_punctuation = last_char
+ @link_text.chop!
+ [@original_scheme, @user, @host, @port, @path, @query, @fragment].compact.last.chop!
+ else
+ @trailing_punctuation = nil
+ end
+ end
+
+ def scheme
+ @original_scheme or (@user ? 'mailto' : 'http')
+ end
+
+ def scheme_delimiter
+ scheme == 'mailto' ? ':' : '://'
+ end
+
+ def user_delimiter
+ '@' unless @user.nil?
+ end
+
+ def port_delimiter
+ ':' unless @port.nil?
+ end
+
+ def query_delimiter
+ '?' unless @query.nil?
+ end
+
+ def uri
+ [scheme, scheme_delimiter, user, user_delimiter, host, port_delimiter, port, path,
+ query_delimiter, query].compact.join
+ end
+
+end
+
+# uri with mandatory scheme but less restrictive hostname, like
+# http://localhost:2500/blah.html
+class LocalURIChunk < URIChunk
+
+ unless defined? LocalURIChunk::LOCAL_URI_REGEXP
+ # hostname can be just a simple word like 'localhost'
+ ANY_HOSTNAME = "(?:#{DOMLABEL}\\.)*#{TOPLABEL}\\.?"
+
+ # The basic URI expression as a string
+ # Scheme and hostname are mandatory
+ LOCAL_URI =
+ "(?:(#{SCHEME})://)+" + # Mandatory scheme:// (\1)
+ "(?:(#{USERINFO})@)?" + # Optional userinfo@ (\2)
+ "(#{ANY_HOSTNAME})" + # Mandatory hostname (\3)
+ "(?::(#{PORT}))?" + # Optional :port (\4)
+ "(#{ABS_PATH})?" + # Optional absolute path (\5)
+ "(?:\\?(#{QUERY}))?" + # Optional ?query (\6)
+ "(?:\\#(#{FRAGMENT}))?" + # Optional #fragment (\7)
+ '(?=\.?(?:\s|\)|\z))' # ends only with optional dot + space or ")"
+ # or end of the string
+
+ LOCAL_URI_REGEXP = Regexp.new(SUSPICIOUS_PRECEDING_CHARACTER + LOCAL_URI, Regexp::EXTENDED, 'N')
+ end
+
+ def LocalURIChunk.pattern
+ LOCAL_URI_REGEXP
+ end
+
+end
diff --git a/lib/chunks/wiki.rb b/lib/chunks/wiki.rb
new file mode 100644
index 00000000..617e6da5
--- /dev/null
+++ b/lib/chunks/wiki.rb
@@ -0,0 +1,143 @@
+require 'wiki_words'
+require 'chunks/chunk'
+require 'chunks/wiki'
+require 'cgi'
+
+# Contains all the methods for finding and replacing wiki related links.
+module WikiChunk
+ include Chunk
+
+ # A wiki reference is the top-level class for anything that refers to
+ # another wiki page.
+ class WikiReference < Chunk::Abstract
+
+ # Name of the referenced page
+ attr_reader :page_name
+
+ # the referenced page
+ def refpage
+ @content.web.page(@page_name)
+ end
+
+ end
+
+ # A wiki link is the top-level class for links that refers to
+ # another wiki page.
+ class WikiLink < WikiReference
+
+ attr_reader :link_text, :link_type
+
+ def initialize(match_data, content)
+ super
+ @link_type = :show
+ end
+
+ def self.apply_to(content)
+ content.gsub!( self.pattern ) do |matched_text|
+ chunk = self.new($~, content)
+ if chunk.textile_url?
+ # do not substitute
+ matched_text
+ else
+ content.add_chunk(chunk)
+ chunk.mask
+ end
+ end
+ end
+
+ def textile_url?
+ not @textile_link_suffix.nil?
+ end
+
+ # replace any sequence of whitespace characters with a single space
+ def normalize_whitespace(line)
+ line.gsub(/\s+/, ' ')
+ end
+
+ end
+
+ # This chunk matches a WikiWord. WikiWords can be escaped
+ # by prepending a '\'. When this is the case, the +escaped_text+
+ # method will return the WikiWord instead of the usual +nil+.
+ # The +page_name+ method returns the matched WikiWord.
+ class Word < WikiLink
+
+ attr_reader :escaped_text
+
+ unless defined? WIKI_WORD
+ WIKI_WORD = Regexp.new('(":)?(\\\\)?(' + WikiWords::WIKI_WORD_PATTERN + ')\b', 0, "utf-8")
+ end
+
+ def self.pattern
+ WIKI_WORD
+ end
+
+ def initialize(match_data, content)
+ super
+ @textile_link_suffix, @escape, @page_name = match_data[1..3]
+ if @escape
+ @unmask_mode = :escape
+ @escaped_text = @page_name
+ else
+ @escaped_text = nil
+ end
+ @link_text = WikiWords.separate(@page_name)
+ @unmask_text = (@escaped_text || @content.page_link(@page_name, @link_text, @link_type))
+ end
+
+ end
+
+ # This chunk handles [[bracketted wiki words]] and
+ # [[AliasedWords|aliased wiki words]]. The first part of an
+ # aliased wiki word must be a WikiWord. If the WikiWord
+ # is aliased, the +link_text+ field will contain the
+ # alias, otherwise +link_text+ will contain the entire
+ # contents within the double brackets.
+ #
+ # NOTE: This chunk must be tested before WikiWord since
+ # a WikiWords can be a substring of a WikiLink.
+ class Link < WikiLink
+
+ unless defined? WIKI_LINK
+ WIKI_LINK = /(":)?\[\[\s*([^\]\s][^\]]+?)\s*\]\]/
+ LINK_TYPE_SEPARATION = Regexp.new('^(.+):((file)|(pic))$', 0, 'utf-8')
+ ALIAS_SEPARATION = Regexp.new('^(.+)\|(.+)$', 0, 'utf-8')
+ end
+
+ def self.pattern() WIKI_LINK end
+
+ def initialize(match_data, content)
+ super
+ @textile_link_suffix = match_data[1]
+ @link_text = @page_name = normalize_whitespace(match_data[2])
+ separate_link_type
+ separate_alias
+ @unmask_text = @content.page_link(@page_name, @link_text, @link_type)
+ end
+
+ private
+
+ # if link wihin the brackets has a form of [[filename:file]] or [[filename:pic]],
+ # this means a link to a picture or a file
+ def separate_link_type
+ link_type_match = LINK_TYPE_SEPARATION.match(@page_name)
+ if link_type_match
+ @link_text = @page_name = link_type_match[1]
+ @link_type = link_type_match[2..3].compact[0].to_sym
+ end
+ end
+
+ # link text may be different from page name. this will look like [[actual page|link text]]
+ def separate_alias
+ alias_match = ALIAS_SEPARATION.match(@page_name)
+ if alias_match
+ @page_name = normalize_whitespace(alias_match[1])
+ @link_text = alias_match[2]
+ end
+ # note that [[filename|link text:file]] is also supported
+ end
+
+ end
+
+
+end
diff --git a/lib/diff.rb b/lib/diff.rb
new file mode 100644
index 00000000..1e3fe298
--- /dev/null
+++ b/lib/diff.rb
@@ -0,0 +1,316 @@
+module HTMLDiff
+
+ Match = Struct.new(:start_in_old, :start_in_new, :size)
+ class Match
+ def end_in_old
+ self.start_in_old + self.size
+ end
+
+ def end_in_new
+ self.start_in_new + self.size
+ end
+ end
+
+ Operation = Struct.new(:action, :start_in_old, :end_in_old, :start_in_new, :end_in_new)
+
+ class DiffBuilder
+
+ def initialize(old_version, new_version)
+ @old_version, @new_version = old_version, new_version
+ @content = []
+ end
+
+ def build
+ split_inputs_to_words
+ index_new_words
+ operations.each { |op| perform_operation(op) }
+ return @content.join
+ end
+
+ def split_inputs_to_words
+ @old_words = convert_html_to_list_of_words(explode(@old_version))
+ @new_words = convert_html_to_list_of_words(explode(@new_version))
+ end
+
+ def index_new_words
+ @word_indices = Hash.new { |h, word| h[word] = [] }
+ @new_words.each_with_index { |word, i| @word_indices[word] << i }
+ end
+
+ def operations
+ position_in_old = position_in_new = 0
+ operations = []
+
+ matches = matching_blocks
+ # an empty match at the end forces the loop below to handle the unmatched tails
+ # I'm sure it can be done more gracefully, but not at 23:52
+ matches << Match.new(@old_words.length, @new_words.length, 0)
+
+ matches.each_with_index do |match, i|
+ match_starts_at_current_position_in_old = (position_in_old == match.start_in_old)
+ match_starts_at_current_position_in_new = (position_in_new == match.start_in_new)
+
+ action_upto_match_positions =
+ case [match_starts_at_current_position_in_old, match_starts_at_current_position_in_new]
+ when [false, false]
+ :replace
+ when [true, false]
+ :insert
+ when [false, true]
+ :delete
+ else
+ # this happens if the first few words are same in both versions
+ :none
+ end
+
+ if action_upto_match_positions != :none
+ operation_upto_match_positions =
+ Operation.new(action_upto_match_positions,
+ position_in_old, match.start_in_old,
+ position_in_new, match.start_in_new)
+ operations << operation_upto_match_positions
+ end
+ if match.size != 0
+ match_operation = Operation.new(:equal,
+ match.start_in_old, match.end_in_old,
+ match.start_in_new, match.end_in_new)
+ operations << match_operation
+ end
+
+ position_in_old = match.end_in_old
+ position_in_new = match.end_in_new
+ end
+
+ operations
+ end
+
+ def matching_blocks
+ matching_blocks = []
+ recursively_find_matching_blocks(0, @old_words.size, 0, @new_words.size, matching_blocks)
+ matching_blocks
+ end
+
+ def recursively_find_matching_blocks(start_in_old, end_in_old, start_in_new, end_in_new, matching_blocks)
+ match = find_match(start_in_old, end_in_old, start_in_new, end_in_new)
+ if match
+ if start_in_old < match.start_in_old and start_in_new < match.start_in_new
+ recursively_find_matching_blocks(
+ start_in_old, match.start_in_old, start_in_new, match.start_in_new, matching_blocks)
+ end
+ matching_blocks << match
+ if match.end_in_old < end_in_old and match.end_in_new < end_in_new
+ recursively_find_matching_blocks(
+ match.end_in_old, end_in_old, match.end_in_new, end_in_new, matching_blocks)
+ end
+ end
+ end
+
+ def find_match(start_in_old, end_in_old, start_in_new, end_in_new)
+
+ best_match_in_old = start_in_old
+ best_match_in_new = start_in_new
+ best_match_size = 0
+
+ match_length_at = Hash.new { |h, index| h[index] = 0 }
+
+ start_in_old.upto(end_in_old - 1) do |index_in_old|
+
+ new_match_length_at = Hash.new { |h, index| h[index] = 0 }
+
+ @word_indices[@old_words[index_in_old]].each do |index_in_new|
+ next if index_in_new < start_in_new
+ break if index_in_new >= end_in_new
+
+ new_match_length = match_length_at[index_in_new - 1] + 1
+ new_match_length_at[index_in_new] = new_match_length
+
+ if new_match_length > best_match_size
+ best_match_in_old = index_in_old - new_match_length + 1
+ best_match_in_new = index_in_new - new_match_length + 1
+ best_match_size = new_match_length
+ end
+ end
+ match_length_at = new_match_length_at
+ end
+
+# best_match_in_old, best_match_in_new, best_match_size = add_matching_words_left(
+# best_match_in_old, best_match_in_new, best_match_size, start_in_old, start_in_new)
+# best_match_in_old, best_match_in_new, match_size = add_matching_words_right(
+# best_match_in_old, best_match_in_new, best_match_size, end_in_old, end_in_new)
+
+ return (best_match_size != 0 ? Match.new(best_match_in_old, best_match_in_new, best_match_size) : nil)
+ end
+
+ def add_matching_words_left(match_in_old, match_in_new, match_size, start_in_old, start_in_new)
+ while match_in_old > start_in_old and
+ match_in_new > start_in_new and
+ @old_words[match_in_old - 1] == @new_words[match_in_new - 1]
+ match_in_old -= 1
+ match_in_new -= 1
+ match_size += 1
+ end
+ [match_in_old, match_in_new, match_size]
+ end
+
+ def add_matching_words_right(match_in_old, match_in_new, match_size, end_in_old, end_in_new)
+ while match_in_old + match_size < end_in_old and
+ match_in_new + match_size < end_in_new and
+ @old_words[match_in_old + match_size] == @new_words[match_in_new + match_size]
+ match_size += 1
+ end
+ [match_in_old, match_in_new, match_size]
+ end
+
+ VALID_METHODS = [:replace, :insert, :delete, :equal]
+
+ def perform_operation(operation)
+ @operation = operation
+ self.send operation.action, operation
+ end
+
+ def replace(operation)
+ delete(operation, 'diffmod')
+ insert(operation, 'diffmod')
+ end
+
+ def insert(operation, tagclass = 'diffins')
+ insert_tag('ins', tagclass, @new_words[operation.start_in_new...operation.end_in_new])
+ end
+
+ def delete(operation, tagclass = 'diffdel')
+ insert_tag('del', tagclass, @old_words[operation.start_in_old...operation.end_in_old])
+ end
+
+ def equal(operation)
+ # no tags to insert, simply copy the matching words from one of the versions
+ @content += @new_words[operation.start_in_new...operation.end_in_new]
+ end
+
+ def opening_tag?(item)
+ item =~ %r!^\s*<[^>]+>\s*$!
+ end
+
+ def closing_tag?(item)
+ item =~ %r!^\s*[^>]+>\s*$!
+ end
+
+ def tag?(item)
+ opening_tag?(item) or closing_tag?(item)
+ end
+
+ def extract_consecutive_words(words, &condition)
+ index_of_first_tag = nil
+ words.each_with_index do |word, i|
+ if !condition.call(word)
+ index_of_first_tag = i
+ break
+ end
+ end
+ if index_of_first_tag
+ return words.slice!(0...index_of_first_tag)
+ else
+ return words.slice!(0..words.length)
+ end
+ end
+
+ # This method encloses words within a specified tag (ins or del), and adds this into @content,
+ # with a twist: if there are words contain tags, it actually creates multiple ins or del,
+ # so that they don't include any ins or del. This handles cases like
+ # old: '