Multiple changes.

- Remove the runtime dependency on rake-compiler.
 - Use #rb_str_new instead of #rb_str_new2, the hash character array
   isn't null terminated.
 - Add various safeguards for object instantiations.
 - Remove the 'threaded' options for messages, folding them into
   'archived'.  If archiving is enabled, so is threading.
 - Return nil for lookups from the list object instead of raising
   exceptions.
 - Open subject indexes with the proper encodings (thanks Michael
   Granger!)
 - Allow touching and unlinking files to operate on multiple paths
   at once, within a single safety() wrap.
This commit is contained in:
Mahlon E. Smith 2017-05-16 13:58:34 -07:00
parent c99bdfe747
commit 3871084daa
17 changed files with 257 additions and 110 deletions

View file

@ -2,3 +2,4 @@ Session.vim
pkg/* pkg/*
docs/* docs/*
tmp/* tmp/*
lib/ezmlm/hash.so

View file

@ -37,10 +37,6 @@ be a generic interface for parsing and browsing list content.
$ gem install ezmlm $ gem install ezmlm
## Usage
....
## TODO ## TODO

View file

@ -59,7 +59,7 @@ environment.)
s.required_ruby_version = '>= 2.1' s.required_ruby_version = '>= 2.1'
s.add_dependency 'mail', "~> 2.6" s.add_dependency 'mail', "~> 2.6"
s.add_dependency 'rake-compiler', "~> 1.0" s.add_development_dependency 'rake-compiler', "~> 1.0"
end end
Gem::PackageTask.new( spec ) do |pkg| Gem::PackageTask.new( spec ) do |pkg|

126
USAGE.md Normal file
View file

@ -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.

View file

@ -148,12 +148,12 @@ address( VALUE klass, VALUE email ) {
Check_Type( email, T_STRING ); Check_Type( email, T_STRING );
email = rb_str_plus( rb_str_new2("<"), email); email = rb_str_plus( rb_str_new2("<"), email );
input = StringValueCStr( email ); input = StringValueCStr( email );
makehash( input, strlen(input), hash ); makehash( input, strlen(input), hash );
return rb_str_new2( hash ); return rb_str_new( hash, 20 );
} }

View file

@ -1,8 +1,15 @@
#!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4: # vim: set nosta noet ts=4 sw=4:
#
# A Ruby interface to the ezmlm-idx mailing list system. # 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 # == Version
# #
# $Id$ # $Id$

View file

@ -1,8 +1,12 @@
#!/usr/bin/ruby #!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4: # vim: set nosta noet ts=4 sw=4:
#
# A Ruby interface to a single Ezmlm-idx mailing list directory. # A Ruby interface to a single Ezmlm-idx mailing list directory.
# #
# list = Ezmlm::List.new( '/path/to/listdir' )
#
#
# == Version # == Version
# #
# $Id$ # $Id$
@ -28,6 +32,9 @@ class Ezmlm::List
### ###
def initialize( listdir ) def initialize( listdir )
listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname ) 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 @listdir = listdir
end end
@ -219,27 +226,6 @@ class Ezmlm::List
end 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 ### Returns +true+ if the list is configured to respond
### to remote management requests. ### to remote management requests.
### ###
@ -380,21 +366,23 @@ class Ezmlm::List
### Returns +true+ if message archival is enabled. ### Returns +true+ if message archival is enabled.
### ###
def archived? 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 end
### Disable or enable message archiving (and indexing.) return test.all?( &:exist? )
end
### Disable or enable message archiving (and indexing/threading.)
### ###
def archive=( enable=true ) def archived=( enable=true )
if enable if enable
self.touch( 'archived' ) self.touch( 'archived', 'indexed', 'threaded' )
self.touch( 'indexed' )
else else
self.unlink( 'archived' ) self.unlink( 'archived', 'indexed', 'threaded' )
self.unlink( 'indexed' )
end end
end end
alias_method :archive, :archive= alias_method :archived, :archived=
### Returns +true+ if the message archive is accessible only to ### Returns +true+ if the message archive is accessible only to
### moderators. ### moderators.
@ -653,9 +641,8 @@ class Ezmlm::List
### Returns an individual message if archiving was enabled. ### Returns an individual message if archiving was enabled.
### ###
def message( message_id ) def message( message_id )
raise "Archiving is not enabled." unless self.archived?
raise "Message archive is empty." if self.message_count.zero? 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 end
### Lazy load each message ID as a Ezmlm::List::Message, ### 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+. ### Return a Thread object for the given +thread_id+.
### ###
def thread( thread_id ) def thread( thread_id )
raise "Archiving is not enabled." unless self.archived? return Ezmlm::List::Thread.new( self, thread_id ) rescue nil
return Ezmlm::List::Thread.new( self, thread_id )
end end
@ -680,9 +666,8 @@ class Ezmlm::List
### could also be an email address. ### could also be an email address.
### ###
def author( author_id ) def author( author_id )
raise "Archiving is not enabled." unless self.archived?
author_id = Ezmlm::Hash.address(author_id) if author_id.index( '@' ) 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 end
@ -700,7 +685,9 @@ class Ezmlm::List
index = archivedir + dir.to_s + 'index' index = archivedir + dir.to_s + 'index'
next unless index.exist? next unless index.exist?
index.each_line.lazy.slice_before( /^\d+:/ ).each do |message| index.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh|
fh.each_line.lazy.slice_before( /^\d+:/ ).each do |message|
match = message[0].match( /^(?<message_id>\d+): (?<thread_id>\w+)/ ) match = message[0].match( /^(?<message_id>\d+): (?<thread_id>\w+)/ )
next unless match next unless match
thread_id = match[ :thread_id ] thread_id = match[ :thread_id ]
@ -718,6 +705,7 @@ class Ezmlm::List
acc << metadata acc << metadata
end end
end end
end
return idx return idx
end end
@ -757,18 +745,25 @@ class Ezmlm::List
### Simply create an empty file, safely. ### Simply create an empty file, safely.
### ###
def touch( file ) def touch( *file )
self.write( 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 end
### Delete +file+ safely. ### Delete +file+ safely.
### ###
def unlink( file ) def unlink( *file )
file = self.listdir + file unless file.is_a?( Pathname )
return unless file.exist?
self.with_safety do 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
end end

View file

@ -1,6 +1,7 @@
#!/usr/bin/ruby #!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4: # vim: set nosta noet ts=4 sw=4:
#
# A collection of messages authored from a unique user. # A collection of messages authored from a unique user.
# #
# Note that Ezmlm uses the "real name" part of an address # Note that Ezmlm uses the "real name" part of an address
@ -27,12 +28,12 @@ class Ezmlm::List::Author
include Enumerable include Enumerable
### Instantiate a new list of messages given ### Instantiate a new list of messages given
### a +list+ and a +author_id+. ### a +list+ and an +author_id+.
### ###
def initialize( list, author_id ) def initialize( list, author_id )
raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir )
raise ArgumentError, "Malformed Author ID." unless author_id =~ /^\w{20}$/ 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 @list = list
@id = author_id @id = author_id
@ -67,6 +68,7 @@ class Ezmlm::List::Author
yield Ezmlm::List::Message.new( self.list, id ) yield Ezmlm::List::Message.new( self.list, id )
end end
end end
alias_method :each_message, :each
### Lazy load each thread ID as a Ezmlm::List::Thread, yielding it to the block. ### Lazy load each thread ID as a Ezmlm::List::Thread, yielding it to the block.
@ -92,7 +94,8 @@ class Ezmlm::List::Author
path = self.author_path path = self.author_path
raise "Unknown author: %p" % [ self.id ] unless path.exist? raise "Unknown author: %p" % [ self.id ] unless path.exist?
path.each_line.with_index do |line, i| path.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh|
fh.each_line.with_index do |line, i|
if i.zero? if i.zero?
@name = line.match( /^\w+ (.+)/ )[1] @name = line.match( /^\w+ (.+)/ )[1]
else else
@ -102,6 +105,7 @@ class Ezmlm::List::Author
end end
end end
end end
end
### Return the path on disk for the author index. ### Return the path on disk for the author index.

View file

@ -1,6 +1,7 @@
#!/usr/bin/ruby #!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4: # vim: set nosta noet ts=4 sw=4:
#
# An individual list message. # An individual list message.
# #
# message = Ezmlm::List::Message.new( list, 24 ) # message = Ezmlm::List::Message.new( list, 24 )
@ -31,6 +32,7 @@ class Ezmlm::List::Message
def initialize( list, message_number=0 ) def initialize( list, message_number=0 )
raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir )
raise ArgumentError, "Invalid message number (impossible)" if message_number < 1 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 raise ArgumentError, "Invalid message number (out of list bounds)" if message_number > list.message_count
@list = list @list = list

View file

@ -1,6 +1,7 @@
#!/usr/bin/ruby #!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4: # vim: set nosta noet ts=4 sw=4:
#
# A collection of messages for a specific archive thread. # A collection of messages for a specific archive thread.
# #
# thread = Ezmlm::List::Thread.new( list, 'acgcbmbmeapgpfckcdol' ) # thread = Ezmlm::List::Thread.new( list, 'acgcbmbmeapgpfckcdol' )
@ -29,7 +30,7 @@ class Ezmlm::List::Thread
def initialize( list, thread_id ) def initialize( list, thread_id )
raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir )
raise ArgumentError, "Malformed Thread ID." unless thread_id =~ /^\w{20}$/ 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 @list = list
@id = thread_id @id = thread_id
@ -65,6 +66,18 @@ class Ezmlm::List::Thread
yield Ezmlm::List::Message.new( self.list, id ) yield Ezmlm::List::Message.new( self.list, id )
end end
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,7 +92,8 @@ class Ezmlm::List::Thread
path = self.thread_path path = self.thread_path
raise "Unknown thread: %p" % [ self.id ] unless path.exist? raise "Unknown thread: %p" % [ self.id ] unless path.exist?
path.each_line.with_index do |line, i| path.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh|
fh.each_line.with_index do |line, i|
if i.zero? if i.zero?
@subject = line.match( /^\w+ (.+)/ )[1] @subject = line.match( /^\w+ (.+)/ )[1]
else else
@ -89,6 +103,7 @@ class Ezmlm::List::Thread
end end
end end
end end
end
### Return the path on disk for the thread index. ### Return the path on disk for the thread index.

View file

@ -20,7 +20,7 @@
7 May 2017 21:55:05 -0000;gclofnmfhpbehngoppdg Amalia Purdy 7 May 2017 21:55:05 -0000;gclofnmfhpbehngoppdg Amalia Purdy
11: bhfejoliggjidmkbclnn Parsing panel for a hard drive????? 11: bhfejoliggjidmkbclnn Parsing panel for a hard drive?????
7 May 2017 21:55:05 -0000;decddajmifhkgodaginh Sally Pagac 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 7 May 2017 21:55:05 -0000;kdlcjeacpilheebkcbaf Wayne Friesen
13: mipieokohgoiigideadf Generating circuit for a application????? 13: mipieokohgoiigideadf Generating circuit for a application?????
7 May 2017 21:55:05 -0000;ojjhjlapnejjlbcplabi Jena Smitham 7 May 2017 21:55:05 -0000;ojjhjlapnejjlbcplabi Jena Smitham

View file

@ -0,0 +1 @@
contact testlist-help@lists.laika.com; run by ezmlm

View file

@ -28,11 +28,11 @@ describe Ezmlm::List::Author do
}.to raise_error( ArgumentError, /unknown list/i ) }.to raise_error( ArgumentError, /unknown list/i )
end end
it 'raises error if thread indexing is disabled' do it 'raises error if thread archiving is disabled' do
expect( list ).to receive( :threaded? ).and_return( false ) expect( list ).to receive( :archived? ).and_return( false )
expect { expect {
described_class.new( list, author_id ) 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 end
it 'raises error if passed a malformed author ID' do 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 it 'raises error when unable to read index file' do
allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) 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 { expect {
described_class.new( list, author_id ) described_class.new( list, author_id )
}.to raise_error( RuntimeError, /unknown author/i ) }.to raise_error( RuntimeError, /unknown author/i )

View file

@ -44,6 +44,7 @@ describe Ezmlm::List::Message do
it 'raises error when unable to read message' do it 'raises error when unable to read message' do
allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) allow( list ).to receive( :listdir ).and_return( Pathname('/nope') )
expect( list ).to receive( :message_count ).and_return( 1 ) expect( list ).to receive( :message_count ).and_return( 1 )
expect( list ).to receive( :archived? ).and_return( true )
expect { expect {
described_class.new( list, 1 ) described_class.new( list, 1 )
}.to raise_error( RuntimeError, /unable to determine message path/i ) }.to raise_error( RuntimeError, /unable to determine message path/i )

View file

@ -29,10 +29,10 @@ describe Ezmlm::List::Thread do
end end
it 'raises error if thread indexing is disabled' do 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 { expect {
described_class.new( list, thread_id ) 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 end
it 'raises error if passed a malformed thread ID' do 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 it 'raises error when unable to read thread file' do
allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) 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 { expect {
described_class.new( list, thread_id ) described_class.new( list, thread_id )
}.to raise_error( RuntimeError, /unknown thread/i ) }.to raise_error( RuntimeError, /unknown thread/i )

View file

@ -113,16 +113,6 @@ describe Ezmlm::List do
end 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 it 'returns the current public/private state' do
expect( list.public? ).to be_truthy expect( list.public? ).to be_truthy
expect( list.private? ).to be_falsey expect( list.private? ).to be_falsey
@ -200,9 +190,9 @@ describe Ezmlm::List do
it 'can set archival status' do it 'can set archival status' do
expect( list.archived? ).to be_truthy expect( list.archived? ).to be_truthy
list.archive = false list.archived = false
expect( list.archived? ).to be_falsey expect( list.archived? ).to be_falsey
list.archive = true list.archived = true
expect( list.archived? ).to be_truthy expect( list.archived? ).to be_truthy
end end
@ -336,6 +326,10 @@ describe Ezmlm::List do
expect( list.thread('cadgeokhhaieijmndokb') ).to be_a( Ezmlm::List::Thread ) expect( list.thread('cadgeokhhaieijmndokb') ).to be_a( Ezmlm::List::Thread )
end 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 it 'fetches author objects upon request' do
expect( list.author('ojjhjlapnejjlbcplabi') ).to be_a( Ezmlm::List::Author ) 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 ) expect( list.author('yvette@example.net').name ).to eq( author.name )
end end
it 'returns nil when fetching an invalid author' do
expect( list.author('whatever') ).to be_nil
end
context 'fetching messages' do 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 ).to receive( :archived? ).and_return( false )
expect { expect( list.message(1) ).to be_nil
list.message( 1 ) end
}.to raise_error( RuntimeError, /archiving is not enabled/i )
it 'returns nil when fetching an invalid message id' do
expect( list.message(2389234) ).to be_nil
end end
it 'raises an error if the message archive is empty' do it 'raises an error if the message archive is empty' do

View file

@ -2,7 +2,6 @@
require 'simplecov' if ENV['COVERAGE'] require 'simplecov' if ENV['COVERAGE']
require 'rspec' require 'rspec'
require 'loggability/spechelpers'
require 'fileutils' require 'fileutils'
require_relative '../lib/ezmlm' require_relative '../lib/ezmlm'