standup_md 1.0.1 → 2.0.1

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
@@ -21,7 +20,7 @@ module StandupMD
21
20
  #
22
21
  # @return [StandupMD::Config::Entry]
23
22
  def self.config
24
- @config ||= StandupMD.config.entry
23
+ StandupMD.config.entry
25
24
  end
26
25
 
27
26
  ##
@@ -36,14 +35,33 @@ module StandupMD
36
35
  # Creates a generic entry. Default values can be set via configuration.
37
36
  # Yields the entry if a block is passed so you can change values.
38
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
+ #
39
50
  # @return [StandupMD::Entry]
40
- 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
+ )
41
59
  new(
42
- Date.today,
43
- config.current,
44
- config.previous,
45
- config.impediments,
46
- config.notes
60
+ date,
61
+ current || config.current,
62
+ previous || config.previous,
63
+ impediments || config.impediments,
64
+ notes || config.notes
47
65
  ).tap { |entry| yield entry if block_given? }
48
66
  end
49
67
 
@@ -62,7 +80,6 @@ module StandupMD
62
80
  def initialize(date, current, previous, impediments, notes = [])
63
81
  raise unless date.is_a?(Date)
64
82
 
65
- @config = self.class.config
66
83
  @date = date
67
84
  @sections = {}
68
85
  self.current = current
@@ -110,7 +127,7 @@ module StandupMD
110
127
  end
111
128
 
112
129
  ##
113
- # Entry as a hash .
130
+ # Entry as a hash.
114
131
  #
115
132
  # @return [Hash]
116
133
  def to_h
@@ -124,14 +141,6 @@ module StandupMD
124
141
  }
125
142
  end
126
143
 
127
- ##
128
- # Entry as a json object.
129
- #
130
- # @return [String]
131
- def to_json
132
- to_h.to_json
133
- end
134
-
135
144
  private
136
145
 
137
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,7 +5,6 @@ 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
11
10
  ##
@@ -14,6 +13,10 @@ module StandupMD
14
13
  ##
15
14
  # Parser and renderer for the markdown standup format.
16
15
  class Markdown
16
+ ##
17
+ # Raised when markdown cannot be parsed into standup entries.
18
+ class Error < StandardError; end
19
+
17
20
  ##
18
21
  # Access to file configuration.
19
22
  #
@@ -29,17 +32,17 @@ module StandupMD
29
32
  end
30
33
 
31
34
  ##
32
- # Reads entries from a markdown standup file.
35
+ # Parses entries from markdown text.
33
36
  #
34
- # @param [String] file_name
37
+ # @param [String] text
35
38
  #
36
39
  # @return [StandupMD::EntryList]
37
- def read(file_name)
40
+ def parse(text)
38
41
  entry_list = EntryList.new
39
42
  record = nil
40
43
  section = nil
41
44
 
42
- ::File.foreach(file_name) do |line|
45
+ text.to_s.each_line do |line|
43
46
  line.chomp!
44
47
  next if line.strip.empty?
45
48
 
@@ -59,42 +62,40 @@ module StandupMD
59
62
  entry_list << entry(record) if record
60
63
  entry_list.sort
61
64
  rescue => e
62
- raise "File malformation: #{e}"
65
+ raise Error, "Markdown malformation: #{e.message}"
63
66
  end
64
67
 
65
68
  ##
66
- # Writes entries to a markdown standup file.
69
+ # Renders entries as markdown text.
67
70
  #
68
- # @param [String] file_name
69
71
  # @param [StandupMD::EntryList] entries
70
72
  # @param [Date] start_date
71
73
  # @param [Date] end_date
72
74
  #
73
- # @return [Boolean]
74
- def write(file_name, entries, start_date:, end_date:)
75
- ::File.open(file_name, "w") do |f|
76
- entries.filter(start_date, end_date).sort_reverse.each do |entry|
77
- f.puts Title.new(entry.date).to_markdown
78
- config.sub_header_order.each do |attr|
79
- section = Section.new(attr, entry.public_send("#{attr}_tasks"))
80
- next if section.empty?
81
-
82
- f.puts section.to_markdown
83
- end
84
- f.puts
85
- end
86
- end
87
- 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
88
80
  end
89
81
 
90
82
  ##
91
- # Renders a task as a markdown list item.
83
+ # Renders a single entry as markdown text.
92
84
  #
93
- # @param [String, StandupMD::Task] task
85
+ # @param [StandupMD::Entry] entry
94
86
  #
95
87
  # @return [String]
96
- def task_line(task)
97
- 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"
98
99
  end
99
100
 
100
101
  private
@@ -123,11 +124,19 @@ module StandupMD
123
124
  Section.new(type)
124
125
  end
125
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
+
126
135
  def section_type(line)
127
136
  sub_header = line.sub(/^\#{#{config.sub_header_depth}}\s*/, "")
128
137
  Entry::SECTION_TYPES.each do |type|
129
138
  header = config.public_send("#{type}_header")
130
- return type if sub_header.include?(header)
139
+ return type if sub_header == header
131
140
  end
132
141
  raise "Unrecognized header [#{sub_header}]"
133
142
  end
@@ -146,6 +155,11 @@ module StandupMD
146
155
  /\A(?<indent>\s*)#{Regexp.escape(config.bullet_character)}\s*(?<text>.*)\z/
147
156
  end
148
157
 
158
+ def task_line(task)
159
+ indent = " " * config.indent_width * task.indent_level
160
+ "#{indent}#{config.bullet_character} #{task.text}"
161
+ end
162
+
149
163
  def entry(record)
150
164
  Entry.new(
151
165
  Date.strptime(record[:title], config.header_date_format),
@@ -159,12 +173,6 @@ module StandupMD
159
173
  def tasks(record, type)
160
174
  record[:sections].fetch(type, Section.new(type)).tasks
161
175
  end
162
-
163
- def build_task(task)
164
- return task if task.is_a?(Task)
165
-
166
- Task.new(task)
167
- end
168
176
  end
169
177
  end
170
178
  end