standup_md 1.0.0 → 2.0.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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "standup_md/post"
4
+
5
+ module StandupMD
6
+ class Config
7
+ ##
8
+ # The configuration class for chat posting.
9
+ class Post
10
+ ##
11
+ # The default posting options.
12
+ #
13
+ # @return [Hash]
14
+ DEFAULTS = {
15
+ default_adapter: :slack,
16
+ title: nil
17
+ }.freeze
18
+
19
+ ##
20
+ # Attributes copied into request-scoped config snapshots.
21
+ #
22
+ # @return [Array<Symbol>]
23
+ CONFIG_ATTRIBUTES = DEFAULTS.keys.freeze
24
+
25
+ ##
26
+ # The adapter used when `standup --post` is called without a platform.
27
+ #
28
+ # @return [Symbol]
29
+ attr_accessor :default_adapter
30
+
31
+ ##
32
+ # Format string for posted entry titles.
33
+ #
34
+ # This only affects messages sent through chat posting adapters. It does
35
+ # not change stored standup markdown files. Use `%s` as a placeholder for
36
+ # the normal entry title, such as the entry date. This is useful when chat
37
+ # clients post through workspace apps or bots with shared names like
38
+ # "StandupMD", but the message should still identify whose standup it is.
39
+ #
40
+ # @example Include the person's name after the entry date
41
+ # StandupMD.config.post.title = "%s - Evan Gray"
42
+ #
43
+ # @return [String, nil]
44
+ attr_accessor :title
45
+
46
+ ##
47
+ # Registered adapter classes or instances.
48
+ #
49
+ # @return [Hash]
50
+ attr_reader :adapters
51
+
52
+ ##
53
+ # Non-secret adapter options.
54
+ #
55
+ # @return [Hash]
56
+ attr_reader :adapter_options
57
+
58
+ ##
59
+ # Initializes the config with default values.
60
+ def initialize
61
+ reset
62
+ end
63
+
64
+ ##
65
+ # Sets all config values back to their defaults.
66
+ #
67
+ # @return [Hash]
68
+ def reset
69
+ DEFAULTS.each { |k, v| instance_variable_set("@#{k}", v) }
70
+ @adapters = {}
71
+ @adapter_options = Hash.new { |hash, key| hash[key] = {} }
72
+ register_adapter(:slack, StandupMD::Post::Adapters::Slack)
73
+ DEFAULTS
74
+ end
75
+
76
+ ##
77
+ # Copies values from another post config.
78
+ #
79
+ # @param [StandupMD::Config::Post] config
80
+ #
81
+ # @return [StandupMD::Config::Post]
82
+ def copy_from(config)
83
+ CONFIG_ATTRIBUTES.each do |attribute|
84
+ instance_variable_set("@#{attribute}", config.public_send(attribute))
85
+ end
86
+ @adapters = config.adapters.dup
87
+ @adapter_options = Hash.new { |hash, key| hash[key] = {} }
88
+ config.adapter_options.each do |name, options|
89
+ @adapter_options[name] = options.dup
90
+ end
91
+ self
92
+ end
93
+
94
+ ##
95
+ # Registers a posting adapter.
96
+ #
97
+ # @param name [String, Symbol]
98
+ # @param adapter [Class, Object]
99
+ #
100
+ # @return [Class, Object]
101
+ def register_adapter(name, adapter)
102
+ adapters[name.to_sym] = adapter
103
+ end
104
+
105
+ ##
106
+ # Configures non-secret adapter options.
107
+ #
108
+ # @param name [String, Symbol]
109
+ # @param options [Hash]
110
+ #
111
+ # @return [Hash]
112
+ def configure_adapter(name, options = {})
113
+ options_for(name).merge!(symbolize_keys(options))
114
+ end
115
+
116
+ ##
117
+ # Returns non-secret options for an adapter.
118
+ #
119
+ # @param name [String, Symbol]
120
+ #
121
+ # @return [Hash]
122
+ def options_for(name)
123
+ adapter_options[name.to_sym]
124
+ end
125
+
126
+ ##
127
+ # Builds the adapter requested by name.
128
+ #
129
+ # @param name [String, Symbol, nil]
130
+ #
131
+ # @return [Object]
132
+ def build_adapter(name = nil)
133
+ adapter_name = (name || default_adapter).to_sym
134
+ adapter = adapters.fetch(adapter_name) do
135
+ raise StandupMD::Post::UnknownAdapter, "No post adapter registered for #{adapter_name}"
136
+ end
137
+ return adapter unless adapter.respond_to?(:new)
138
+ return adapter.new if adapter.instance_method(:initialize).arity.zero?
139
+
140
+ adapter.new(options_for(adapter_name))
141
+ end
142
+
143
+ private
144
+
145
+ def symbolize_keys(hash)
146
+ hash.each_with_object({}) do |(key, value), result|
147
+ result[key.to_sym] = value
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -3,7 +3,7 @@
3
3
  require "standup_md/config/cli"
