jekyll-rp_logs 0.1.6 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,184 +1,208 @@
1
- require_relative 'rp_parser'
2
- require_relative 'rp_arcs'
3
- require_relative 'rp_tags'
1
+ require_relative "rp_parser"
2
+ require_relative "rp_logline"
3
+ require_relative "rp_page"
4
+ require_relative "rp_arcs"
5
+ require_relative "rp_tags"
4
6
 
5
7
  module Jekyll
6
8
  module RpLogs
7
-
8
9
  # Consider renaming since it is more of a converter in practice
9
10
  class RpLogGenerator < Jekyll::Generator
10
11
  safe true
11
12
  priority :normal
12
13
 
13
- RP_KEY = "rps"
14
+ @parsers = {}
15
+
16
+ class << self
17
+ attr_reader :parsers, :rp_key
14
18
 
15
- @@parsers = {}
19
+ def add(parser)
20
+ @parsers[parser::FORMAT_STR] = parser
21
+ end
16
22
 
17
- def RpLogGenerator.add(parser)
18
- @@parsers[parser::FORMAT_STR] = parser
23
+ ##
24
+ # Extract global settings from the config file.
25
+ # The rp directory and collection name is pulled out; it must be the
26
+ # first collection defined.
27
+ def extract_settings(config)
28
+ @rp_key = config["collections"].keys[0].freeze
29
+ end
19
30
  end
20
31
 
21
32
  def initialize(config)
22
- config['rp_convert'] ||= true
23
- end
33
+ # Should actually probably complain if things are undefined or missing
34
+ config["rp_convert"] = true unless config.key? "rp_convert"
35
+
36
+ RpLogGenerator.extract_settings(config)
37
+ LogLine.extract_settings(config)
24
38
 
25
- def skip_page(page, message)
26
- @site.collections[RP_KEY].docs.delete page
27
- print "\nSkipping #{page.path}: #{message}"
39
+ Jekyll.logger.info "Loaded jekyll-rp_logs #{RpLogs::VERSION}"
28
40
  end
29
41
 
30
- def has_errors?(page)
31
- # Verify that formats are specified
32
- if page.data['format'].nil? || page.data['format'].length == 0 then
33
- skip_page(page, "No formats specified")
34
- return true
35
- else
36
- # Verify that the parser for each format exists
37
- page.data['format'].each { |format|
38
- if @@parsers[format].nil? then
39
- skip_page(page, "Format #{format} does not exist.")
40
- return true
41
- end
42
- }
43
- end
42
+ def generate(site)
43
+ return unless site.config["rp_convert"]
44
44
 
45
- # Verify that tags exist
46
- if page.data['rp_tags'].nil? then
47
- skip_page(page, "No tags specified")
48
- return true
49
- # Verify that arc names are in the proper format
50
- elsif not (page.data['arc_name'].nil? || page.data['arc_name'].respond_to?('each')) then
51
- skip_page(page, "arc_name must be blank or a YAML list")
52
- return true
53
- end
45
+ main_index, arc_index = extract_indexes(site)
54
46
 
55
- false
47
+ # Pull out all the pages that are error-free
48
+ rp_pages = extract_valid_rps(site)
49
+
50
+ convert_all_pages(site, main_index, arc_index, rp_pages)
56
51
  end
57
52
 
58
- def generate(site)
59
- return unless site.config['rp_convert']
60
- @site = site
53
+ private
61
54
 
55
+ ##
56
+ # Convenience method for accessing the collection key name
57
+ def rp_key
58
+ self.class.rp_key
59
+ end
60
+
61
+ ##
62
+ #
63
+ def extract_indexes(site)
62
64
  # Directory of RPs
63
- index = site.pages.detect { |page| page.data['rp_index'] }
64
- index.data['rps'] = {'canon' => [], 'noncanon' => []}
65
+ main_index = site.pages.find { |page| page.data["rp_index"] }
66
+ main_index.data["rps"] = { "canon" => [], "noncanon" => [] }
65
67
 
66
68
  # Arc-style directory
