blob: b1ca250217382a73f9b08736928f7d8739c71b3c [file] [log] [blame]
# This is the client model for an entire Agenda. Class methods refer to
# the agenda as a whole. Instance methods refer to an individual agenda
# item.
#
class Agenda
Vue.util.defineReactive @@index, []
@@etag = nil
@@digest = nil
Vue.util.defineReactive @@date, ''
Vue.util.defineReactive @@approved, '?'
@@color = 'blank'
# (re)-load an agenda, creating instances for each item, and linking
# each instance to their next and previous items.
def self.load(list, digest)
return unless list
before = @@index
@@digest = digest
@@index = []
prev = nil
list.each do |item|
item = Agenda.new(item)
item.prev = prev
prev.next = item if prev
prev = item
@@index << item
end
# remove president attachments from the normal flow
@@index.each do |pres|
match = (pres.title == 'President' and pres.text and pres.text.
match(/Additionally, please see Attachments (\d) through (\d)/))
next unless match
# find first and last president report; update shepherd along the way
first = last = nil
@@index.each do |item|
first = item if item.attach == match[1]
item._shepherd ||= pres.shepherd if first and !last
last = item if item.attach == match[2]
end
# remove president attachments from the normal flow
if first and last and not Minutes.started
first.prev.next = last.next
last.next.prev = first.prev
last.next.index = first.index
first.index = nil
last.next = pres
first.prev = pres
end
end
@@date = Date.new(@@index[0].timestamp).toISOString()[/(.*?)T/, 1]
Main.refresh()
Chat.agenda_change(before, @@index)
return @@index
end
# fetch agenda if etag is not supplied
def self.fetch(etag, digest)
if etag
@@etag = etag
elsif digest != @@digest or not @@etag
if PageCache.enabled
loaded = false
# if bootstrapping and cache is available, load it
if not digest
caches.open('board/agenda').then do |cache|
cache.match("../#{@@date}.json").then do |response|
if response
response.json().then do |json|
Agenda.load(json) unless loaded
Main.refresh()
end
end
end
end
end
# set fetch options: credentials and etag
options = {credentials: 'include'}
options['headers'] = {'If-None-Match' => @@etag} if @@etag
request = Request.new("../#{@@date}.json", options)
# perform fetch
fetch(request).then do |response|
if response and response.ok
loaded = true
# load response into the agenda
response.clone().json().then do |json|
@@etag = response.headers.get('etag')
Agenda.load(json)
Main.refresh()
end
# save response in the cache
caches.open('board/agenda').then do |cache|
cache.put(request, response)
end
end
end
else
# AJAX fallback
xhr = XMLHttpRequest.new()
xhr.open('GET', "../#{@@date}.json", true)
xhr.setRequestHeader('If-None-Match', @@etag) if @@etag
xhr.responseType = 'text'
def xhr.onreadystatechange()
if xhr.readyState==4 and xhr.status==200 and xhr.responseText!=''
@@etag = xhr.getResponseHeader('ETag')
Agenda.load(JSON.parse(xhr.responseText))
Main.refresh()
end
end
xhr.send()
end
end
@@digest = digest
end
# return the entire agenda
def self.index
@@index
end
# find an agenda item by path name
def self.find(path)
result = nil
path = path.gsub(/\W+/, '-')
@@index.each do |item|
result = item if item.href == path
end
return result
end
# initialize an entry by copying each JSON property to a class instance
# variable.
def initialize(entry)
entry.each_pair do |name, value|
self["_#{name}"] = value
end
end
# provide read-only access to a number of properties
attr_reader :attach, :title, :owner, :timestamp, :digest, :mtime
attr_reader :approved, :roster, :prior_reports, :stats, :people, :notes
attr_reader :chair_email, :mail_list, :warnings, :flagged_by
# provide read/write access to other properties
attr_accessor :index, :shepherd
attr_writer :color
def fulltitle
@fulltitle || @title
end
# override missing if minutes aren't present
def missing
if @missing
return true
elsif @attach =~ /^3\w$/
if Server.drafts.include? @text[/board_minutes_\w+.txt/]
return false
elsif Minutes.get(@title) == 'approved' or @title =~ /^Action/
return false
else
return true
end
else
return false
end
end
# report was marked as NOT accepted during the meeting
def rejected
Minutes.rejected and Minutes.rejected.include?(@title)
end
# PMC has missed two consecutive months
def nonresponsive
@notes and @notes.include? 'missing' and
@notes.sub(/^.*missing/, '').split(',').length >= 2
end
# extract (new) chair name from resolutions
def chair_name
if @chair
@people[@chair].name
end
end
# compute href by taking the title and replacing all non alphanumeric
# characters with dashes
def href
@title.gsub(/[^a-zA-Z0-9]+/, '-')
end
# return the text or report for the agenda item
def text
@text || @report
end
# return comments as an array of individual comments
def comments
splitComments(@comments)
end
# item's comments excluding comments that have been seen before
def unseen_comments
visible = []
seen = Pending.seen[@attach] || []
self.comments.each do |comment|
visible << comment unless seen.include? comment
end
return visible
end
# retrieve the pending comment (if any) associated with this agenda item
def pending
Pending.comments and Pending.comments[@attach]
end
# retrieve the action items associated with this agenda item
def actions
if @title == 'Action Items'
@actions
else
item = Agenda.find('Action-Items')
list = []
if item
item.actions.each {|action| list << action if action.pmc == @title}
end
list
end
end
def special_orders
items = []
if @attach =~ /^[A-Z]+$/
Agenda.index.each do |item|
items << item if item.attach =~ /^7\w/ and item.roster == @roster
end
end
return items
end
def ready_for_review(initials)
return defined?(@approved) && !self.missing &&
!@approved.include?(initials) &&
!(@flagged_by && @flagged_by.include?(initials))
end
# determine if this agenda was approved in a later meeting
def self.approved
@@approved = 'approved' unless defined? fetch
if @@approved == '?'
options = {month: 'long', day: 'numeric', year: 'numeric'}
date = Date.new(Agenda.file[/\d\d\d\d_\d\d_\d\d/].
gsub('_', '-') + 'T18:30:00.000Z').toLocaleString('en-US', options)
Server.agendas.each do |agenda|
next if agenda <= Agenda.file
url = "../#{agenda[/\d\d\d\d_\d\d_\d\d/].gsub('_', '-')}.json"
fetch(url, credentials: 'include').then do |response|
if response.ok
response.json().then do |agenda|
agenda.each do |item|
@@approved = item.minutes if item.title == date and item.minutes
end
end
end
end
end
@@approved = 'tabled'
end
return @@approved
end
# the default view to use for the agenda as a whole
def self.view
Index
end
# buttons to show on the index page
def self.buttons
list = [{button: Refresh}]
if not Minutes.complete
list << {form: Post, text: 'add item'}
elsif [:director, :secretary].include? User.role
list << {form: Summary} unless Minutes.summary_sent
end
if User.role == :secretary
if Agenda.approved == 'approved'
list << {form: PublishMinutes}
elsif Minutes.ready_to_post_draft
list << {form: DraftMinutes}
end
end
list
end
# the default banner color to use for the agenda as a whole
def self.color
@@color
end
def self.color=(color)
@@color = color
end
# fetch the start date
def self.date
@@date
end
# is today the meeting day?
def self.meeting_day
Date.new().toISOString().slice(0,10) >= @@date
end
# the default title for the agenda as a whole
def self.title
@@date
end
# the file associated with this agenda
def self.file
"board_agenda_#{@@date.gsub('-', '_')}.txt"
end
# get the digest of the file associated with this agenda
def self.digest
@@digest
end
# previous link for the agenda index page
def self.prev
result = {title: 'Help', href: 'help'}
Server.agendas.each do |agenda|
date = agenda[/(\d+_\d+_\d+)/, 1].gsub('_', '-')
if date < @@date and (result.title == 'Help' or date > result.title)
result = {title: date, href: "../#{date}/"}
end
end
result
end
# next link for the agenda index page
def self.next
result = {title: 'Help', href: 'help'}
Server.agendas.each do |agenda|
date = agenda[/(\d+_\d+_\d+)/, 1].gsub('_', '-')
if date > @@date and (result.title == 'Help' or date < result.title)
result = {title: date, href: "../#{date}/"}
end
end
result
end
# find the shortest match for shepherd name (example: Rich)
def self.shepherd
_shepherd = nil
firstname = User.firstname.downcase()
Agenda.index.each do |item|
if
item.shepherd and
firstname.start_with? item.shepherd.downcase() and
(not _shepherd or item.shepherd.length < _shepherd.length)
then
_shepherd = item.shepherd
end
end
return _shepherd
end
# summary
def self.summary
results = []
# committee reports
count = 0
link = nil
Agenda.index.each do |item|
if item.attach =~ /^[A-Z]+$/
count += 1
link ||= item.href
end
end
results << {color: 'available', count: count, href: link,
text: 'committee reports'}
# special orders
count = 0
link = nil
Agenda.index.each do |item|
if item.attach =~ /^7[A-Z]+$/
count += 1
link ||= item.href
end
end
results << {color: 'available', count: count, href: link,
text: 'special orders'}
# discussion items
count = 0
link = nil
Agenda.index.each do |item|
if item.attach =~ /^8[.A-Z]+$/
count += 1 unless item.attach == '8.' and not item.text
link ||= item.href
end
end
results << {color: 'available', count: count, href: link,
text: 'discussion items'}
# awaiting preapprovals
count = 0
Agenda.index.each do |item|
count += 1 if item.color == 'ready' and item.title != 'Action Items'
end
results << {color: 'ready', count: count, href: 'queue',
text: 'awaiting preapprovals'}
# flagged reports
count = 0
Agenda.index.each {|item| count += 1 if item.flagged_by}
results << {color: 'commented', count: count, href: 'flagged',
text: 'flagged reports'}
# missing reports
count = 0
Agenda.index.each {|item| count += 1 if item.missing}
results << {color: 'missing', count: count, href: 'missing',
text: 'missing reports'}
# rejected reports
count = 0
Agenda.index.each {|item| count += 1 if item.rejected}
if Minutes.started or count > 0
results << {color: 'missing', count: count, href: 'rejected',
text: 'not accepted'}
end
return results
end
#
# Methods on individual agenda items
#
# default view for an individual agenda item
def view
if @title == 'Action Items'
if @text or Minutes.started
ActionItems
else
SelectActions
end
elsif @title == 'Roll Call' and User.role == :secretary
RollCall
elsif @title == 'Adjournment' and User.role == :secretary
Adjournment
else
Report
end
end
# buttons and forms to show with this report
def buttons
list = []
unless (@attach !~ /^\d+$/ and @comments === undefined) or Minutes.complete
# some reports don't have comments
if self.pending
list << {form: AddComment, text: 'edit comment'}
else
list << {form: AddComment, text: 'add comment'}
end
end
list << {button: Attend} if @title == 'Roll Call'
if @attach =~ /^(\d+|7?[A-Z]+|4[A-Z]|8[.A-Z])$/
if User.role == :secretary or not Minutes.complete
unless Minutes.draft_posted
if @attach =~ /^8[.A-Z]/
if @attach =~ /^8[A-Z]/
list << {form: Post, text: 'edit item'}
elsif not text or @text.strip().empty?
list << {form: Post, text: 'post item'}
else
list << {form: Post, text: 'edit items'}
end
elsif self.missing
list << {form: Post, text: 'post report'}
elsif @attach =~ /^7\w/
list << {form: Post, text: 'edit resolution'}
else
list << {form: Post, text: 'edit report'}
end
end
end
end
if User.role == :director
unless self.missing or @comments === undefined or Minutes.complete
list << {button: Approve} if @attach =~ /^(3[A-Z]|\d+|[A-Z]+)$/
end
elsif User.role == :secretary
unless Minutes.draft_posted
if @attach =~ /^7\w/
list << {form: Vote}
elsif Minutes.get(@title)
list << {form: AddMinutes, text: 'edit minutes'}
elsif ['Call to order', 'Adjournment'].include? @title
list << {button: Timestamp}
else
list << {form: AddMinutes, text: 'add minutes'}
end
end
if @attach =~ /^3\w/
if
Minutes.get(@title) == 'approved' and
Server.drafts.include? @text[/board_minutes_\w+\.txt/]
then
list << {form: PublishMinutes}
end
elsif @title == 'Adjournment'
if Minutes.ready_to_post_draft
list << {form: DraftMinutes}
end
end
end
list
end
# determine if this item is flagged, accounting for pending actions
def flagged
return true if Pending.flagged and Pending.flagged.include? @attach
return false unless @flagged_by
return false if @flagged_by.length == 1 and
@flagged_by.first == User.initials and
Pending.unflagged.include?(@attach)
return ! @flagged_by.empty?
end
# determine if this report can be skipped during the course of the meeting
def skippable
return false if self.flagged
if self.missing and Agenda.meeting_day
return true if @to == 'president'
return true unless @notes or Server.userid == 'test'
return false
end
return false if @approved and @approved.length < 5 and Agenda.meeting_day
return true
end
# banner color for this agenda item
def color
if self.flagged
'commented'
elsif @color
@color
elsif not @title
'blank'
elsif @warnings
'missing'
elsif self.missing or self.rejected
'missing'
elsif @approved
if @approved.length < 5
'ready'
else
'reviewed'
end
elsif self.title == 'Action Items'
if self.actions.empty?
'missing'
elsif self.actions.any? {|action| action.status.empty?}
'ready'
else
'reviewed'
end
elsif @text or @report
'available'
elsif @text === undefined
'missing'
else
'reviewed'
end
end
# who to copy on emails
def cc
if @to == 'president'
'operations@apache.org'
else
'board@apache.org'
end
end
end
Events.subscribe :agenda do |message|
Agenda.fetch(nil, message.digest) if message.file == Agenda.file
end
Events.subscribe :server do |message|
Server.drafts = message.drafts if message.drafts
Server.agendas = message.agendas if message.agendas
end