4
4
  require "standup_md/config/file"
5
5
  require "standup_md/config/entry"
6
- require "standup_md/config/entry_list"
6
+ require "standup_md/config/post"
7
7
 
8
8
  module StandupMD
9
9
  ##
@@ -28,10 +28,10 @@ module StandupMD
28
28
  attr_reader :entry
29
29
 
30
30
  ##
31
- # Reader for EntryList config.
31
+ # Reader for Post config.
32
32
  #
33
- # @return [StandupMD::Config::EntryList]
34
- attr_reader :entry_list
33
+ # @return [StandupMD::Config::Post]
34
+ attr_reader :post
35
35
 
36
36
  ##
37
37
  # Builds the links to the configuration classes.
@@ -39,7 +39,20 @@ module StandupMD
39
39
  @cli = StandupMD::Config::Cli.new
40
40
  @file = StandupMD::Config::File.new
41
41
  @entry = StandupMD::Config::Entry.new
42
- @entry_list = StandupMD::Config::EntryList.new
42
+ @post = StandupMD::Config::Post.new
43
+ end
44
+
45
+ ##
46
+ # Builds an independent snapshot of the current configuration.
47
+ #
48
+ # @return [StandupMD::Config]
49
+ def copy
50
+ self.class.new.tap do |config|
51
+ config.cli.copy_from(cli)
52
+ config.file.copy_from(file)
53
+ config.entry.copy_from(entry)
54
+ config.post.copy_from(post)
55
+ end
43
56
  end
44
57
  end
45
58
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "standup_md/section"
5
4
 
6
5
  module StandupMD
@@ -10,6 +9,10 @@ module StandupMD
10
9
  class Entry
11
10
  include Comparable
12
11
 
12
+ ##
13
+ # Section identifiers supported by each standup entry.
14
+ #
15
+ # @return [Array<Symbol>]
13
16
  SECTION_TYPES = %i[current previous impediments notes].freeze
14
17
 
15
18
  ##
@@ -17,7 +20,7 @@ module StandupMD
17
20
  #
18
21
  # @return [StandupMD::Config::Entry]
19
22
  def self.config
20
- @config ||= StandupMD.config.entry
23
+ StandupMD.config.entry
21
24
  end
22
25
 
23
26
  ##
@@ -32,14 +35,33 @@ module StandupMD
32
35
  # Creates a generic entry. Default values can be set via configuration.
33
36
  # Yields the entry if a block is passed so you can change values.
34
37
  #
38
+ # @param [StandupMD::Config::Entry] config
39
+ #
40
+ # @param [Date] date
41
+ #
42
+ # @param [Array, nil] current
43
+ #
44
+ # @param [Array, nil] previous
45
+ #
46
+ # @param [Array, nil] impediments
47
+ #
48
+ # @param [Array, nil] notes
49
+ #
35
50
  # @return [StandupMD::Entry]