67
- arc_page = site.pages.detect { |page| page.data['rp_arcs'] }
69
+ arc_index = site.pages.find { |page| page.data["rp_arcs"] }
68
70
 
69
- site.data['menu_pages'] = [index, arc_page]
71
+ site.data["menu_pages"] = [main_index, arc_index]
72
+ end
70
73
 
74
+ ##
75
+ # Returns a list of RpLogs::Page objects that are error-free.
76
+ def extract_valid_rps(site)
77
+ site.collections[rp_key].docs.map { |p| RpLogs::Page.new(p) }
78
+ .reject do |p|
79
+ message = p.errors?(self.class.parsers)
80
+ skip_page(site, p, message) if message
81
+ message
82
+ end
83
+ end
84
+
85
+ def convert_all_pages(site, main_index, arc_index, rp_pages)
71
86
  arcs = Hash.new { |hash, key| hash[key] = Arc.new(key) }
72
87
  no_arc_rps = []
73
88
 
74
89
  # Convert all of the posts to be pretty
75
90
  # Also build up our hash of tags
76
- site.collections[RP_KEY].docs.select { true }
77
- .each { |page|
78
- # because we're iterating over a selected array, we can delete from the original
79
- begin
80
- next if has_errors? page
81
-
82
- page.data['rp_tags'] = page.data['rp_tags'].split(',').map { |t| Tag.new t }
83
-
84
- # Skip if something goes wrong
85
- next unless convertRp page
86
-
87
- key = if page.data['canon'] then 'canon' else 'noncanon' end
88
- # Add key for canon/noncanon
89
- index.data['rps'][key] << page
90
- # Add tag for canon/noncanon
91
- page.data['rp_tags'] << (Tag.new key)
92
- page.data['rp_tags'].sort!
93
-
94
- arc_name = page.data['arc_name']
95
- if arc_name then
96
- arc_name.each { |n| arcs[n] << page }
97
- else
98
- no_arc_rps << page
99
- end
100
- rescue
101
- # Catch all for any other exception encountered when parsing a page
102
- skip_page(page, "Error parsing #{page.path}: " + $!.inspect)
103
- # Raise exception, so Jekyll prints backtrace if run with --trace
104
- raise $!
91
+ rp_pages.each do |page|
92
+ begin
93
+ # Skip if something goes wrong
94
+ next unless convert_rp(site, page)
95
+
96
+ key = page[:canon] ? "canon" : "noncanon"
97
+ # Add key for canon/noncanon
98
+ main_index.data["rps"][key] << page
99
+ # Add tag for canon/noncanon
100
+ page[:rp_tags] << (Tag.new key)
101
+ page[:rp_tags].sort!
102
+
103
+ arc_name = page[:arc_name]
104
+ if arc_name && !arc_name.empty?
105
+ arc_name.each { |n| arcs[n] << page }
106
+ else
107
+ no_arc_rps << page
105
108
  end
106
- }
107
109
 
