kood 0.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,128 @@
1
+ require 'active_support/core_ext'
2
+ require 'toystore'
3
+
4
+ module Kood
5
+ class Card
6
+ include Toy::Store
7
+
8
+ # References
9
+ reference :list, List
10
+
11
+ # Attributes
12
+ attribute :title, String
13
+ attribute :content, String
14
+ attribute :participants, Array
15
+ attribute :labels, Array
16
+ attribute :position, Integer
17
+ attribute :date, Time, default: lambda { Time.now }
18
+ attribute :more, Hash # to store additional user-defined properties
19
+
20
+ def self.get!(id)
21
+ super rescue raise NotFound, "The specified card does not exist."
22
+ end
23
+
24
+ def self.find_all_by_partial_attribute(attrs, search_param, options = {})
25
+ cards = options.key?(:list) ? options[:list].cards : Board.current!.cards
26
+
27
+ attrs.split('_or_').map do |a|
28
+ cards.select do |c|
29
+ # `search_param` may be a normal string or a string representing a regular expression
30
+ c[a].match /#{ search_param }/i or c[a].downcase.include?(search_param.downcase)
31
+ end
32
+ end.flatten
33
+ end
34
+
35
+ # If the `unique` option is present, an exception must be raised if:
36
+ # - More than one exact match was found
37
+ # - Zero exact matches were found but more than one partial match was found
38
+ # If `unique` not present, return the first match giving preference to exact matches
39
+ #
40
+ def self.find_by_partial_attribute!(attrs, search_param, options = {})
41
+ must_unique = options[:unique] == true
42
+
43
+ # Find partial (and exact) matches
44
+ matches = find_all_by_partial_attribute(attrs, search_param, options)
45
+
46
+ # If nothing was found, raise an exception
47
+ raise NotFound, "The specified card does not exist." if matches.empty?
48
+
49
+ # Refine the search and retrieve only exact matches
50
+ exact_matches = attrs.split('_or_').map do |a|
51
+ matches.select { |c| c[a].casecmp(search_param).zero? }
52
+ end.flatten
53
+
54
+ if (must_unique and exact_matches.length == 1) or (!must_unique and !exact_matches.empty?)
55
+ exact_matches.first
56
+ elsif matches.length > 1 and must_unique
57
+ raise MultipleDocumentsFound, "Multiple cards match the given criteria."
58
+ else
59
+ matches.first
60
+ end
61
+ end
62
+
63
+ # FIXME With humanize "foo_id" becomes "Foo" instead of "Foo Id" which may be confusing
64
+ def pretty_attributes(to_print = [ 'labels', 'participants', 'more' ])
65
+ attrs = self.attributes.dup
66
+ attrs.delete_if { |k, v| v.blank? or k.eql? 'more' or not to_print.include? k }
67
+ attrs.merge! self.more
68
+ max_size = attrs.keys.max_by { |k| k.humanize.size }.humanize.size unless attrs.empty?
69
+
70
+ attrs.map do |key, value|
71
+ case value
72
+ when Array
73
+ "#{ (key.humanize + ":").ljust(max_size + 2) } #{ value.join(', ') }"
74
+ else
75
+ "#{ (key.humanize + ":").ljust(max_size + 2) } #{ value }"
76
+ end
77
+ end.compact.join("\n")
78
+ end
79
+
80
+ def edit_file
81
+ board = Board.current!
82
+ changed = false
83
+
84
+ adapter.client.with_stash_and_branch(board.id) do
85
+ Dir.chdir(board.root) do
86
+ yield filepath if block_given?
87
+ end
88
+
89
+ data = File.read(File.join(board.root, filepath))
90
+ self.attributes = Card.adapter.decode(data)
91
+ changed = self.changed?
92
+
93
+ save! if changed
94
+ adapter.client.git.reset(hard: true)
95
+ end
96
+ changed
97
+ end
98
+
99
+ private
100
+
101
+ def self.method_missing(meth, *args, &block)
102
+ if meth.to_s =~ /^find_all_by_partial_(.+)$/
103
+ find_all_by_partial_attribute($1, *args)
104
+ elsif meth.to_s =~ /^find_by_partial_(.+)!$/
105
+ find_by_partial_attribute!($1, *args)
106
+ else
107
+ super
108
+ end
109
+ end
110
+
111
+ # ToyStore supports adapters per model but this program needs an adapter per instance
112
+ def self.with_adapter(branch, root)
113
+ current_client = adapter.client
114
+ current_options = adapter.options
115
+
116
+ adapter :git, Kood.repo(root), branch: branch, path: 'cards'
117
+ adapter.file_extension = 'md'
118
+ yield
119
+ ensure
120
+ adapter :git, current_client, current_options
121
+ adapter.file_extension = 'md'
122
+ end
123
+
124
+ def filepath
125
+ File.join('cards', id) + ".#{ adapter.file_extension }"
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,121 @@
1
+ require 'thor'
2
+ require 'thor/group'
3
+
4
+ require_relative 'cli/helpers/table'
5
+ require_relative 'cli/board'
6
+ require_relative 'cli/switch'
7
+ require_relative 'cli/list'
8
+ require_relative 'cli/card'
9
+ require_relative 'cli/edit'
10
+ require_relative 'cli/plugin'
11
+
12
+ class Kood::CLI < Thor
13
+ namespace :kood
14
+
15
+ class_option 'debug', :desc => "Run Kood in debug mode", :type => :boolean
16
+ class_option 'color', :desc => "Colorization in output", :type => :boolean,
17
+ :default => Kood::Shell.color_support?
18
+
19
+ check_unknown_options!
20
+
21
+ # Thor help is not used for subcommands. Docs for each subcommand are written in the
22
+ # man files.
23
+ #
24
+ # If the `default` key of `method_option` is a method, it will be executed each time
25
+ # this program is called. As an alternative, a default value is specified inside each
26
+ # method. So for example instead of:
27
+ #
28
+ # method_option ... :default => foo()
29
+ # def bar(arg)
30
+ # ...
31
+ # end
32
+ #
33
+ # The following is done:
34
+ #
35
+ # method_option ...
36
+ # def bar(arg)
37
+ # arg = foo() if arg.nil?
38
+ # end
39
+
40
+ # Invoked with `kood --help`, `kood help`, `kood help <cmd>` and `kood --help <cmd>`
41
+ def help(cli = nil)
42
+ case cli
43
+ when nil then command = "kood"
44
+ when "boards" then command = "kood-board"
45
+ when "select" then command = "kood-switch"
46
+ when "lists" then command = "kood-list"
47
+ when "cards" then command = "kood-card"
48
+ else command = "kood-#{cli}"
49
+ end
50
+
51
+ manpages = %w(
52
+ kood-board
53
+ kood-switch
54
+ kood-list
55
+ kood-card)
56
+
57
+ if manpages.include? command # Present a man page for the command
58
+ root = File.expand_path("../../../man", __FILE__)
59
+ exec "man #{ root }/#{ command }.1"
60
+ else
61
+ super # Use thor to output help
62
+ end
63
+ end
64
+
65
+ desc "version", "Print kood's version information"
66
+ map ['--version', '-v'] => :version
67
+ def version
68
+ puts "kood version #{ Kood::VERSION }"
69
+ end
70
+
71
+ private
72
+
73
+ # Reimplement the `start` method in order to catch raised exceptions
74
+ # For example, when running `kood c`, Thor will raise "Ambiguous task c matches ..."
75
+ # FIXME Should not be necessary, since Thor catches exceptions when not in debug mode
76
+ def self.start(given_args=ARGV, config={})
77
+ Grit.debug = given_args.include?('--debug')
78
+ super
79
+ rescue Exception => e
80
+ if given_args.include? '--debug'
81
+ puts e.inspect
82
+ puts e.backtrace
83
+ elsif given_args.include? '--no-color'
84
+ puts e
85
+ else
86
+ puts "\e[31m#{ e }\e[0m"
87
+ end
88
+ end
89
+
90
+ def no_method_options?
91
+ (options.keys - self.class.class_options.keys).empty?
92
+ end
93
+
94
+ def ok(text)
95
+ # An idea from [git.io/logbook](//git.io/logbook). You should check it out.
96
+ if options.color?
97
+ say text, :green
98
+ else
99
+ puts text
100
+ end
101
+ end
102
+
103
+ def error(text)
104
+ if options.color?
105
+ say text, :red
106
+ else
107
+ puts text
108
+ end
109
+ end
110
+ end
111
+
112
+ # Third-party commands (plugins)
113
+ #
114
+ Kood::CLI.load_plugins
115
+
116
+ # Warn users that non-ascii characters in the arguments may cause errors
117
+ #
118
+ if ARGV.any? { |arg| not arg.ascii_only? }
119
+ puts "For now, please avoid non-ascii characters. We're still working on providing" \
120
+ " full support for utf-8 encoding."
121
+ end
@@ -0,0 +1,139 @@
1
+ class Kood::CLI < Thor
2
+
3
+ desc "board [OPTIONS] [<BOARD-ID>]", "Display and manage boards"
4
+ #
5
+ # Delete a board. If <board-id> is present, the specified board will be deleted.
6
+ # With no arguments, the current board will be deleted.
7
+ method_option :delete, :aliases => '-d', :type => :boolean
8
+ #
9
+ # Copy a board. <board-id> will be copied to <new-board-id>.
10
+ # <board-id> will be kept intact and a new one is created with the exact same data.
11
+ method_option :copy, :aliases => '-c', :type => :string
12
+ #
13
+ # Create a board in an external repository.
14
+ method_option :repo, :aliases => '-r', :type => :string
15
+ def board(board_id = nil)
16
+ # If no arguments and options are present, the command displays all existing boards
17
+ if board_id.nil? and no_method_options?
18
+ list_existing_boards
19
+
20
+ # If the <board-id> argument is present without options, a new board will be created
21
+ elsif no_method_options? or options.repo.present?
22
+ create_board(board_id)
23
+
24
+ else # Since <board-id> is present, operate on the specified board
25
+ operate_on_board(board_id)
26
+ end
27
+ end
28
+ map 'boards' => 'board'
29
+
30
+ private
31
+
32
+ def operate_on_board(board_id)
33
+ board = get_board_or_current!(board_id) # Raises exception if inexistent
34
+
35
+ copy_board(board) if options.copy.present?
36
+ delete_board(board_id) if options.key? 'delete'
37
+ end
38
+
39
+ def list_existing_boards
40
+ return error "No boards were found." if Kood.config.boards.empty?
41
+
42
+ max_board_id = Kood.config.boards.max_by { |b| b.id.size }.id.size
43
+ Kood.config.boards.each do |b|
44
+ marker = b.is_current? ? "* " : " "
45
+ visibility = b.published? ? "shared" : "private"
46
+ repo_path = b.root.gsub(/^#{ ENV['HOME'] }/, "~") if b.external?
47
+ visibility = "(#{ visibility }#{ ' at '+ repo_path if b.external? })"
48
+ visibility = set_color(visibility, :black, :bold) if options.color?
49
+ puts marker + b.id.to_s.ljust(max_board_id + 2) + visibility
50
+ end
51
+ end
52
+
53
+ def create_board(board_id)
54
+ board = Kood.config.boards.create(id: board_id, custom_repo: options['repo'])
55
+
56
+ unless board.persisted?
57
+ msgs = board.errors.full_messages.join("\n")
58
+ return error "#{ msgs.gsub('Id', 'Board ID') }."
59
+ end
60
+
61
+ if Kood.config.boards.size == 1
62
+ board.select
63
+ ok "Board created and selected."
64
+ else
65
+ ok "Board created."
66
+ end
67
+ end
68
+
69
+ def copy_board(board)
70
+ end
71
+
72
+ def delete_board(board_id)
73
+ Kood.config.boards.destroy(board_id)
74
+ ok "Board deleted."
75
+ end
76
+
77
+ def get_board_or_current!(board_id)
78
+ board_id.nil? ? Kood::Board.current! : Kood::Board.get!(board_id)
79
+ end
80
+
81
+ def print_board(board)
82
+ opts = options.color? ? { color: [:black, :bold] } : {}
83
+ num_lists = board.list_ids.size
84
+ header = Kood::Table.new(num_lists)
85
+ body = Kood::Table.new(num_lists)
86
+
87
+ board.lists.each do |list|
88
+ # Setup the header of the table
89
+ header.new_column.add_row(list.id, align: 'center', separator: false)
90
+
91
+ # Setup the body of the table
92
+ column = body.new_column
93
+ list.cards.each do |card|
94
+ card_info = card.title
95
+ if options.color?
96
+ colored_separator = color_separator_with_labels(column.separator, card.labels)
97
+ column.add_row(colored_separator, slice: false)
98
+ else
99
+ labels = card.labels.uniq.map { |l| "##{ l }" }.join(", ")
100
+ card_info += " #{ labels }" unless labels.blank?
101
+ column.add_row(column.separator, separator: false)
102
+ end
103
+ column.add_row(card_info, separator: false)
104
+ column.add_row(card.id.slice(0, 8), opts.merge(separator: false))
105
+ end
106
+ column.add_row(column.separator, slice: false)
107
+ end
108
+
109
+ # Setup the title of the table
110
+ title = Kood::Table.new(1, body.width)
111
+ title.new_column.add_row(board.id, align: 'center')
112
+
113
+ out = [ title.to_s(separator: false) ]
114
+ out << header.separator('first') << header
115
+ out << body unless body.to_s.empty?
116
+ out << body.separator('last')
117
+
118
+ # `join` is used to prevent partial content from being printed if an exception occurs
119
+ puts out.join("\n")
120
+ end
121
+
122
+ def color_separator_with_labels(separator, labels)
123
+ return separator if labels.blank? or not options.color?
124
+
125
+ hbar = Kood::Shell.horizontal_bar
126
+ colored_bars = labels.map { |l| set_color(hbar * 3, label_to_color(l)) }.uniq
127
+
128
+ if colored_bars.length * 3 > separator.length
129
+ colored_bars = colored_bars[0...separator.length/3-1]
130
+ colored_bars << set_color(hbar * 3, :black, :bold)
131
+ end
132
+
133
+ separator[0...-colored_bars.length*3] + colored_bars.join
134
+ end
135
+
136
+ def label_to_color(label)
137
+ (Kood.config.labels[label] || 'blue').to_sym
138
+ end
139
+ end
@@ -0,0 +1,188 @@
1
+ require 'active_support/core_ext/hash/except'
2
+
3
+ class Kood::CLI < Thor
4
+
5
+ desc "card [OPTIONS] [<CARD-ID|CARD-TITLE>]", "Display and manage cards"
6
+ #
7
+ # Delete a card. If <card-title> is present, the specified card will be deleted.
8
+ method_option :delete, :aliases => '-d', :type => :boolean
9
+ #
10
+ # Copy a card. <card-title> will be copied to a new card in the given list.
11
+ # <card-title> will be kept intact and a new one is created with the exact same data.
12
+ method_option :copy, :aliases => '-c', :type => :string
13
+ #
14
+ # Show status information of the current board associated with the given user.
15
+ method_option :participant, :aliases => '-p', :type => :string
16
+ #
17
+ # Does the card action in the given list.
18
+ method_option :list, :aliases => '-l', :type => :string
19
+ #
20
+ # Launches the configured editor to modify the card
21
+ method_option :edit, :aliases => '-e', :type => :boolean
22
+ #
23
+ # Set and unset properties (add and remove are useful for properties that are arrays)
24
+ method_option :set, :aliases => '-s', :type => :hash
25
+ method_option :unset, :aliases => '-u', :type => :array
26
+ method_option :add, :aliases => '-a', :type => :array
27
+ method_option :remove, :aliases => '-r', :type => :array
28
+ def card(card_id_or_title = nil)
29
+ Kood::Board.current!.with_context do |current_board|
30
+ card_title = card_id = card_id_or_title
31
+
32
+ # If no arguments and options are specified, display all existing cards
33
+ if card_title.nil? and no_method_options?
34
+ return error "No lists were found." if current_board.lists.empty?
35
+ print_board(current_board)
36
+
37
+ # If <card-title> is present without options, display the card with given ID or title
38
+ elsif card_id_or_title and no_method_options?
39
+ card = Kood::Card.find_by_partial_id_or_title!(card_id_or_title)
40
+ print_card(current_board, card)
41
+
42
+ else # If <card-title> and the `list` option are present, a new card is created
43
+ create_card(card_id_or_title) if card_id_or_title and options.list.present?
44
+
45
+ # Since <card-title> is present, operate on the specified card
46
+ operate_on_card(current_board, card_id)
47
+ end
48
+ end
49
+ end
50
+ map 'cards' => 'card'
51
+
52
+ private
53
+
54
+ def operate_on_card(current_board, card_id_or_title, card_id = card_id_or_title)
55
+ card = Kood::Card.find_by_partial_id_or_title!(card_id_or_title)
56
+
57
+ copy_card(card) if options.copy.present?
58
+ delete_card(card_id) if options.key? 'delete'
59
+ return edit(card_id) if options.edit.present? # Execute the `edit` task
60
+
61
+ if options.any? { |k,v| %w{ set unset add remove }.include? k }
62
+ set_card_attributes(card) if options.set.present?
63
+ unset_card_attributes(card) if options.unset.present?
64
+ insert_into_card_array_attribute(current_board, card) if options.add.present?
65
+ remove_from_card_array_attribute(current_board, card) if options.remove.present?
66
+
67
+ if card.changed?
68
+ card.save!
69
+ ok "Card updated."
70
+ else
71
+ error "No changes to persist."
72
+ end
73
+ end
74
+ end
75
+
76
+ def create_card(card_title)
77
+ list = Kood::List.get! options.list
78
+ list.cards.create(title: card_title, list: list)
79
+ ok "Card created."
80
+ end
81
+
82
+ def copy_card(card) # TODO Support card copy between boards
83
+ list = options.copy.eql?('copy') ? card.list : Kood::List.get!(options.copy)
84
+ list.cards.create(card.dup.attributes.except 'date', list: list)
85
+ ok "Card copied."
86
+ end
87
+
88
+ def delete_card(card_id_or_title)
89
+ card = Kood::Card.find_by_partial_id_or_title!(card_id_or_title, unique: true)
90
+ list = card.list
91
+ list.cards.destroy(card.id)
92
+ ok "Card deleted."
93
+ end
94
+
95
+ # Iterates over all pairs of `options.set` and:
96
+ # - If a key is an attribute of card, sets its value
97
+ # - If a key is not an attribute of card, set it in the `more` hash, which is used to
98
+ # store custom attributes defined by the user
99
+ #
100
+ # Example: kood card lorem --set title:lorem description:"lorem ipsum" foo:bar
101
+ #
102
+ def set_card_attributes(card)
103
+ options.set.each do |key, value|
104
+ value = Kood::Shell.type_cast(value) # Convert to float or int if possible
105
+
106
+ if Kood::Card.attribute? key and not %w{ list list_id more }.include? key.to_s
107
+ card[key] = value
108
+ else
109
+ card.more ||= {}
110
+ card.more = card.more.merge(key => value) # Has to be "card.more=" to be consi-
111
+ end # dered changed (merge! wouldn't work)
112
+ end
113
+ end
114
+
115
+ # It operates in the same way of the `set_card_attributes` method but iterates over
116
+ # the `options.unset` array, instead of an hash
117
+ #
118
+ # Example: kood card lorem --unset title description labels
119
+ #
120
+ def unset_card_attributes(card)
121
+ options.unset.each do |key|
122
+ if Kood::Card.attribute? key and not %w{ title list list_id more }.include? key
123
+ card[key] = nil
124
+ else
125
+ card.more ||= {}
126
+ card.more = card.more.except(key) # Has to be "card.more=" to be considered changed
127
+ end
128
+ end
129
+ end
130
+
131
+ def update_card_array_attribute(current_board, card, opt)
132
+ values = opt.eql?(:+) ? options.add : options.remove
133
+ key = values.shift
134
+
135
+ if key.eql? 'participants'
136
+ board_members = values.map do |v|
137
+ current_board.find_potential_member_by_partial_name_or_email(v) || v
138
+ end
139
+ # Since this command may be called by other users with distinct boards, the found
140
+ # board members may be different, so always keep the typed values if this is about
141
+ # removing participants
142
+ values = opt.eql?(:+) ? board_members : (values + board_members)
143
+ end
144
+
145
+ if Kood::Card.attribute? key and Kood::Card.attributes[key].type.eql? Array
146
+ card[key] ||= []
147
+ card[key] = opt.eql?(:+) ? (card[key] + values) : (card[key] - values)
148
+ else
149
+ begin
150
+ card.more ||= {}
151
+ card.more[key] ||= []
152
+ new_value = opt.eql?(:+) ? (card.more[key] + values) : (card.more[key] - values)
153
+ card.more = card.more.merge(key => new_value) # Has to be "card.more=" to be consi-
154
+ rescue TypeError, NoMethodError # dered changed (merge! wouldn't work)
155
+ raise Kood::TypeError, "Can't convert the attribute into a list."
156
+ end
157
+ end
158
+ end
159
+
160
+ # TODO Example: kood card lorem --add participants David Diogo -a labels bug
161
+ def insert_into_card_array_attribute(current_board, card)
162
+ update_card_array_attribute(current_board, card, :+)
163
+ end
164
+
165
+ # TODO Example: kood card lorem --remove participants David
166
+ def remove_from_card_array_attribute(current_board, card)
167
+ update_card_array_attribute(current_board, card, :-)
168
+ end
169
+
170
+ def print_card(board, card)
171
+ # The board's table may not use all terminal's horizontal space. In order to keep
172
+ # things visually similar / aligned, the card table should have the same width.
173
+ width = Kood::Table.new(board.list_ids.size).width
174
+
175
+ table = Kood::Table.new(1, width)
176
+ col = table.new_column
177
+ attribute_list = card.pretty_attributes
178
+ col.add_row(card.title, separator: !(card.content.empty? and attribute_list.empty?))
179
+ col.add_row(card.content) unless card.content.empty?
180
+ col.add_row(attribute_list) unless attribute_list.empty?
181
+
182
+ opts = options.color? ? { color: [:black, :bold] } : {}
183
+ col.add_row("#{ card.id } (created at #{ card.date })", opts)
184
+
185
+ # `join` is used to prevent partial content from being printed if an exception occurs
186
+ puts [table.separator('first'), table, table.separator('last')].join("\n")
187
+ end
188
+ end