36
- def self.create
51
+ def self.create(
52
+ config: StandupMD.config.entry,
53
+ date: Date.today,
54
+ current: nil,
55
+ previous: nil,
56
+ impediments: nil,
57
+ notes: nil
58
+ )
37
59
  new(
38
- Date.today,
39
- config.current,
40
- config.previous,
41
- config.impediments,
42
- config.notes
60
+ date,
61
+ current || config.current,
62
+ previous || config.previous,
63
+ impediments || config.impediments,
64
+ notes || config.notes
43
65
  ).tap { |entry| yield entry if block_given? }
44
66
  end
45
67
 
@@ -58,7 +80,6 @@ module StandupMD
58
80
  def initialize(date, current, previous, impediments, notes = [])
59
81
  raise unless date.is_a?(Date)
60
82
 
61
- @config = self.class.config
62
83
  @date = date
63
84
  @sections = {}
64
85
  self.current = current
@@ -106,7 +127,7 @@ module StandupMD
106
127
  end
107
128
 
108
129
  ##
109
- # Entry as a hash .
130
+ # Entry as a hash.
110
131
  #
111
132
  # @return [Hash]
112
133
  def to_h
@@ -120,14 +141,6 @@ module StandupMD
120
141
  }
121
142
  end
122
143
 
123
- ##
124
- # Entry as a json object.
125
- #
126
- # @return [String]
127
- def to_json
128
- to_h.to_json
129
- end
130
-
131
144
  private
132
145
 
133
146
  def set_section(type, tasks)
@@ -10,15 +10,7 @@ module StandupMD
10
10
  include Enumerable
11
11
 
12
12
  ##
13
- # Access to the class's configuration.
14
- #
15
- # @return [StandupMD::Config::EntryList]
16
- def self.config
17
- @config ||= StandupMD.config.entry_list
18
- end
19
-
20
- ##
21
- # Contruct a list. Can pass any amount of +StandupMD::Entry+ instances.
13
+ # Construct a list. Can pass any amount of +StandupMD::Entry+ instances.
22
14
  #
23
15
  # @param [Entry] entries
24
16
  #
@@ -26,7 +18,6 @@ module StandupMD
26
18
  def initialize(*entries)
27
19
  entries.each { |entry| validate_entry(entry) }
28
20
 
29
- @config = self.class.config
30
21
  @entries = entries
31
22
  end
32
23
 
@@ -35,7 +26,7 @@ module StandupMD
35
26
  #
36
27
  # @param [StandupMD::Entry] entry
37
28
  #
38
- # @return [Array]
29
+ # @return [StandupMD::EntryList]
39
30
  def <<(entry)
40
31
  validate_entry(entry)
41
32
 
@@ -100,9 +91,9 @@ module StandupMD
100
91
  #
101
92
  # @param [Date] end_date
102
93
  #
103
- # @return [Array]
94
+ # @return [StandupMD::EntryList]
104
95
  def filter!(start_date, end_date)
105
- @entries = filter(start_date, end_date)
96
+ @entries = filter(start_date, end_date).to_a
106
97
  self
107
98
  end
108
99
 
@@ -124,18 +115,10 @@ module StandupMD
124
115
  end.to_h
125
116
  end
126
117
 
127
- ##
128
- # The entry list as a json object.
129
- #
130
- # @return [String]
131
- def to_json
132
- to_h.to_json
133
- end
134
-
135
118
  # :section: Delegators
136
119
 
137
120
  ##
138
- # The following are forwarded to @entries, which is the underly array of
121
+ # The following are forwarded to @entries, which is the underlying array of
139
122
  # entries.
140
123
  #
141
124
  # +each+:: Iterate over each entry.
@@ -8,68 +8,59 @@ module StandupMD
8
8
  ##
9
9
  # Class for handling reading and writing standup files.
10
10
  class File
11
+ ##
12
+ # Raised when a standup file or directory is missing and creation is off.
13
+ class NotFoundError < StandardError; end
14
+
11
15
  class << self
12
16
  ##
13
17
  # Access to the class's configuration.