108
- arcs.each_key { |key| sort_chronologically! arcs[key].rps }
109
- combined_rps = no_arc_rps.map { |x| ['rp', x] } + arcs.values.map { |x| ['arc', x] }
110
- combined_rps.sort_by! { |type,x|
110
+ Jekyll.logger.info "Converted #{page.basename}"
111
+ rescue
112
+ # Catch all for any other exception encountered when parsing a page
113
+ skip_page(site, page, "Error parsing #{page.basename}: #{$ERROR_INFO.inspect}")
114
+ # Raise exception, so Jekyll prints backtrace if run with --trace
115
+ raise $ERROR_INFO
116
+ end
117
+ end
118
+
119
+ arcs.each_key { |key| sort_chronologically! arcs[key].rps }
120
+ combined_rps = no_arc_rps.map { |x| ["rp", x] } + arcs.values.map { |x| ["arc", x] }
121
+ combined_rps.sort_by! { |type, x|
111
122
  case type
112
- when 'rp'
113
- x.data['start_date']
114
- when 'arc'
115
- x.start_date
123
+ when "rp"
124
+ x[:time_line] || x[:start_date]
125
+ when "arc"
126
+ x.start_date
116
127
  end
117
128
  }.reverse!
118
- arc_page.data['rps'] = combined_rps
129
+ arc_index.data["rps"] = combined_rps
119
130
 
120
- sort_chronologically! index.data['rps']['canon']
121
- sort_chronologically! index.data['rps']['noncanon']
131
+ sort_chronologically! main_index.data["rps"]["canon"]
132
+ sort_chronologically! main_index.data["rps"]["noncanon"]
122
133
  end
123
134
 
124
- def sort_chronologically!(pages)
125
- pages.sort_by! { |p| p.data['start_date'] }.reverse!
135
+ def sort_chronologically!(pages)
136
+ # Check pages for invalid time_line value
137
+ pages.each do |p|
138
+ if p[:time_line] && !p[:time_line].is_a?(Date)
139
+ Jekyll.logger.error "Malformed time_line #{p[:time_line]} in file #{p.path}"
140
+ fail "Malformed time_line date"
141
+ end
142
+ end
143
+ # Sort pages by time_line if present or start_date otherwise
144
+ pages.sort_by! { |p| p[:time_line] || p[:start_date] }.reverse!
126
145
  end
127
146
 
128
- def convertRp(page)
129
- options = get_options page
147
+ def convert_rp(site, page)
148
+ options = page.options
130
149
 
131
150
  compiled_lines = []
132
- page.content.each_line { |raw_line|
133
- page.data['format'].each { |format|
134
- log_line = @@parsers[format].parse_line(raw_line, options)
135
- if log_line then
136
- compiled_lines << log_line
151
+ page.content.each_line { |raw_line|
152
+ page[:format].each { |format|
153
+ log_line = self.class.parsers[format].parse_line(raw_line, options)
154
+ if log_line
155
+ compiled_lines << log_line
137
156
  break
138
157
  end
139
158
  }
140
159
  }
141
160
 
142
- if compiled_lines.length == 0 then
143
- skip_page(page, "No lines were matched by any format.")
161
+ if compiled_lines.length == 0
162
+ skip_page(site, page, "No lines were matched by any format.")
144
163
  return false
145
164
  end
146
165
 
147
166
  merge_lines! compiled_lines
148
167
  stats = extract_stats compiled_lines
149
168
 
150
- split_output = compiled_lines.map { |line| line.output }
151
-
169
+ split_output = compiled_lines.map(&:output)
152
170
  page.content = split_output.join("\n")
153
171
 
154
- if page.data['infer_char_tags'] then
172
+ if page[:infer_char_tags]
155
173
  # Turn the nicks into characters
156
- nick_tags = stats[:nicks].map! { |n| Tag.new('char:' + n) }
157
- page.data['rp_tags'] = (nick_tags.merge page.data['rp_tags']).to_a.sort
174
+ nick_tags = stats[:nicks].map! { |n| Tag.new("char:" + n) }
175
+ page[:rp_tags] = (nick_tags.merge page[:rp_tags]).to_a.sort
158
176
  end
159
177
 
160
- page.data['end_date'] = stats[:end_date]
161
- page.data['start_date'] ||= stats[:start_date]
178
+ page[:end_date] = stats[:end_date]
179
+ page[:start_date] ||= stats[:start_date]
162
180
 
163
181
  true
164
182
  end
165
183
 
166
- def get_options(page)
167
- { :strict_ooc => page.data['strict_ooc'],
168
- :merge_text_into_rp => page.data['merge_text_into_rp'] }
184
+ ##
185
+ # Skip the page. Removes it from the site collection, and outputs a
186
+ # warning message saying it was skipped with the given reason.
187
+ def skip_page(site, page, message)
188
+ site.collections[rp_key].docs.delete page.page
189
+ Jekyll.logger.warn "Skipping #{page.basename}: #{message}"
169
190
  end
170
191
 
192
+ ##
193
+ # Consider moving this into Parser or RpLogs::Page
194
+ # It doesn't really belong here
171
195
  def merge_lines!(compiled_lines)
172
196
  last_line = nil
173
- compiled_lines.reject! { |line|
174
- if last_line == nil then
197
+ compiled_lines.reject! { |line|
198
+ if last_line.nil?
175
199
  last_line = line
176
200
  false
177
- elsif last_line.mergeable_with? line then
201
+ elsif last_line.mergeable_with? line
178
202
  last_line.merge! line
179
- # Delete the current line from output and maintain last_line
203
+ # Delete the current line from output and maintain last_line
180
204
  # in case we need to merge multiple times.
181
- true
205
+ true
182
206
  else
183
207
  last_line = line
184
208
  false
@@ -186,17 +210,16 @@ module Jekyll
186
210
  }
187
211
  end
188
212
 
189
- def extract_stats(compiled_lines)
213
+ def extract_stats(compiled_lines)
190
214
  nicks = Set.new
191
- compiled_lines.each { |line|
215
+ compiled_lines.each { |line|
192
216
  nicks << line.sender if line.output_type == :rp
193
217
  }
194
218
 
195
- { :nicks => nicks,
196
- :end_date => compiled_lines[-1].timestamp,
197
- :start_date => compiled_lines[0].timestamp }
219
+ { nicks: nicks,
220
+ end_date: compiled_lines[-1].timestamp,
221
+ start_date: compiled_lines[0].timestamp }
198
222
  end
199
223
  end
200
-
201
224
  end
202
- end
225
+ end
@@ -0,0 +1,225 @@
1
+ require "cgi"
2
+
3
+ module Jekyll
4
+ module RpLogs
5
+ class LogLine
6
+ RP_FLAG = "!RP".freeze
7
+ OOC_FLAG = "!OOC".freeze
8
+ MERGE_FLAG = "!MERGE".freeze
9
+ SPLIT_FLAG = "!SPLIT".freeze
10
+
11
+ attr_reader :timestamp, :mode, :sender, :contents, :flags
12
+ # Some things depend on the original type of the line (nick format)
13
+ attr_reader :base_type, :output_type
14
+ attr_reader :options
15
+
16
+ # Timestamp of the most recent line this line was merged with, to allow
17
+ # merging consecutive lines each MAX_SECONDS_BETWEEN_POSTS apart
18
+ attr_reader :last_merged_timestamp
19
+
20
+ # The max number of seconds between two lines that can still be merged
21
+ @max_seconds_between_posts = 3
22
+
23
+ # All characters that can denote the beginning of an OOC line
24
+ @ooc_start_delimiters = "([".freeze
25
+
26
+ class << self
27
+ attr_reader :ooc_start_delimiters, :max_seconds_between_posts
28
+
29
+ def extract_settings(config)
30
+ @max_seconds_between_posts = config.fetch("max_seconds_between_posts",
31
+ @max_seconds_between_posts)
32
+ @ooc_start_delimiters = config.fetch("ooc_start_delimiters",
33
+ @ooc_start_delimiters).freeze
34
+ end
35
+ end
36
+
37
+ def initialize(timestamp, options = {}, sender:, contents:, flags:, type:, mode: " ")
38
+ @timestamp = timestamp
39
+ # Initialize to be the same as @timestamp
40
+ @last_merged_timestamp = timestamp
41
+ @mode = mode
42
+ @sender = sender
43
+ @contents = contents
44
+ @flags = flags.split(" ")
45
+
46
+ @base_type = type
47
+ @output_type = type
48
+
49
+ @options = options
50
+
51
+ classify
52
+ end
53
+
54
+ ##
55
+ # Set derived properties of this LogLine based on various options
56
+ private def classify
57
+ # This makes it RP by default
58
+ @output_type = :rp if @options[:strict_ooc]
59
+
60
+ # Check the contents for leading ( or [
61
+ @output_type = :ooc if ooc_start_delimiters.include? @contents.strip[0]
62
+
63
+ # Flags override our assumptions, always
64
+ if @flags.include? RP_FLAG
65
+ @output_type = :rp
66
+ elsif @flags.include? OOC_FLAG
67
+ @output_type = :ooc
68
+ end
69
+ # TODO: Containing both flags should result in a warning
70
+ end
71
+
72
+ def output
73
+ tag_open, tag_close = output_tags
74
+ # Escape any HTML special characters in the input
75
+ escaped_content = CGI.escapeHTML(@contents)
76
+ "#{tag_open}#{output_timestamp}#{output_sender} #{escaped_content}#{tag_close}"
77
+ end
78
+
79
+ def output_timestamp
80
+ # String used for the timestamp anchors
81
+ anchor = @timestamp.strftime("%Y-%m-%d_%H:%M:%S")
82
+ # String used when hovering over timestamps (friendly long-form)
83
+ title = @timestamp.strftime("%H:%M:%S %B %-d, %Y")
84
+ # String actually displayed on page
85
+ display = @timestamp.strftime("%H:%M")
86
+ "<a name=\"#{anchor}\" title=\"#{title}\" href=\"##{anchor}\">#{display}</a>"
87
+ end
88
+
89
+ def output_sender
90
+ case @base_type
91
+ when :rp
92
+ return " * #{@sender}"
93
+ when :ooc
94
+ return " &lt;#{@mode}#{@sender}&gt;"
95
+ else
96
+ # Explode.
97
+ fail "No known type: #{@base_type}"
98
+ end
99
+ end
100
+
101
+ def output_tags
102
+ tag_class =
103
+ case @output_type
104
+ when :rp then "rp"
105
+ when :ooc then "ooc"
106
+ else # Explode.
107
+ fail "No known type: #{@output_type}"
108
+ end
109
+ tag_open = "<p class=\"#{tag_class}\">"
110
+ tag_close = "</p>"
111
+
112
+ [tag_open, tag_close]
113
+ end
114
+
115
+ ##
116
+ # Check if this line can be merged with the given line. In order to be
117
+ # merged, the two lines must fulfill the following requirements:
118
+ #
119
+ # * The timestamp difference is >= 0 and <= MAX_SECONDS_BETWEEN POSTS
120
+ # (close_enough_timestamps?)
121
+ # * The lines have the same sender (same_sender?)
122
+ # * The first line has output_type :rp (rp?)
123
+ # * The next line has output_type :rp OR the sender has been specified
124
+ # as someone who splits to normal text
125
+ #
126
+ # Exceptions:
127
+ # * If the next line has the SPLIT flag, it will never be merged
128
+ # * If the next line has the MERGE flag, it will always be merged
129
+ def mergeable_with?(next_line)
130
+ # Perform the checks for the override flags
131
+ return true if next_line.merge_flag?
132
+ return false if next_line.split_flag?
133
+ mergeable_ignoring_flags?(next_line)
134
+ end
135
+
136
+ ##
137
+ # Does all the rest of the checks that don't have to do with the
138
+ # override flags SPLIT_FLAG and MERGE_FLAG.
139
+ private def mergeable_ignoring_flags?(next_line)
140
+ close_enough_timestamps?(next_line) &&
141
+ same_sender?(next_line) &&
142
+ rp? &&
143
+ (next_line.rp? || next_line.possible_split_to_normal_text?)
144
+ end
145
+
146
+ def merge!(next_line)
147
+ @contents += "#{space_between_lines}#{next_line.contents}"
148
+ @last_merged_timestamp = next_line.timestamp
149
+ self
150
+ end
151
+
152
+ ##
153
+ # Returns "" if the sender has been said to split by characters.
154
+ # Returns " " otherwise.
155
+ #
156
+ # When the sender splits by characters, adding a space will put spaces in
157
+ # the middle of words. Their spaces will be preserved at the end of lines.
158
+ private def space_between_lines
159
+ if options[:splits_by_character] &&
160
+ options[:splits_by_character].include?(@sender)
161
+ ""
162
+ else
163
+ " "
164
+ end
165
+ end
166
+
167
+ ##
168
+ # Returns true if this line has the output_type :rp
169
+ def rp?
170
+ @output_type == :rp
171
+ end
172
+
173
+ def split_flag?
174
+ @flags.include? SPLIT_FLAG
175
+ end
176
+
177
+ def merge_flag?
178
+ @flags.include? MERGE_FLAG
179
+ end
180
+
181
+ ##
182
+ # Return true if this sender splits to normal text, and the line base
183
+ # type was OOC. This allows you to force a quick text post not to merge
184
+ # by flagging it !OOC.
185
+ #
186
+ # Only merge if the base type was OOC... otherwise you couldn't force not merging
187
+ # Maybe a job for !NOTMERGE flag, or similar
188
+ protected def possible_split_to_normal_text?
189
+ base_type == :ooc && @options[:merge_text_into_rp] &&
190
+ @options[:merge_text_into_rp].include?(@sender)
191
+ end
192
+
193
+ def inspect
194
+ "<#{@mode}#{@sender}> (#{@base_type} -> #{@output_type}) #{@contents}"
195
+ end
196
+
197
+ private
198
+
199
+ ##
200
+ # Only merge posts close enough in time
201
+ # The difference in time between the post merged into this one, and
202
+ # the next post, must be less than the limit (and non-negative)
203
+ def close_enough_timestamps?(next_line)
204
+ time_diff = (next_line.timestamp - @last_merged_timestamp) * 24 * 60 * 60
205
+ time_diff >= 0 && time_diff <= max_seconds_between_posts
206
+ end
207
+
208
+ ##
209
+ # Returns if these lines have the same sender
210
+ def same_sender?(next_line)
211
+ @sender == next_line.sender
212
+ end
213
+
214
+ ##
215
+ # Convenience methods for accessing class instance variables
216
+ def max_seconds_between_posts
217
+ self.class.max_seconds_between_posts
218
+ end
219
+
220
+ def ooc_start_delimiters
221
+ self.class.ooc_start_delimiters
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,63 @@
1
+ module Jekyll
2
+ module RpLogs
3
+ class Page
4
+ extend Forwardable
5
+ def_delegators :@page, :basename, :content, :content=, :path, :to_liquid
6
+
7
+ # Jekyll::Page object
8
+ attr_reader :page
9
+
10
+ def initialize(page)
11
+ @page = page
12
+
13
+ # If the tags exist, try to convert them to a list of Tag objects
14
+ self[:rp_tags] &&= self[:rp_tags].split(",").map { |t| Tag.new t }
15
+ end
16
+
17
+ ##
18
+ # Pass the request along to the page's data hash, and allow symbols to be
19
+ # used by converting them to strings first.
20
+ def [](key)
21
+ @page.data[key.to_s]
22
+ end
23
+
24
+ def []=(key, value)
25
+ @page.data[key.to_s] = value
26
+ end
27
+
28
+ ##
29
+ # Check this page for errors, using the provided list of supported parse
30
+ # formats
31
+ #
32
+ # Returns false if there is no error
33
+ # Returns error_message if there is an error
34
+ def errors?(supported_formats)
35
+ # Verify that formats are specified
36
+ if self[:format].nil? || self[:format].empty?
37
+ return "No formats specified"
38
+ end
39
+
40
+ # Verify that the parser for each format exists
41
+ self[:format].each do |format|
42
+ return "Format #{format} does not exist." unless supported_formats[format]
43
+ end
44
+
45
+ # Verify that tags exist
46
+ return "No tags specified" if self[:rp_tags].nil?
47
+
48
+ # Verify that arc names are in the proper format
49
+ if self[:arc_name] && !self[:arc_name].respond_to?("each")
50
+ return "arc_name must be blank or a YAML list"
51
+ end
52
+
53
+ false
54
+ end
55
+
56
+ def options
57
+ { strict_ooc: self[:strict_ooc],
58
+ merge_text_into_rp: self[:merge_text_into_rp],
59
+ splits_by_character: self[:splits_by_character] }
60
+ end
61
+ end
62
+ end
63
+ end