diff --git a/.hgignore b/.hgignore index 409dac2..aae363e 100644 --- a/.hgignore +++ b/.hgignore @@ -2,3 +2,4 @@ Session.vim pkg/* docs/* tmp/* +lib/ezmlm/hash.so diff --git a/README.md b/README.md index 08dc394..80523ea 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,6 @@ be a generic interface for parsing and browsing list content. $ gem install ezmlm -## Usage - - .... - ## TODO diff --git a/Rakefile b/Rakefile index e6d612f..da4a692 100644 --- a/Rakefile +++ b/Rakefile @@ -59,7 +59,7 @@ environment.) s.required_ruby_version = '>= 2.1' s.add_dependency 'mail', "~> 2.6" - s.add_dependency 'rake-compiler', "~> 1.0" + s.add_development_dependency 'rake-compiler', "~> 1.0" end Gem::PackageTask.new( spec ) do |pkg| diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..5b5c9f7 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,126 @@ + +Usage +======= + +Here's a quick rundown of how to use this library. For specifics, see +the generated RDoc. + + +Examples +-------- + + +*Print the list address for all lists in a directory*: + + Ezmlm.each_list( '/lists' ) do |list| + puts list.address + end + + +*Check if I'm subscribed to a list, and if so, unsubscribe*: + +(You don't really have to check first, subscribe and unsubscribe are +idempotent.) + + list = Ezmlm::List.new( '/lists/waffle-lovers' ) + + if list.include?( 'mahlon@martini.nu' ) + list.unsubscribe( 'mahlon@martini.nu' ) + end + + puts "The list now has %d subscribers!" % [ list.subscribers.size ] + + +*Iterate over the subscriber list*: + + list.subscribers.each do |subscriber| + # ... + end + + +*Make the list moderated, and add a moderator*: + + list.moderated = true + list.add_moderator( 'mahlon@martini.nu' ) + list.moderated? #=> true + +All other list behavior tunables operate in a similar fashion, see RDoc +for details. + + +*Archiving!* + +All of the archival pieces take advantage of Ezmlm-IDX extensions. +If you want to use these features, you'll want to enable archiving +and indexing for your lists, using the -a and -i flags to ezmlm-make. +(Enabling archiving with this library also enables indexing and thread +indexes, I assume that since you're using ezmlm-idx, you want these +enhancements!) + + list.archived? #=> false + list.archived = true + list.archived? #=> true + +If your list(s) already had archiving enabled (the default to +ezmlm-make) but not indexing, you can manually run ezmlm-archive to +rebuild the necessary files - afterwards, they are kept up to date +automatically. + + +*How many messages are in the archive?*: + + list.message_count #=> 123 + + +*Fetch message number 100 from the archive*: + + message = list.message( 100 ) or abort "No such message." + + puts message.subject + puts message.body.to_s # Print just the body of the message. + puts message.to_s # Print the entire, unparsed message. + + thread = message.thread # Returns an Ezmlm::List::Thread object + author = message.author # Returns an Ezmlm::List::Author object + +As a general rule, methods called on the Ezmlm::List object return nil +if they are unable to perform the requested task. Instantiating the +underlying objects directly raise with a specific error. The following +are equivalent, but behave differently: + + message = list.message( 10000 ) # nonexistent message, returns nil + message = Ezmlm::List::Message.new( list, 10000 ) # Raises a RuntimeError + +Message objects act as "Mail" objects from the excellent library from +Mikel Lindsaar (https://github.com/mikel/mail). See its documentation +for specifics. + + +*Iterate over messages in a specific thread*: + +Messages know what thread they belong to. Once you have a thread object +from a message, it is an enumerable. Iterate or sort on it using +standard Ruby methods. + + thread.each do |message| + # ... + end + +Threads are also aware of who participated in the conversation, via the +'authors' and 'each_author' methods. + + +*Iterate over messages from a specific author:* + +Messages know who authored them. Once you have an author object from a +message, it is an enumerable. Iterate or sort on it using standard Ruby +methods. + + author.each do |message| + # ... + end + +An Author object is also aware of all threads the author participated +in, via the 'threads' and 'each_thread' methods. + + diff --git a/ext/ezmlm/hash/hash.c b/ext/ezmlm/hash/hash.c index 7c913b8..ff7cd94 100644 --- a/ext/ezmlm/hash/hash.c +++ b/ext/ezmlm/hash/hash.c @@ -148,12 +148,12 @@ address( VALUE klass, VALUE email ) { Check_Type( email, T_STRING ); - email = rb_str_plus( rb_str_new2("<"), email); + email = rb_str_plus( rb_str_new2("<"), email ); input = StringValueCStr( email ); makehash( input, strlen(input), hash ); - return rb_str_new2( hash ); + return rb_str_new( hash, 20 ); } diff --git a/lib/ezmlm.rb b/lib/ezmlm.rb index e5848f5..1c63535 100644 --- a/lib/ezmlm.rb +++ b/lib/ezmlm.rb @@ -1,8 +1,15 @@ -#!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # A Ruby interface to the ezmlm-idx mailing list system. # +# Ezmlm.find_directories( '/lists' ) #=> [ Ezmlm::List, Ezmlm::List ] +# +# Ezmlm.each_list( '/lists' ) do |list| +# puts "\"%s\" <%s>" % [ list.name, list.address ] +# end +# +# # == Version # # $Id$ diff --git a/lib/ezmlm/list.rb b/lib/ezmlm/list.rb index ece10a3..d88eec9 100644 --- a/lib/ezmlm/list.rb +++ b/lib/ezmlm/list.rb @@ -1,8 +1,12 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # A Ruby interface to a single Ezmlm-idx mailing list directory. # +# list = Ezmlm::List.new( '/path/to/listdir' ) +# +# # == Version # # $Id$ @@ -28,6 +32,9 @@ class Ezmlm::List ### def initialize( listdir ) listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname ) + unless listdir.directory? && ( listdir + 'mailinglist' ).exist? + raise ArgumentError, "%p doesn't appear to be an ezmlm-idx list." % [ listdir.to_s ] + end @listdir = listdir end @@ -219,27 +226,6 @@ class Ezmlm::List end - ### Returns +true+ if message threading is enabled. - ### - def threaded? - return ( self.listdir + 'threaded' ).exist? - end - - ### Disable or enable message threading. - ### - ### This automatically builds message indexes and thread - ### information on an incoming message. - ### - def threaded=( enable=true ) - if enable - self.touch( 'threaded' ) - else - self.unlink( 'threaded' ) - end - end - alias_method :threaded, :threaded= - - ### Returns +true+ if the list is configured to respond ### to remote management requests. ### @@ -380,21 +366,23 @@ class Ezmlm::List ### Returns +true+ if message archival is enabled. ### def archived? - return ( self.listdir + 'archived' ).exist? || ( self.listdir + 'indexed' ).exist? + test = %w[ archived indexed threaded ].each_with_object( [] ) do |f, acc| + acc << self.listdir + f + end + + return test.all?( &:exist? ) end - ### Disable or enable message archiving (and indexing.) + ### Disable or enable message archiving (and indexing/threading.) ### - def archive=( enable=true ) + def archived=( enable=true ) if enable - self.touch( 'archived' ) - self.touch( 'indexed' ) + self.touch( 'archived', 'indexed', 'threaded' ) else - self.unlink( 'archived' ) - self.unlink( 'indexed' ) + self.unlink( 'archived', 'indexed', 'threaded' ) end end - alias_method :archive, :archive= + alias_method :archived, :archived= ### Returns +true+ if the message archive is accessible only to ### moderators. @@ -653,9 +641,8 @@ class Ezmlm::List ### Returns an individual message if archiving was enabled. ### def message( message_id ) - raise "Archiving is not enabled." unless self.archived? raise "Message archive is empty." if self.message_count.zero? - return Ezmlm::List::Message.new( self, message_id ) + return Ezmlm::List::Message.new( self, message_id ) rescue nil end ### Lazy load each message ID as a Ezmlm::List::Message, @@ -671,8 +658,7 @@ class Ezmlm::List ### Return a Thread object for the given +thread_id+. ### def thread( thread_id ) - raise "Archiving is not enabled." unless self.archived? - return Ezmlm::List::Thread.new( self, thread_id ) + return Ezmlm::List::Thread.new( self, thread_id ) rescue nil end @@ -680,9 +666,8 @@ class Ezmlm::List ### could also be an email address. ### def author( author_id ) - raise "Archiving is not enabled." unless self.archived? author_id = Ezmlm::Hash.address(author_id) if author_id.index( '@' ) - return Ezmlm::List::Author.new( self, author_id ) + return Ezmlm::List::Author.new( self, author_id ) rescue nil end @@ -700,22 +685,25 @@ class Ezmlm::List index = archivedir + dir.to_s + 'index' next unless index.exist? - index.each_line.lazy.slice_before( /^\d+:/ ).each do |message| - match = message[0].match( /^(?\d+): (?\w+)/ ) - next unless match - thread_id = match[ :thread_id ] + index.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh| + fh.each_line.lazy.slice_before( /^\d+:/ ).each do |message| - match = message[1].match( /^(?[^;]+);(?\w+) / ) - next unless match - author_id = match[ :author_id ] - date = match[ :date ] + match = message[0].match( /^(?\d+): (?\w+)/ ) + next unless match + thread_id = match[ :thread_id ] - metadata = { - date: Time.parse( date ), - thread: thread_id, - author: author_id - } - acc << metadata + match = message[1].match( /^(?[^;]+);(?\w+) / ) + next unless match + author_id = match[ :author_id ] + date = match[ :date ] + + metadata = { + date: Time.parse( date ), + thread: thread_id, + author: author_id + } + acc << metadata + end end end @@ -757,18 +745,25 @@ class Ezmlm::List ### Simply create an empty file, safely. ### - def touch( file ) - self.write( file ) {} + def touch( *file ) + self.with_safety do + Array( file ).flatten.each do |f| + f = self.listdir + f unless f.is_a?( Pathname ) + f.open( 'w' ) {} + end + end end ### Delete +file+ safely. ### - def unlink( file ) - file = self.listdir + file unless file.is_a?( Pathname ) - return unless file.exist? + def unlink( *file ) self.with_safety do - file.unlink + Array( file ).flatten.each do |f| + f = self.listdir + f unless f.is_a?( Pathname ) + next unless f.exist? + f.unlink + end end end diff --git a/lib/ezmlm/list/author.rb b/lib/ezmlm/list/author.rb index 007b9c1..561e517 100644 --- a/lib/ezmlm/list/author.rb +++ b/lib/ezmlm/list/author.rb @@ -1,6 +1,7 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # A collection of messages authored from a unique user. # # Note that Ezmlm uses the "real name" part of an address @@ -27,12 +28,12 @@ class Ezmlm::List::Author include Enumerable ### Instantiate a new list of messages given - ### a +list+ and a +author_id+. + ### a +list+ and an +author_id+. ### def initialize( list, author_id ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Malformed Author ID." unless author_id =~ /^\w{20}$/ - raise "Thread indexing is not enabled." unless list.threaded? + raise "Archiving is not enabled." unless list.archived? @list = list @id = author_id @@ -67,6 +68,7 @@ class Ezmlm::List::Author yield Ezmlm::List::Message.new( self.list, id ) end end + alias_method :each_message, :each ### Lazy load each thread ID as a Ezmlm::List::Thread, yielding it to the block. @@ -92,13 +94,15 @@ class Ezmlm::List::Author path = self.author_path raise "Unknown author: %p" % [ self.id ] unless path.exist? - path.each_line.with_index do |line, i| - if i.zero? - @name = line.match( /^\w+ (.+)/ )[1] - else - match = line.match( /^(\d+):\d+:(\w+) / ) or next - self.messages << match[1].to_i - self.threads << match[2] + path.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh| + fh.each_line.with_index do |line, i| + if i.zero? + @name = line.match( /^\w+ (.+)/ )[1] + else + match = line.match( /^(\d+):\d+:(\w+) / ) or next + self.messages << match[1].to_i + self.threads << match[2] + end end end end diff --git a/lib/ezmlm/list/message.rb b/lib/ezmlm/list/message.rb index 88bdccf..77e2d6e 100644 --- a/lib/ezmlm/list/message.rb +++ b/lib/ezmlm/list/message.rb @@ -1,6 +1,7 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # An individual list message. # # message = Ezmlm::List::Message.new( list, 24 ) @@ -31,6 +32,7 @@ class Ezmlm::List::Message def initialize( list, message_number=0 ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Invalid message number (impossible)" if message_number < 1 + raise "Archiving is not enabled." unless list.archived? raise ArgumentError, "Invalid message number (out of list bounds)" if message_number > list.message_count @list = list diff --git a/lib/ezmlm/list/thread.rb b/lib/ezmlm/list/thread.rb index f59b106..e154a61 100644 --- a/lib/ezmlm/list/thread.rb +++ b/lib/ezmlm/list/thread.rb @@ -1,6 +1,7 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # A collection of messages for a specific archive thread. # # thread = Ezmlm::List::Thread.new( list, 'acgcbmbmeapgpfckcdol' ) @@ -29,7 +30,7 @@ class Ezmlm::List::Thread def initialize( list, thread_id ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Malformed Thread ID." unless thread_id =~ /^\w{20}$/ - raise "Thread indexing is not enabled." unless list.threaded? + raise "Archiving is not enabled." unless list.archived? @list = list @id = thread_id @@ -65,6 +66,18 @@ class Ezmlm::List::Thread yield Ezmlm::List::Message.new( self.list, id ) end end + alias_method :each_message, :each + + + ### Lazy load each author ID as a Ezmlm::List::Author, yielding it + ### to the block. + ### + def each_author + self.load_thread # refresh for any thread updates since object was created + self.authors.each do |id| + yield Ezmlm::List::Author.new( self.list, id ) + end + end ######### @@ -79,13 +92,15 @@ class Ezmlm::List::Thread path = self.thread_path raise "Unknown thread: %p" % [ self.id ] unless path.exist? - path.each_line.with_index do |line, i| - if i.zero? - @subject = line.match( /^\w+ (.+)/ )[1] - else - match = line.match( /^(\d+):\d+:(\w+) / ) or next - self.messages << match[1].to_i - self.authors << match[2] + path.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh| + fh.each_line.with_index do |line, i| + if i.zero? + @subject = line.match( /^\w+ (.+)/ )[1] + else + match = line.match( /^(\d+):\d+:(\w+) / ) or next + self.messages << match[1].to_i + self.authors << match[2] + end end end end diff --git a/spec/data/testlist/archive/0/index b/spec/data/testlist/archive/0/index index 3d732c0..c38e2c8 100755 --- a/spec/data/testlist/archive/0/index +++ b/spec/data/testlist/archive/0/index @@ -20,7 +20,7 @@ 7 May 2017 21:55:05 -0000;gclofnmfhpbehngoppdg Amalia Purdy 11: bhfejoliggjidmkbclnn Parsing panel for a hard drive????? 7 May 2017 21:55:05 -0000;decddajmifhkgodaginh Sally Pagac -12: fbhfcpngckkjbhlfjooh Trying to override SCSI on the microchip. +12: fbhfcpngckkjbhlfjooh Trying to override SCSI’s on the microchip. 7 May 2017 21:55:05 -0000;kdlcjeacpilheebkcbaf Wayne Friesen 13: mipieokohgoiigideadf Generating circuit for a application????? 7 May 2017 21:55:05 -0000;ojjhjlapnejjlbcplabi Jena Smitham diff --git a/spec/data/testlist/mailinglist b/spec/data/testlist/mailinglist new file mode 100644 index 0000000..b1a5095 --- /dev/null +++ b/spec/data/testlist/mailinglist @@ -0,0 +1 @@ +contact testlist-help@lists.laika.com; run by ezmlm diff --git a/spec/ezmlm/list/author_spec.rb b/spec/ezmlm/list/author_spec.rb index ddacfe6..25fb329 100644 --- a/spec/ezmlm/list/author_spec.rb +++ b/spec/ezmlm/list/author_spec.rb @@ -28,11 +28,11 @@ describe Ezmlm::List::Author do }.to raise_error( ArgumentError, /unknown list/i ) end - it 'raises error if thread indexing is disabled' do - expect( list ).to receive( :threaded? ).and_return( false ) + it 'raises error if thread archiving is disabled' do + expect( list ).to receive( :archived? ).and_return( false ) expect { described_class.new( list, author_id ) - }.to raise_error( RuntimeError, /indexing is not enabled/i ) + }.to raise_error( RuntimeError, /archiving is not enabled/i ) end it 'raises error if passed a malformed author ID' do @@ -43,7 +43,7 @@ describe Ezmlm::List::Author do it 'raises error when unable to read index file' do allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) - expect( list ).to receive( :threaded? ).and_return( true ) + expect( list ).to receive( :archived? ).and_return( true ) expect { described_class.new( list, author_id ) }.to raise_error( RuntimeError, /unknown author/i ) diff --git a/spec/ezmlm/list/message_spec.rb b/spec/ezmlm/list/message_spec.rb index 27b8992..8aa8295 100644 --- a/spec/ezmlm/list/message_spec.rb +++ b/spec/ezmlm/list/message_spec.rb @@ -44,6 +44,7 @@ describe Ezmlm::List::Message do it 'raises error when unable to read message' do allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) expect( list ).to receive( :message_count ).and_return( 1 ) + expect( list ).to receive( :archived? ).and_return( true ) expect { described_class.new( list, 1 ) }.to raise_error( RuntimeError, /unable to determine message path/i ) diff --git a/spec/ezmlm/list/thread_spec.rb b/spec/ezmlm/list/thread_spec.rb index d97de37..18ddda9 100644 --- a/spec/ezmlm/list/thread_spec.rb +++ b/spec/ezmlm/list/thread_spec.rb @@ -29,10 +29,10 @@ describe Ezmlm::List::Thread do end it 'raises error if thread indexing is disabled' do - expect( list ).to receive( :threaded? ).and_return( false ) + expect( list ).to receive( :archived? ).and_return( false ) expect { described_class.new( list, thread_id ) - }.to raise_error( RuntimeError, /indexing is not enabled/i ) + }.to raise_error( RuntimeError, /archiving is not enabled/i ) end it 'raises error if passed a malformed thread ID' do @@ -43,7 +43,7 @@ describe Ezmlm::List::Thread do it 'raises error when unable to read thread file' do allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) - expect( list ).to receive( :threaded? ).and_return( true ) + expect( list ).to receive( :archived? ).and_return( true ) expect { described_class.new( list, thread_id ) }.to raise_error( RuntimeError, /unknown thread/i ) diff --git a/spec/ezmlm/list_spec.rb b/spec/ezmlm/list_spec.rb index bf0e443..08d3619 100644 --- a/spec/ezmlm/list_spec.rb +++ b/spec/ezmlm/list_spec.rb @@ -113,16 +113,6 @@ describe Ezmlm::List do end - it 'returns the current threading state' do - expect( list.threaded? ).to be_truthy - end - - it 'can set the threading state' do - list.threaded = false - expect( list.threaded? ).to be_falsey - end - - it 'returns the current public/private state' do expect( list.public? ).to be_truthy expect( list.private? ).to be_falsey @@ -200,9 +190,9 @@ describe Ezmlm::List do it 'can set archival status' do expect( list.archived? ).to be_truthy - list.archive = false + list.archived = false expect( list.archived? ).to be_falsey - list.archive = true + list.archived = true expect( list.archived? ).to be_truthy end @@ -336,6 +326,10 @@ describe Ezmlm::List do expect( list.thread('cadgeokhhaieijmndokb') ).to be_a( Ezmlm::List::Thread ) end + it 'returns nil when fetching an invalid thread' do + expect( list.thread('whatever') ).to be_nil + end + it 'fetches author objects upon request' do expect( list.author('ojjhjlapnejjlbcplabi') ).to be_a( Ezmlm::List::Author ) @@ -346,13 +340,19 @@ describe Ezmlm::List do expect( list.author('yvette@example.net').name ).to eq( author.name ) end + it 'returns nil when fetching an invalid author' do + expect( list.author('whatever') ).to be_nil + end + context 'fetching messages' do - it 'raises an error if archiving is disabled' do + it 'returns nil if archiving is disabled' do expect( list ).to receive( :archived? ).and_return( false ) - expect { - list.message( 1 ) - }.to raise_error( RuntimeError, /archiving is not enabled/i ) + expect( list.message(1) ).to be_nil + end + + it 'returns nil when fetching an invalid message id' do + expect( list.message(2389234) ).to be_nil end it 'raises an error if the message archive is empty' do diff --git a/spec/spec_helpers.rb b/spec/spec_helpers.rb index 2112a55..6fe76e4 100644 --- a/spec/spec_helpers.rb +++ b/spec/spec_helpers.rb @@ -2,7 +2,6 @@ require 'simplecov' if ENV['COVERAGE'] require 'rspec' -require 'loggability/spechelpers' require 'fileutils' require_relative '../lib/ezmlm'