14
18
  #
15
- # @return [StandupMD::Config::EntryList]
19
+ # @return [StandupMD::Config::File]
16
20
  def config
17
- @config ||= StandupMD.config.file
21
+ StandupMD.config.file
18
22
  end
19
23
 
20
24
  ##
21
- # Convenience method for calling File.find(file_name).load
25
+ # Convenience method for calling File.find(file_name).load.
22
26
  #
23
27
  # @param [String] file_name
28
+ # @param [StandupMD::Config::File] config
24
29
  #
25
30
  # @return [StandupMD::File]
26
- def load(file_name)
27
- unless ::File.directory?(config.directory)
28
- raise "Dir #{config.directory} not found." unless config.create
29
-
30
- FileUtils.mkdir_p(config.directory)
31
- end
32
- new(file_name).load
31
+ def load(file_name, config: StandupMD.config.file)
32
+ new(file_name, config: config).load
33
33
  end
34
34
 
35
35
  ##
36
36
  # Find standup file in directory by file name.
37
37
  #
38
- # @param [String] File_naem
39
- def find(file_name)
40
- unless ::File.directory?(config.directory)
41
- raise "Dir #{config.directory} not found." unless config.create
42
-
43
- FileUtils.mkdir_p(config.directory)
44
- end
45
- file_path = ::File.join(config.directory, file_name)
46
- unless ::File.file?(file_path) || config.create
47
- raise "File #{file_name} not found."
48
- end
49
-
50
- new(file_name)
38
+ # @param [String] file_name
39
+ # @param [StandupMD::Config::File] config
40
+ #
41
+ # @return [StandupMD::File]
42
+ def find(file_name, config: StandupMD.config.file)
43
+ new(file_name, config: config)
51
44
  end
52
45
 
53
46
  ##
54
47
  # Find standup file in directory by Date object.
55
48
  #
56
49
  # @param [Date] date
57
- def find_by_date(date)
50
+ # @param [StandupMD::Config::File] config
51
+ #
52
+ # @return [StandupMD::File]
53
+ def find_by_date(date, config: StandupMD.config.file)
58
54
  raise ArgumentError, "Must be a Date object" unless date.is_a?(Date)
59
55
 
60
- unless ::File.directory?(config.directory)
61
- raise "Dir #{config.directory} not found." unless config.create
62
-
63
- FileUtils.mkdir_p(config.directory)
64
- end
65
- find(date.strftime(config.name_format))
56
+ find(date.strftime(config.name_format), config: config)
66
57
  end
67
58
  end
68
59
 
69
60
  ##
70
61
  # The list of entries in the file.
71
62
  #
72
- # @return [StandupMP::EntryList]
63
+ # @return [StandupMD::EntryList]
73
64
  attr_reader :entries
74
65
 
75
66
  ##
@@ -82,29 +73,20 @@ module StandupMD
82
73
  # Constructs the instance.
83
74
  #
84
75
  # @param [String] file_name
76
+ # @param [StandupMD::Config::File] config
85
77
  #
86
- # @return [StandupMP::File]
87
- def initialize(file_name)
88
- @config = self.class.config
78
+ # @return [StandupMD::File]
79
+ def initialize(file_name, config: StandupMD.config.file)
80
+ @config = config
89
81
  @parser = StandupMD::Parsers::Markdown.new(@config)
90
82
  if file_name.include?(::File::SEPARATOR)
91
83
  raise ArgumentError,
92
- "#{file_name} contains directory. Please use `StandupMD.config.file.directory=`"
93
- end
94
-
95
- unless ::File.directory?(@config.directory)
96
- raise "Dir #{@config.directory} not found." unless @config.create
97
-
98
- FileUtils.mkdir_p(@config.directory)
84
+ "#{file_name} contains directory. Configure the file directory separately."
99
85
  end
100
86
 
87
+ ensure_directory
101
88
  @name = ::File.expand_path(::File.join(@config.directory, file_name))
