jekyll-rp_logs 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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