Changeset 247

Show
Ignore:
Timestamp:
04/24/08 10:04:46 (7 months ago)
Author:
eric.dumin..@gmail.com
Message:

yet_another_index_structure is much more stable than trunk. Merging back into trunk

Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • trunk/History.txt

    r227 r247  
    11== 0.1.5  2008-04- 
     2 
     3* 1 major enhancement: 
     4  * yet another Indexer & Index rewrite 
     5 
    26* 1 minor enhancement: 
    37  * flags to indicate found language 
     8   
     9* bug fixes: 
     10  * No more (or just less?) index lock errors 
    411 
    512== 0.1.4  2008-04-23 
  • trunk/Manifest.txt

    r228 r247  
    2323lib/picolena/templates/app/models/document.rb 
    2424lib/picolena/templates/app/models/finder.rb 
    25 lib/picolena/templates/app/models/index_reader.rb 
    26 lib/picolena/templates/app/models/index_writer.rb 
    2725lib/picolena/templates/app/models/indexer.rb 
    2826lib/picolena/templates/app/models/plain_text_extractor.rb 
     
    137135lib/picolena/templates/spec/models/host_indexing_system_spec.rb 
    138136lib/picolena/templates/spec/models/index_directories_spec.rb 
    139 lib/picolena/templates/spec/models/index_reader_spec.rb 
    140 lib/picolena/templates/spec/models/index_writer_spec.rb 
    141137lib/picolena/templates/spec/models/indexer_spec.rb 
    142138lib/picolena/templates/spec/models/plain_text_extractor_spec.rb 
  • trunk/lib/picolena/templates/app/helpers/documents_helper.rb

    r228 r247  
    3939   
    4040  def language_icon_for(document) 
    41     (lang=document.lang) && image_tag("flags/#{lang}.png") 
     41    (lang=document.language) && image_tag("flags/#{lang}.png") 
    4242  end 
    4343   
  • trunk/lib/picolena/templates/app/models/document.rb

    r190 r247  
    7272  # Useful to know how old a document is, and to which version the cache corresponds. 
    7373  def date 
    74     from_index[:date].sub(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/,'\1-\2-\3 \4:\5:\6') 
     74    from_index[:modified].sub(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/,'\1-\2-\3 \4:\5:\6') 
    7575  end 
    7676   
    7777  def mtime 
    78     from_index[:date].to_i 
     78    from_index[:modified].to_i 
    7979  end 
    8080   
    8181  # Returns language. 
    82   def lang 
    83     from_index[:lang
     82  def language 
     83    from_index[:language
    8484  end 
    8585   
    8686  # Returns the id with which the document is indexed. 
    8787  def index_id 
    88     @index_id ||= Document.find_by_complete_path(complete_path).index_id 
     88    @index_id ||= Finder.term_search(:complete_path, complete_path).doc 
     89  end 
     90   
     91  # Fields that are shared between every document. 
     92  def self.default_fields_for(complete_path) 
     93    { 
     94      :complete_path      => complete_path, 
     95      :probably_unique_id => complete_path.base26_hash, 
     96      :filename           => File.basename(complete_path), 
     97      :basename           => File.basename(complete_path, File.extname(complete_path)).gsub(/_/,' '), 
     98      :filetype           => File.extname(complete_path), 
     99      :modified           => File.mtime(complete_path).strftime("%Y%m%d%H%M%S") 
     100    }              
    89101  end 
    90102   
     
    94106  # Useful to get meta-info about it. 
    95107  def from_index 
    96     IndexReader.new[index_id] 
     108    Indexer.index[index_id] 
    97109  end 
    98110   
    99111  def self.find_by_unique_id(some_id) 
    100     Finder.new("probably_unique_id:"<<some_id).matching_document 
     112    doc_id=Finder.term_search(:probably_unique_id, some_id).doc 
     113    new(Indexer.index[doc_id][:complete_path]) 
    101114  end 
    102    
    103   def self.find_by_complete_path(complete_path) 
    104     Finder.new('complete_path:"'<<complete_path<<'"').matching_document 
    105   end 
    106    
     115  
    107116  def in_indexed_directory? 
    108117    !indexed_directory.nil? 
  • trunk/lib/picolena/templates/app/models/finder.rb

    r177 r247  
    33   
    44  def index 
    5     # caching index @@index ||=   
    6     # causes ferret-0.11.6/lib/ferret/index.rb:768: [BUG] Segmentation fault 
    7     IndexReader.new 
     5    @@index ||= Indexer.index 
    86  end 
    97   
     
    119    @query = Query.extract_from(raw_query) 
    1210    @raw_query= raw_query 
    13     IndexReader.ensure_existence 
     11    Indexer.ensure_index_existence 
    1412    @per_page=results_per_page 
    1513    @offset=(page.to_i-1)*results_per_page 
    16     index.should_have_documents 
     14    index_should_have_documents 
    1715  end 
    1816   
     
    3230        found_doc.index_id=index_id 
    3331        @matching_documents<<found_doc 
    34         rescue Errno::ENOENT 
    35           #"File has been moved/deleted!" 
    36         end 
     32      rescue Errno::ENOENT 
     33        #"File has been moved/deleted!" 
     34      end 
    3735      } 
    3836      @executed=true 
     
    6159   # exactly one document is found. 
    6260   # Raises otherwise. 
    63    def matching_document 
    64      case matching_documents.size 
    65      when 0 
    66        raise IndexError, "No document found" 
    67      when 1 
    68        matching_documents.first 
    69      else 
    70        raise IndexError, "More than one document found" 
    71      end 
    72    end 
     61  def matching_document 
     62    case matching_documents.size 
     63    when 0 
     64      raise IndexError, "No document found" 
     65    when 1 
     66      matching_documents.first 
     67    else 
     68      raise IndexError, "More than one document found" 
     69    end 
     70  end 
     71   
     72  class<<self   
     73    def searcher 
     74      @@searcher ||= Ferret::Search::Searcher.new(Picolena::IndexSavePath) 
     75    end 
     76     
     77    def term_search(field,term) 
     78      query = Ferret::Search::TermQuery.new(field,term) 
     79      searcher.search(query).hits.first 
     80    end 
     81 
     82    def reload! 
     83      @@searcher = nil 
     84      @@index    = nil 
     85    end 
     86  end 
     87   
     88  private 
     89   
     90  def index_should_have_documents 
     91    raise IndexError, "no document found" unless index.size > 0 
     92  end 
    7393end 
  • trunk/lib/picolena/templates/app/models/indexer.rb

    r211 r247  
    66   
    77  class << self 
    8     def fields_for(complete_path) 
    9       { 
    10         :complete_path      => complete_path, 
    11         :probably_unique_id => complete_path.base26_hash, 
    12         :file               => File.basename(complete_path), 
    13         :basename           => File.basename(complete_path, File.extname(complete_path)).gsub(/_/,' '), 
    14         :filetype           => File.extname(complete_path), 
    15         :date               => File.mtime(complete_path).strftime("%Y%m%d%H%M%S") 
    16       }       
    17     end     
    18      
    19     def index_every_directory(update=true) 
     8    def index_every_directory(remove_first=false) 
     9      clear! if remove_first 
     10      # Forces Finder.searcher and Finder.index to be reloaded, by removing them from the cache. 
     11      Finder.reload! 
    2012      log :debug => "Indexing every directory" 
    21        
    22        
    2313      start=Time.now 
    24       @update = update 
    25       reset! unless update 
    26        
    2714      Picolena::IndexedDirectories.each{|dir, alias_dir| 
    2815        index_directory_with_multithreads(dir) 
    2916      } 
    30       # FIXME: with those 2 lines, 
     17      log :debug => "Now optimizing index" 
    3118      writer.optimize 
    32       writer.close 
    33       # launching Indexer.index_every_directory twice in a row 
    34       # would raise a SEGFAULT: 
    35       # picolena/lib/picolena/templates/app/models/indexer.rb:27: [BUG] Segmentation fault 
    36       # ruby 1.8.6 (2007-06-07) [i486-linux] 
    37       # 
    38       # Aborted (core dumped) 
    39       # 
    40       # But without those 2 lines, specs don't pass anymore. 
    41       # 
    4219      log :debug => "Indexing done in #{Time.now-start} s." 
    4320    end 
    4421     
    4522    def index_directory_with_multithreads(dir) 
    46       # FIXME: Don't know why, but if more than one thread is created while update the index, 
    47       # indexer raises: 
    48       # 
    49       # current thread not owner 
    50       # /usr/lib/ruby/1.8/monitor.rb:278:in `mon_check_owner' 
    51       # /home/www/picolena/lib/picolena/templates/lib/core_exts.rb:32:in `join' 
    52       # ... 
    53       # 
    54       # So Index creation is multithreaded, Index update is monothreaded. 
    55       threads_number = @update ? 1 : @@max_threads_number 
     23      threads_number = @@max_threads_number 
    5624      log :debug => "Indexing #{dir}, #{threads_number} thread(s)" 
    5725       
     
    6230      indexing_list_chunks=indexing_list.in_transposed_slices(threads_number) 
    6331       
     32      # It initializes an IndexWriter before launching multithreaded 
     33      # indexing. Otherwise, two threads could try to instantiate 
     34      # an IndexWriter at the same time, and get a 
     35      #  Ferret::Store::Lock::LockError 
     36      writer 
     37       
    6438      indexing_list_chunks.each_with_thread{|chunk| 
    6539        chunk.each{|filename| 
    66           add_or_update_file(filename) 
     40          add_file(filename) 
    6741        } 
    6842      } 
    6943    end 
    7044     
    71     def add_or_update_file(complete_path) 
    72       should_be_added = true 
    73       if @update then 
    74         log :debug =>  "What to do with #{complete_path} ?" 
    75         occurences = reader.occurences_number(complete_path)  
    76         log :debug =>  "\tappears #{occurences} times in the index" 
    77         case occurences 
    78           when 0 
    79           #Nothing to do here, the file will be added. 
    80           when 1 
    81           d=Document.find_by_complete_path(complete_path) 
    82           if File.mtime(complete_path).strftime("%Y%m%d%H%M%S").to_i > d.mtime then 
    83             log :debug => "\thas been modified" 
    84             delete_file(complete_path) 
    85           else 
    86             should_be_added = false 
    87             log :debug => "\thas not been modified. leaving it" 
    88           end 
    89         else 
    90           delete_file(complete_path) 
    91         end 
     45    def add_file(complete_path) 
     46      default_fields = Document.default_fields_for(complete_path) 
     47      begin 
     48        document = PlainTextExtractor.extract_content_and_language_from(complete_path) 
     49        raise "empty document #{complete_path}" if document[:content].strip.empty? 
     50        document.merge! default_fields 
     51        log :debug => ["Added : #{complete_path}",document[:language] ? " (#{document[:language]})" : ""].join 
     52      rescue => e 
     53        log :debug => "\tindexing without content: #{e.message}" 
     54        document = default_fields 
    9255      end 
    93       add_file(complete_path) if should_be_added 
     56      writer << document 
    9457    end 
    9558     
    96     def add_file(complete_path) 
    97       log :debug => "Adding #{complete_path}" 
    98       mime_type=File.mime(complete_path) 
    99       fields = fields_for(complete_path) 
    100        
    101       begin  
    102         text, lang = PlainTextExtractor.extract_content_and_language_from(complete_path) 
    103         raise "\tempty document #{complete_path}" if text.strip.empty? 
    104         fields[:content] = text 
    105         log :debug => "language found: #{lang}" if lang 
    106         fields[:lang] = lang 
    107       rescue => e 
    108         log :debug => "\tindexing without content: #{e.message}" 
    109       end 
    110        
    111       writer << fields 
     59    # Ensures writer is closed, and removes every index file for RAILS_ENV. 
     60    def clear!(all=false) 
     61      close 
     62      to_remove=all ? Picolena::IndexesSavePath : Picolena::IndexSavePath 
     63      Dir.glob(File.join(to_remove,'**/*')).each{|f| FileUtils.rm(f) if File.file?(f)} 
    11264    end 
    11365     
    114     def writer 
    115       @@writer ||= IndexWriter.new 
     66    # Closes the writer and 
     67    # ensures that a new IndexWriter is instantiated next time writer is called. 
     68    def close 
     69      @@writer.close rescue nil 
     70      # Ferret will SEGFAULT otherwise. 
     71      @@writer = nil 
    11672    end 
    11773     
    118     def reader 
    119       @@reader ||= IndexReader.new 
     74    # Only one IndexWriter should be instantiated. 
     75    # If one already exists, returns it. 
     76    # Creates it otherwise. 
     77    def writer 
     78      @@writer ||= Ferret::Index::IndexWriter.new(default_index_params) 
    12079    end 
    12180     
    122     def reset! 
    123       log :debug => "Resetting Index" 
    124       @@writer=nil 
    125       @@reader=nil 
    126       IndexWriter.remove 
     81    def index 
     82      Ferret::Index::Index.new(default_index_params)   
    12783    end 
    12884     
    129     def delete_file(complete_path) 
    130       log :debug => "\tRemoving from index" 
    131       reader.delete_by_complete_path(complete_path) 
     85    def ensure_index_existence 
     86      index_every_directory(:remove_first) unless index_exists? or RAILS_ENV=="production" 
    13287    end 
    13388     
    13489    private 
    13590     
     91    def index_exists? 
     92      index_filename and File.exists?(index_filename) 
     93    end 
     94     
     95    def index_filename 
     96      Dir.glob(File.join(Picolena::IndexSavePath,'*.cfs')).first 
     97    end 
     98 
    13699    def log(hash) 
    137100      hash.each{|level,message| 
    138101        IndexerLogger.send(level,message) 
    139102      } 
    140     end   
     103    end 
     104     
     105    def default_index_params 
     106      {:path => Picolena::IndexSavePath, :analyzer => Picolena::Analyzer, :field_infos => default_field_infos} 
     107    end 
     108     
     109    def default_field_infos 
     110      returning Ferret::Index::FieldInfos.new do |field_infos| 
     111        field_infos.add_field(:complete_path,      :store => :yes, :index => :untokenized) 
     112        field_infos.add_field(:content,            :store => :yes, :index => :yes) 
     113        field_infos.add_field(:basename,           :store => :no,  :index => :yes, :boost => 1.5) 
     114        field_infos.add_field(:filename,           :store => :no,  :index => :yes, :boost => 1.5) 
     115        field_infos.add_field(:filetype,           :store => :no,  :index => :yes, :boost => 1.5) 
     116        field_infos.add_field(:modified,           :store => :yes, :index => :untokenized) 
     117        field_infos.add_field(:probably_unique_id, :store => :no,  :index => :yes) 
     118        field_infos.add_field(:language,           :store => :yes, :index => :yes) 
     119      end 
     120    end 
    141121  end 
    142122end 
  • trunk/lib/picolena/templates/app/models/plain_text_extractor.rb

    r219 r247  
    110110  def extract_content_and_language 
    111111    content=extract_content 
    112     return [content, nil] unless [# Is LanguageRecognition turned on? (cf config/custom/picolena.rb) 
    113                                   Picolena::UseLanguageRecognition, 
    114                                   # Is a language guesser already installed? 
    115                                   PlainTextExtractor.language_guesser, 
    116                                   # Language recognition is too unreliable for small files. 
    117                                   content.size > 500].all? 
     112    return {:content => content} unless [# Is LanguageRecognition turned on? (cf config/custom/picolena.rb) 
     113                                         Picolena::UseLanguageRecognition, 
     114                                         # Is a language guesser already installed? 
     115                                         PlainTextExtractor.language_guesser, 
     116                                         # Language recognition is too unreliable for small files. 
     117                                         content.size > 500].all? 
    118118    language=IO.popen(PlainTextExtractor.language_guesser,'w+'){|lang_guesser| 
    119119      lang_guesser.write content 
     
    126126      end 
    127127    } 
    128     [content,language] 
     128    {:content => content, :language => language} 
    129129  end 
    130130end 
  • trunk/lib/picolena/templates/app/models/query.rb

    r177 r247  
    1414       /\b#{:OR.l}\b/=>'OR', 
    1515       /\b#{:NOT.l}\b/=>'NOT', 
     16       /(#{:filename.l}):/=>'filename:', 
    1617       /(#{:filetype.l}):/=>'filetype:', 
    1718       /#{:content.l}:/ => 'content:', 
    18        /#{:date.l}:/ => 'date:', 
     19       /(#{:modified.l}):/ => 'modified:', 
     20       /(#{:language.l}):/ => 'language:', 
    1921       /\b#{:LIKE.l}\s+(\S+)/=>'\1~' 
    2022      } 
     
    2628    # Instantiates a QueryParser once, and keeps it in cache. 
    2729    def parser 
    28       @@parser ||= Ferret::QueryParser.new(:fields => [:content, :file, :basename, :filetype, :date], :or_default => false, :analyzer=>Picolena::Analyzer) 
     30      @@parser ||= Ferret::QueryParser.new(:fields => [:content, :filename, :basename, :filetype, :modified], :or_default => false, :analyzer=>Picolena::Analyzer) 
    2931    end 
    3032  end 
  • trunk/lib/picolena/templates/lang/ui/de.yml

    r128 r247  
    2020 
    2121## Fields 
     22filename: filename|file|datei 
    2223filetype: erweiterung|ext 
    2324content: inhalt 
    24 date: jahr|zeit 
     25modified: jahr|zeit|geÀndert 
     26language: lang|sprache 
  • trunk/lib/picolena/templates/lang/ui/en.yml

    r128 r247  
    2020 
    2121## Fields 
     22filename: filename|file 
    2223filetype: filetype|ext 
    2324content: content 
    24 date: year|date 
     25modified: year|date|modified 
     26language: lang|language 
  • trunk/lib/picolena/templates/lang/ui/es.yml

    r128 r247  
    2020 
    2121## Fields 
     22filename: filename|file|archivo 
    2223filetype: extensión|ext 
    2324content: contenido 
    24 date: fecha|año|anho 
     25modified: fecha|año|anho|modificado 
     26language: lang|idioma 
  • trunk/lib/picolena/templates/lang/ui/fr.yml

    r128 r247  
    2020 
    2121## Fields 
     22filename: filename|file|fichier 
    2223filetype: extension|ext 
    2324content: contenu 
    24 date: année|date|annee 
     25modified: année|date|annee|modifie 
     26language: lang|langue 
  • trunk/lib/picolena/templates/lib/tasks/index.rake

    r153 r247  
    33  desc 'Clear indexes' 
    44  task :clear => :environment do 
    5     IndexWriter.remove 
     5    Indexer.clear! :all 
    66  end 
    77   
    88  desc 'Create index' 
    99  task :create => :environment do 
    10     Indexer.index_every_directory(update=false) 
     10    Indexer.index_every_directory(remove_first=true) 
    1111  end 
    1212 
    1313  desc 'Update index' 
    1414  task :update => :environment do 
    15     Indexer.index_every_directory(update=true) 
     15    Indexer.index_every_directory 
    1616  end 
    1717   
  • trunk/lib/picolena/templates/spec/models/basic_finder_spec.rb

    r167 r247  
    1111   
    1212  before(:each) do 
    13     IndexWriter.remove 
     13    Indexer.clear! 
    1414  end 
    1515   
    1616  it "should create index" do 
    1717    Picolena::IndexedDirectories.replace({'spec/test_dirs/indexed/just_one_doc'=>'//justonedoc/'}) 
    18     lambda {@finder_with_new_index=Finder.new("test moi")}.should change(IndexReader, :exists?).from(false).to(true) 
     18    lambda {@finder_with_new_index=Finder.new("test moi")}.should change(Indexer, :index_exists?).from(false).to(true) 
    1919    File.exists?(File.join(@new_index_path,'_0.cfs')).should be_true 
    20     IndexReader.new.size.should >0 
     20    Indexer.index.size.should >0 
    2121  end 
    2222   
     
    3636fields={ 
    3737  # description => key 
    38   :content=>:content, 
    39   :basename=>:basename, 
    40   :filename=>:file, 
    41   :extension => :filetype, 
    42   :modification_time=>:date 
     38  :content            => :content, 
     39  :complete_path      => :complete_path, 
     40  :basename           => :basename, 
     41  :filename           => :filename, 
     42  :extension          => :filetype, 
     43  :modification_time  => :modified, 
     44  :probably_unique_id => :probably_unique_id, 
     45  :language           => :language 
    4346} 
    4447 
    4548describe "Basic Finder" do   
    4649  before(:all) do 
    47     Indexer.index_every_directory(update=false) 
     50    Indexer.index_every_directory(remove_first=true) 
    4851  end 
    4952   
     
    8386  fields.each_pair do |description,field_name| 
    8487    it "should index #{description} as :#{field_name}" do 
    85       IndexReader.new.field_infos[field_name].should be_an_instance_of(Ferret::Index::FieldInfo) 
     88      Indexer.index.field_infos[field_name].should be_an_instance_of(Ferret::Index::FieldInfo) 
    8689    end 
    8790  end 
  • trunk/lib/picolena/templates/spec/models/finder_spec.rb

    r217 r247  
    2222    File.utime(0, a_bit_later, 'spec/test_dirs/indexed/yet_another_dir/office2003-word-template.dot') 
    2323    File.utime(0, nineties, 'spec/test_dirs/indexed/others/placeholder.txt') 
    24     Indexer.index_every_directory(update=false) 
     24    Indexer.index_every_directory(remove_first=true) 
    2525  end 
    2626   
     
    3131  end 
    3232   
    33   it "should find documents according to their filename when specified with file:query" do 
    34     Finder.new("file:crossed.text").matching_documents.collect{|d| d.content}.should include("txt inside!") 
     33  it "should find documents according to their filename when specified with file:query or filename:query" do 
     34    Finder.new("filename:crossed.text").matching_documents.collect{|d| d.content}.should include("txt inside!") 
    3535    Finder.new("file:crossed.txt").matching_documents.collect{|d| d.content}.should include("text inside!") 
    3636  end 
     
    4848   
    4949  it "should give a boost to basename, filename and filetype in index" do 
    50     index=IndexReader.new 
     50    index=Indexer.index 
    5151    index.field_infos[:basename].boost.should > 1.0 
    52     index.field_infos[:file].boost.should > 1.0 
     52    index.field_infos[:filename].boost.should > 1.0 
    5353    index.field_infos[:filetype].boost.should > 1.0 
    5454  end 
  • trunk/lib/picolena/templates/spec/models/plain_text_extractor_spec.rb

    r214 r247  
    33describe "PlainTextExtractors" do 
    44  before(:all) do 
    5     IndexReader.ensure_existence 
     5    Indexer.ensure_index_existence 
    66  end   
    77   
     
    3030   
    3131  it "should guess language when enough content is available" do 
    32     Document.new("spec/test_dirs/indexed/lang/goethe").lang.should == "de" 
    33     Document.new("spec/test_dirs/indexed/lang/shakespeare").lang.should == "en" 
    34     Document.new("spec/test_dirs/indexed/lang/lorca").lang.should == "es" 
    35     Document.new("spec/test_dirs/indexed/lang/hugo").lang.should == "fr" 
     32    Document.new("spec/test_dirs/indexed/lang/goethe").language.should == "de" 
     33    Document.new("spec/test_dirs/indexed/lang/shakespeare").language.should == "en" 
     34    Document.new("spec/test_dirs/indexed/lang/lorca").language.should == "es" 
     35    Document.new("spec/test_dirs/indexed/lang/hugo").language.should == "fr" 
    3636  end 
    3737   
    3838  it "should not try to guess language when file is too small" do 
    39     Document.new("spec/test_dirs/indexed/basic/hello.rb").lang.should be_empty 
    40     Document.new("spec/test_dirs/indexed/README").lang.should be_empty 
     39    Document.new("spec/test_dirs/indexed/basic/hello.rb").language.should be_nil 
     40    Document.new("spec/test_dirs/indexed/README").language.should be_nil 
    4141  end 
    4242end 
  • trunk/website/index.html

    r210 r247  
    3434    <div id="version" class="clickable" onclick='document.location = "http://rubyforge.org/projects/picolena"; return false'> 
    3535      <p>Get Version</p> 
    36       <a href="http://rubyforge.org/projects/picolena" class="numbers">0.1.4</a> 
     36      <a href="http://rubyforge.org/projects/picolena" class="numbers">0.1.5</a> 
    3737    </div> 
    3838    <h1>&#x2192; &#8216;picolena&#8217;</h1> 
     
    115115        <p>Comments are welcome. Send an email to <a href="mailto:eric_duminil@rubyforge.org">Eric Duminil</a> email via the <a href="http://groups.google.com/group/picolena">forum</a></p> 
    116116    <p class="coda"> 
    117       <a href="eric_duminil@rubyforge.org">Eric DUMINIL</a>, 9th April 2008<br> 
     117      <a href="eric_duminil@rubyforge.org">Eric DUMINIL</a>, 20th April 2008<br> 
    118118      Theme extended from <a href="http://rb2js.rubyforge.org/">Paul Battley</a>, 
    119119      by Daniel Cadenas via <a href="http://depgraph.rubyforge.org/">DepGraph</a>