102
-
103
- unless ::File.file?(@name)
104
- raise "File #{@name} not found." unless @config.create
105
-
106
- FileUtils.touch(@name)
107
- end
89
+ ensure_file
108
90
 
109
91
  @new = ::File.zero?(@name)
110
92
  @loaded = false
@@ -137,28 +119,49 @@ module StandupMD
137
119
  ##
138
120
  # Loads the file's contents.
139
121
  #
140
- # @return [StandupMD::FileList]
122
+ # @return [StandupMD::File]
141
123
  def load
142
- raise "File #{name} does not exist." unless ::File.file?(name)
124
+ raise NotFoundError, "File #{name} does not exist." unless ::File.file?(name)
143
125
 
144
126
  @loaded = true
145
- @entries = @parser.read(name)
127
+ @entries = @parser.parse(::File.read(name))
146
128
  self
147
129
  end
148
130
 
149
131
  ##
150
- # Writes a new entry to the file if the first entry in the file isn't today.
151
- # This method is destructive; if a file for entries in the date range
152
- # already exists, it will be clobbered with the entries in the range.
132
+ # Writes entries to disk. This method is destructive; existing file contents
133
+ # are replaced by the rendered entries in the requested date range.
153
134
  #
154
135
  # @param [Hash] {start_date: Date, end_date: Date}
155
136
  #
156
137
  # @return [Boolean] true if successful
157
138
  def write(**dates)
139
+ raise ArgumentError, "No entries loaded for #{name}" if entries.nil? || entries.empty?
140
+
158
141
  sorted_entries = entries.sort
159
142
  start_date = dates.fetch(:start_date, sorted_entries.first.date)
160
143
  end_date = dates.fetch(:end_date, sorted_entries.last.date)
161
- @parser.write(name, sorted_entries, start_date: start_date, end_date: end_date)
144
+ ::File.write(
145
+ name,
146
+ @parser.render(sorted_entries, start_date: start_date, end_date: end_date)
147
+ )
148
+ true
149
+ end
150
+
151
+ private
152
+
153
+ def ensure_directory
154
+ return if ::File.directory?(@config.directory)
155
+ raise NotFoundError, "Dir #{@config.directory} not found." unless @config.create
156
+
157
+ FileUtils.mkdir_p(@config.directory)
158
+ end
159
+
160
+ def ensure_file
161
+ return if ::File.file?(@name)
162
+ raise NotFoundError, "File #{@name} not found." unless @config.create
163
+
164
+ FileUtils.touch(@name)
162
165
  end
163
166
  end
164
167
  end
@@ -5,13 +5,18 @@ require "standup_md/entry"
5
5
  require "standup_md/entry_list"
6
6
  require "standup_md/section"
7
7
  require "standup_md/task"
8
- require "standup_md/title"
9
8
 
10
9
  module StandupMD
10
+ ##
11
+ # Namespace for standup file parsers and renderers.
11
12
  module Parsers
12
13
  ##
13
14
  # Parser and renderer for the markdown standup format.
14
15
  class Markdown
16
+ ##
17
+ # Raised when markdown cannot be parsed into standup entries.
18
+ class Error < StandardError; end
19
+
15
20
  ##
16
21
  # Access to file configuration.
17
22
  #
@@ -27,17 +32,17 @@ module StandupMD
27
32
  end
28
33
 
29
34
  ##
30
- # Reads entries from a markdown standup file.
35
+ # Parses entries from markdown text.
31
36
  #
32
- # @param [String] file_name
37
+ # @param [String] text
33
38
  #
34
39
  # @return [StandupMD::EntryList]
35
- def read(file_name)
40
+ def parse(text)
36
41
  entry_list = EntryList.new
37
42
  record = nil
38
43
  section = nil
39
44
 
40
- ::File.foreach(file_name) do |line|
45
+ text.to_s.each_line do |line|
41
46
  line.chomp!
42
47
  next if line.strip.empty?
43
48
 
@@ -57,42 +62,40 @@ module StandupMD
57
62
  entry_list << entry(record) if record
