blob: 48810f9872b445535bd18b3e326192df635a8d9a [file] [log] [blame]
require_relative '../asf'
require 'time'
require 'tzinfo'
require 'tzinfo/data'
require 'digest/md5'
module ASF
#
# module which contains the Agenda class
#
module Board
end
end
#
# Class which contains a number of parsers.
#
class ASF::Board::Agenda
# mapping of agenda section numbers to section names
CONTENTS = {
'2.' => 'Roll Call',
'3A' => 'Minutes',
'4A' => 'Executive Officer',
'1' => 'Additional Officer',
'A' => 'Committee Reports',
'7A' => 'Special Orders',
'8.' => 'Discussion Items',
'8A' => 'Discussion Items',
'9.' => 'Action Items'
}
# Regex for start of officer reports (accounts for style differences in early agendas)
OFFICER_SEPARATOR = /^\s*4. (Executive )?Officer Reports/
@@parsers = []
# convenience method. If passed a file, will create an instance of this
# class and call the parse method on that object. If passed a block, will
# add that block to the list of parsers.
def self.parse(file=nil, quick=false, &block)
@@parsers << block if block
new.parse(file, quick) if file
end
# start with an empty list of sections. Sections are added and returned by
# calling the <tt>parse</tt> method.
def initialize
@sections = {}
end
# helper method to scan a section for a pattern. Regular expression named
# matches will be captured and the section will be added to <tt>@sections</tt>
# if a match is found.
def scan(text, pattern, &block)
# convert tabs to spaces
text.gsub!(/^(\t+)/) {|tabs| ' ' * (8*tabs.length)}
text.scan(pattern).each do |matches|
hash = Hash[pattern.names.zip(matches)]
yield hash if block
section = hash.delete('section')
section ||= hash.delete('attach')
if section
hash['approved'] &&= hash['approved'].strip.split(/[ ,]+/)
@sections[section] ||= {}
next if hash['text'] and @sections[section]['text']
@sections[section].merge!(hash)
end
end
end
# parse a board agenda file by passing it through each parser. Additionally,
# converts the file to utf-8, adds index markers for major sections, looks
# for flagged reports, and performs various minor cleanup actions.
#
# If <tt>quick</tt> is <tt>false</tt>, cross-checks with committee membership
# will be performed. This supports the board agenda tools's strategy to
# quickly display possibly stale and possible incomplete data and then to
# update the presentation using React.JS once later and/or more complete
# data is available.
#
# Returns a list of sections.
def parse(file, quick=false)
@file = file
@quick = quick
if not @file.valid_encoding?
filter = Proc.new {|c| c.unpack('U').first rescue 0xFFFD}
@file = @file.chars.map(&filter).pack('U*').force_encoding('utf-8')
end
@@parsers.each { |parser| instance_exec(&parser) }
# add index markers for major sections
CONTENTS.each do |section, index|
@sections[section][:index] = index if @sections[section]
end
# quick exit if none found -- non-standard format agenda
return [] if @sections.empty?
# look for flags
flagged_reports = Hash[@file[/ \d\. Committee Reports.*?\n\s+A\./m].
scan(/# (.*?) \[(.*)\]/)] rescue {}
president = @sections.values.find {|item| item['title'] == 'President'}
return [] unless president # quick exit if non-standard format agenda
pattach = president['report'][/\d+ through \d+\.$/]
# pattach is nil before https://whimsy.apache.org/board/minutes/Change_Officers_to_Serve_at_the_Direction_of_the_President.html
preports = Range.new(*pattach.scan(/\d+/)) if pattach
# cleanup text and comment whitespace, add flags
@sections.each do |section, hash|
text = hash['text'] || hash['report']
if text
text.sub!(/\A\s*\n/, '')
text.sub!(/\s+\Z/, '')
unindent = text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min || 1
text.gsub! /^ {#{unindent-1}}/, ''
end
text = hash['comments']
if text
text.sub!(/\A\s*\n/, '')
text.sub!(/\s+\Z/, '')
unindent = text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min || 1
text.gsub! /^ {#{unindent-1}}/, ''
end
# add flags
flags = flagged_reports[hash['title']]
hash['flagged_by'] = flags.split(', ') if flags
# mark president reports
hash['to'] = 'president' if preports && preports.include?(section)
end
unless @quick
# add roster and prior report link
whimsy = 'https://whimsy.apache.org'
@sections.each do |section, hash|
next unless section =~ /^(4[A-Z]|\d+|[A-Z][A-Z]?)$/
committee = ASF::Committee.find(hash['title'] ||= 'UNKNOWN')
unless section =~ /^4[A-Z]$/
hash['roster'] =
"#{whimsy}/roster/committee/#{CGI.escape committee.name}"
end
if section =~ /^[A-Z][A-Z]?$/
hash['stats'] = 'https://reporter.apache.org/wizard/statistics?' +
CGI.escape(committee.name)
end
hash['prior_reports'] = minutes(committee.display_name)
end
end
# add attach to section
@sections.each do |section, hash|
hash[:attach] = section
end
# look for missing titles
@sections.each do |section, hash|
hash['title'] ||= "UNKNOWN"
if hash['title'] == "UNKNOWN"
hash['warnings'] = ['unable to find attachment']
end
end
@sections.values
end
# provide a link to the collated minutes for a given report
def minutes(title)
"https://whimsy.apache.org/board/minutes/#{title.gsub(/\W/,'_')}"
end
# convert a PST/PDT time to UTC as a JavaScript integer
def timestamp(time)
date = @file[/(\w+ \d+, \d+)/]
tz = TZInfo::Timezone.get('America/Los_Angeles')
tz.local_to_utc(Time.parse("#{date} #{time}")).to_i * 1000
end
end
require_relative 'agenda/front'
require_relative 'agenda/minutes'
require_relative 'agenda/exec-officer'
require_relative 'agenda/attachments'
require_relative 'agenda/committee'
require_relative 'agenda/special'
require_relative 'agenda/discussion'
require_relative 'agenda/back'
require_relative 'agenda/summary'