58
63
  entry_list.sort
59
64
  rescue => e
60
- raise "File malformation: #{e}"
65
+ raise Error, "Markdown malformation: #{e.message}"
61
66
  end
62
67
 
63
68
  ##
64
- # Writes entries to a markdown standup file.
69
+ # Renders entries as markdown text.
65
70
  #
66
- # @param [String] file_name
67
71
  # @param [StandupMD::EntryList] entries
68
72
  # @param [Date] start_date
69
73
  # @param [Date] end_date
70
74
  #
71
- # @return [Boolean]
72
- def write(file_name, entries, start_date:, end_date:)
73
- ::File.open(file_name, "w") do |f|
74
- entries.filter(start_date, end_date).sort_reverse.each do |entry|
75
- f.puts Title.new(entry.date).to_markdown
76
- config.sub_header_order.each do |attr|
77
- section = Section.new(attr, entry.public_send("#{attr}_tasks"))
78
- next if section.empty?
79
-
80
- f.puts section.to_markdown
81
- end
82
- f.puts
83
- end
84
- end
85
- true
75
+ # @return [String]
76
+ def render(entries, start_date:, end_date:)
77
+ entries.filter(start_date, end_date).sort_reverse.map do |entry|
78
+ render_entry(entry)
79
+ end.join
86
80
  end
87
81
 
88
82
  ##
89
- # Renders a task as a markdown list item.
83
+ # Renders a single entry as markdown text.
90
84
  #
91
- # @param [String, StandupMD::Task] task
85
+ # @param [StandupMD::Entry] entry
92
86
  #
93
87
  # @return [String]
94
- def task_line(task)
95
- build_task(task).to_markdown
88
+ def render_entry(entry)
89
+ lines = [entry_header(entry)]
90
+ config.sub_header_order.each do |type|
91
+ section = Section.new(type, entry.public_send("#{type}_tasks"))
92
+ next if section.empty?
93
+
94
+ lines << section_header(type)
95
+ section.tasks.each { |task| lines << task_line(task) }
96
+ end
97
+ lines << ""
98
+ lines.join("\n") + "\n"
96
99
  end
97
100
 
98
101
  private
@@ -121,11 +124,19 @@ module StandupMD
121
124
  Section.new(type)
122
125
  end
123
126
 
127
+ def entry_header(entry)
128
+ "#{"#" * config.header_depth} #{entry.date.strftime(config.header_date_format)}"
129
+ end
130
+
131
+ def section_header(type)
132
+ "#{"#" * config.sub_header_depth} #{config.public_send("#{type}_header")}"
133
+ end
134
+
124
135
  def section_type(line)
125
136
  sub_header = line.sub(/^\#{#{config.sub_header_depth}}\s*/, "")
126
137
  Entry::SECTION_TYPES.each do |type|
127
138
  header = config.public_send("#{type}_header")
128
- return type if sub_header.include?(header)
139
+ return type if sub_header == header
129
140
  end
130
141
  raise "Unrecognized header [#{sub_header}]"
131
142
  end
@@ -144,6 +155,11 @@ module StandupMD
144
155
  /\A(?<indent>\s*)#{Regexp.escape(config.bullet_character)}\s*(?<text>.*)\z/
145
156
  end
146
157
 
158
+ def task_line(task)
159
+ indent = " " * config.indent_width * task.indent_level
160
+ "#{indent}#{config.bullet_character} #{task.text}"
161
+ end
162
+
147
163
  def entry(record)
148
164
  Entry.new(
149
165
  Date.strptime(record[:title], config.header_date_format),
@@ -157,12 +173,6 @@ module StandupMD
157
173
  def tasks(record, type)
158
174
  record[:sections].fetch(type, Section.new(type)).tasks
159
175
  end
160
-
161
- def build_task(task)
162
- return task if task.is_a?(Task)
163
-
164
- Task.new(task)
165
- end
166
176
  end
167
177
  end
168
178
  end