omniboard 1.1.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.
- checksums.yaml +7 -0
- data/HISTORY.md +71 -0
- data/README.md +404 -0
- data/Rakefile +3 -0
- data/bin/omniboard +43 -0
- data/lib/omniboard.rb +25 -0
- data/lib/omniboard/colour.rb +15 -0
- data/lib/omniboard/column.rb +368 -0
- data/lib/omniboard/columns/active.rb +17 -0
- data/lib/omniboard/columns/backburner.rb +5 -0
- data/lib/omniboard/columns/completed.rb +5 -0
- data/lib/omniboard/columns/config.rb +11 -0
- data/lib/omniboard/core_ext.rb +22 -0
- data/lib/omniboard/group.rb +104 -0
- data/lib/omniboard/omniboard.rb +149 -0
- data/lib/omniboard/project_wrapper.rb +119 -0
- data/lib/omniboard/property.rb +53 -0
- data/lib/omniboard/renderer.rb +50 -0
- data/lib/omniboard/styled_text.rb +63 -0
- data/lib/omniboard/styled_text_element.rb +40 -0
- data/lib/omniboard/templates/column.erb +25 -0
- data/lib/omniboard/templates/postamble.erb +226 -0
- data/lib/omniboard/templates/preamble.erb +346 -0
- data/lib/omniboard/templates/project.erb +15 -0
- data/omniboard.gemspec +23 -0
- data/version.txt +1 -0
- metadata +100 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
Omniboard::Column.new "Active" do
|
2
|
+
order 1
|
3
|
+
conditions{ |p| p.active? }
|
4
|
+
|
5
|
+
width 2
|
6
|
+
columns 4
|
7
|
+
|
8
|
+
display :full
|
9
|
+
|
10
|
+
icon do |p|
|
11
|
+
if p.incomplete_tasks.size == 0
|
12
|
+
"svg:hanging"
|
13
|
+
elsif p.actionable_tasks.all?{ |t| t.context && t.context.name == "Waiting for..." }
|
14
|
+
"svg:waiting-on"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# Place global config variables here. See the README for config variables
|
2
|
+
Omniboard::Column.config do
|
3
|
+
# Group projects by their containing folders
|
4
|
+
group_by{ |p| p.container || "" }
|
5
|
+
|
6
|
+
# Sort by rank, with top level projects at the top
|
7
|
+
sort_groups{ |c| c.nil? ? 0 : c.rank }
|
8
|
+
|
9
|
+
# Name based on ancestry
|
10
|
+
group_name{ |c| c.nil? ? "Top level" : c.ancestry.map(&:name).reverse.join("→")}
|
11
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class String
|
2
|
+
HTML_ESCAPE_CHARS = {
|
3
|
+
"&" => "&",
|
4
|
+
">" => ">",
|
5
|
+
"<" => "<",
|
6
|
+
'"' => """,
|
7
|
+
"'" => "'"
|
8
|
+
}
|
9
|
+
|
10
|
+
def html_escape
|
11
|
+
HTML_ESCAPE_CHARS.reduce(self){ |str, (replace_this, with_this)| str.gsub(replace_this, with_this) }
|
12
|
+
end
|
13
|
+
|
14
|
+
# TODO Fix up all of these
|
15
|
+
def sanitize
|
16
|
+
self.gsub('"','\"').gsub("<","<").gsub(">", ">")
|
17
|
+
end
|
18
|
+
|
19
|
+
def sanitize_quotes
|
20
|
+
self.gsub('"','"')
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# This class represents a "group" of Projects.
|
2
|
+
class Omniboard::Group
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
# The identifier for this group. Can be a string, object, whatever you like.
|
6
|
+
attr_accessor :identifier
|
7
|
+
|
8
|
+
# Deprecated methods
|
9
|
+
def name
|
10
|
+
$stderr.puts "Group#name is deprecated. Use Group#identifier instead."
|
11
|
+
@identifier
|
12
|
+
end
|
13
|
+
|
14
|
+
def name= n
|
15
|
+
$stderr.puts "Group#name= is deprecated. Use Group#identifier= instead."
|
16
|
+
@identifier = n
|
17
|
+
end
|
18
|
+
|
19
|
+
# The colour representation of a group
|
20
|
+
attr_accessor :colour
|
21
|
+
|
22
|
+
# Array of all groups in the board
|
23
|
+
@groups = []
|
24
|
+
|
25
|
+
class << self
|
26
|
+
# Global values for group colour brightness and saturation
|
27
|
+
attr_accessor :brightness, :saturation
|
28
|
+
|
29
|
+
# All groups in the board
|
30
|
+
attr_accessor :groups
|
31
|
+
|
32
|
+
# Find a group by identifier
|
33
|
+
def [] identifier
|
34
|
+
@groups.find{ |g| g.identifier == identifier } || new(identifier)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Add a new group to the groups array. Also resets all colour assignments for the group.
|
38
|
+
# Note: usually called from initializer.
|
39
|
+
def add(g)
|
40
|
+
@groups.each(&:reset_colour)
|
41
|
+
@groups << g
|
42
|
+
end
|
43
|
+
|
44
|
+
# If we don't have any groups on our board, this is our default
|
45
|
+
# colour for ungrouped projects
|
46
|
+
def default_colour
|
47
|
+
Omniboard::Colour.new(0).standard
|
48
|
+
end
|
49
|
+
alias_method :colour, :default_colour
|
50
|
+
|
51
|
+
# If we don't have any groups on our board, this is our default
|
52
|
+
# light colour for ungrouped projects
|
53
|
+
def light_colour
|
54
|
+
Omniboard::Colour.new(0).light
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def initialize(identifier)
|
59
|
+
raise(ArgumentError, "nil identifier not allowed") if identifier.nil?
|
60
|
+
@identifier = identifier
|
61
|
+
Omniboard::Group.add(self)
|
62
|
+
end
|
63
|
+
|
64
|
+
def colour
|
65
|
+
colour_obj.standard
|
66
|
+
end
|
67
|
+
|
68
|
+
def light_colour
|
69
|
+
colour_obj.light
|
70
|
+
end
|
71
|
+
|
72
|
+
def reset_colour
|
73
|
+
@colour_obj = nil
|
74
|
+
end
|
75
|
+
|
76
|
+
def assigned_colour
|
77
|
+
Omniboard::Column::colour_for_group(self.identifier)
|
78
|
+
end
|
79
|
+
|
80
|
+
def has_assigned_colour?
|
81
|
+
assigned_colour != nil
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_s
|
85
|
+
@identifier
|
86
|
+
end
|
87
|
+
|
88
|
+
def <=> o
|
89
|
+
self.identifier <=> o.identifier
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
def colour_obj
|
94
|
+
@colour_obj ||= begin
|
95
|
+
my_hue = if has_assigned_colour?
|
96
|
+
assigned_colour
|
97
|
+
else
|
98
|
+
unassigned_groups = Omniboard::Group.groups.select{ |g| !g.has_assigned_colour? }
|
99
|
+
360.0 / unassigned_groups.size * unassigned_groups.index(self)
|
100
|
+
end
|
101
|
+
Omniboard::Colour.new(my_hue)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
module Omniboard
|
2
|
+
@configuration = {}
|
3
|
+
|
4
|
+
class << self
|
5
|
+
# Retrieve a configuration variable
|
6
|
+
def configuration(key)
|
7
|
+
@configuration[key]
|
8
|
+
end
|
9
|
+
|
10
|
+
# Set configuration variables from a dictionary
|
11
|
+
def configuration=(hsh)
|
12
|
+
@configuration = @configuration.merge(hsh)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Set individual configuration variable
|
16
|
+
def configure(key,val)
|
17
|
+
@configuration[key] = val
|
18
|
+
end
|
19
|
+
|
20
|
+
#---------------------------------------
|
21
|
+
# Config location methods
|
22
|
+
|
23
|
+
# Set location where we'll store the configuration files
|
24
|
+
def config_location= loc
|
25
|
+
self.configure(:config_location, loc)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Retrieve configuration_location
|
29
|
+
def config_location
|
30
|
+
@configuration[:config_location] || default_config_location
|
31
|
+
end
|
32
|
+
|
33
|
+
# Database usually exists here
|
34
|
+
def default_config_location
|
35
|
+
@default_config_location ||= File.join(ENV["HOME"], ".omniboard")
|
36
|
+
end
|
37
|
+
|
38
|
+
# Does the config folder exist?
|
39
|
+
def config_exists?
|
40
|
+
File.exists?(config_location)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Populate with base classes
|
44
|
+
def populate
|
45
|
+
|
46
|
+
# 1. Make the columns folder
|
47
|
+
FileUtils::mkdir_p columns_location
|
48
|
+
|
49
|
+
# 2. Drop base columns + config into columns folder
|
50
|
+
Dir[File.join(__dir__, "columns/*.rb")].each{ |f| FileUtils::cp f, columns_location }
|
51
|
+
|
52
|
+
# 3. Add blank "custom.css" file
|
53
|
+
custom_css = File.join(config_location, "custom.css")
|
54
|
+
File.open(custom_css,"w"){ |io| io.puts "/* Custom CSS goes here */" }
|
55
|
+
end
|
56
|
+
|
57
|
+
#---------------------------------------
|
58
|
+
# Column-centric methods
|
59
|
+
|
60
|
+
# This is where we store columns
|
61
|
+
def columns_location
|
62
|
+
File.join(config_location, "columns")
|
63
|
+
end
|
64
|
+
|
65
|
+
# Fetch all columns!
|
66
|
+
def columns
|
67
|
+
@columns ||= begin
|
68
|
+
Dir[File.join(columns_location, "*")].each{ |f| require(f) } if File.exists?(columns_location)
|
69
|
+
Omniboard::Column.columns
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
#---------------------------------------
|
74
|
+
# Project-centric methods
|
75
|
+
|
76
|
+
def projects
|
77
|
+
@document.projects
|
78
|
+
end
|
79
|
+
|
80
|
+
#---------------------------------------
|
81
|
+
# Document-centric methods
|
82
|
+
|
83
|
+
# This is where our serialised document is located
|
84
|
+
def document_location
|
85
|
+
File.join(config_location, "db.yaml")
|
86
|
+
end
|
87
|
+
|
88
|
+
# Do we already have a serialised document?
|
89
|
+
def document_exists?
|
90
|
+
File.exists?(document_location)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Load document from file
|
94
|
+
def load_document
|
95
|
+
@document = YAML::load_file(document_location)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Save document to file
|
99
|
+
def save_document
|
100
|
+
FileUtils::mkdir_p config_location unless File.exists?(config_location)
|
101
|
+
File.open(document_location, "w"){ |io| io.puts YAML.dump(@document) }
|
102
|
+
end
|
103
|
+
|
104
|
+
# Update from an existing rubyfocus document
|
105
|
+
def update_document
|
106
|
+
@document.update
|
107
|
+
end
|
108
|
+
|
109
|
+
# Create a new document. For now, local documents are the only sort supported
|
110
|
+
def create_document
|
111
|
+
@document = Rubyfocus::Document.new(Rubyfocus::LocalFetcher.new)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Arbitrarily assign a document object. Normally used only in debugging
|
115
|
+
attr_writer :document
|
116
|
+
|
117
|
+
#---------------------------------------
|
118
|
+
# Methods for reaching head
|
119
|
+
def document_at_head?
|
120
|
+
raise(RuntimeError, "Called Omniboard.document_at_head? before document loaded") if @document.nil?
|
121
|
+
return (
|
122
|
+
@document.fetcher.nil? || # Always at head if we have no fetcher
|
123
|
+
@document.fetcher.head == @document.patch_id
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
def document_can_reach_head?
|
128
|
+
raise(RuntimeError, "Called Omniboard.document_can_reach_head? before document loaded") if @document.nil?
|
129
|
+
return @document.fetcher.can_reach_head_from?(@document.patch_id)
|
130
|
+
end
|
131
|
+
|
132
|
+
def current_id; @document.patch_id; end
|
133
|
+
def head_id; @document.fetcher.head; end
|
134
|
+
|
135
|
+
#---------------------------------------
|
136
|
+
# Config-related methods
|
137
|
+
|
138
|
+
# Fetches any custom CSS stored in `custom.css` inside the config folder
|
139
|
+
def custom_css
|
140
|
+
custom_css_path = File.join(config_location, "custom.css")
|
141
|
+
if File.exists?(custom_css_path)
|
142
|
+
File.read(custom_css_path)
|
143
|
+
else
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
class Omniboard::ProjectWrapper
|
2
|
+
attr_accessor :project
|
3
|
+
attr_accessor :column
|
4
|
+
attr_accessor :group
|
5
|
+
|
6
|
+
# Is this project marked to show up specially?
|
7
|
+
attr_accessor :marked
|
8
|
+
alias_method :marked?, :marked
|
9
|
+
|
10
|
+
# Is this project dimmed (i.e. shown "faded out")
|
11
|
+
attr_accessor :dimmed
|
12
|
+
alias_method :dimmed?, :dimmed
|
13
|
+
|
14
|
+
# Should this project display an icon in the kanban? If so, what's the filename of the icon?
|
15
|
+
attr_accessor :icon
|
16
|
+
|
17
|
+
# If this project displayed an icon, we can supply an alternate text
|
18
|
+
attr_accessor :icon_alt
|
19
|
+
|
20
|
+
# Create a new project wrapper, wrapping project
|
21
|
+
def initialize(project, column: nil)
|
22
|
+
@project = project
|
23
|
+
@column = column
|
24
|
+
@marked = false
|
25
|
+
end
|
26
|
+
|
27
|
+
#---------------------------------------
|
28
|
+
# Colour methods
|
29
|
+
def colour
|
30
|
+
(@group || Omniboard::Group).colour
|
31
|
+
end
|
32
|
+
|
33
|
+
def light_colour
|
34
|
+
(@group || Omniboard::Group).light_colour
|
35
|
+
end
|
36
|
+
|
37
|
+
#---------------------------------------
|
38
|
+
# Number of tasks
|
39
|
+
def num_tasks
|
40
|
+
@num_tasks ||= project.incomplete_tasks.count
|
41
|
+
end
|
42
|
+
|
43
|
+
#---------------------------------------
|
44
|
+
# Runs on method missing. Allows project to step in and take
|
45
|
+
# the method if it can.
|
46
|
+
def method_missing(sym, *args, &blck)
|
47
|
+
if @project.respond_to?(sym)
|
48
|
+
@project.send(sym, *args, &blck)
|
49
|
+
else
|
50
|
+
raise NoMethodError, "undefined method #{sym} for #{self.inspect}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
#---------------------------------------
|
55
|
+
# Turn this project's tasks into a list
|
56
|
+
def task_list
|
57
|
+
tasks_to_list(@project.tasks)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Format this project's note field to create nice html
|
61
|
+
def formatted_note
|
62
|
+
@formatted_note ||= Omniboard::StyledText.parse(self.note || "").to_html
|
63
|
+
end
|
64
|
+
|
65
|
+
def deferred_date
|
66
|
+
if self.start
|
67
|
+
self.start.strftime("%d %B %Y")
|
68
|
+
else
|
69
|
+
""
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def due_date
|
74
|
+
if self.due
|
75
|
+
self.due.strftime("%d %B %Y")
|
76
|
+
else
|
77
|
+
""
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# A list of CSS classes to apply to this project
|
82
|
+
def css_classes
|
83
|
+
classes = ["project", column.display]
|
84
|
+
classes << "marked" if marked?
|
85
|
+
classes << "dimmed" if dimmed?
|
86
|
+
classes << "hidden" if dimmed? && column && column.property(:hide_dimmed)
|
87
|
+
classes
|
88
|
+
end
|
89
|
+
|
90
|
+
# Are all the available tasks in this project deferred?
|
91
|
+
def all_tasks_deferred?
|
92
|
+
next_tasks.size > 0 && actionable_tasks.size == 0
|
93
|
+
end
|
94
|
+
|
95
|
+
# How many days until one of our tasks becomes available?
|
96
|
+
def days_until_action
|
97
|
+
earliest_start = next_tasks.map(&:start).sort.first
|
98
|
+
if earliest_start.nil?
|
99
|
+
0
|
100
|
+
else
|
101
|
+
earliest_start = earliest_start.to_date
|
102
|
+
if earliest_start < Date.today
|
103
|
+
0
|
104
|
+
else
|
105
|
+
(earliest_start - Date.today).to_i
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def to_s
|
111
|
+
self.project.to_s
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
def tasks_to_list(arr)
|
116
|
+
arr = arr.sort_by(&:rank)
|
117
|
+
"<ul>" + arr.map { |task| %|<li class="#{task.completed? ? "complete" : "incomplete"}">#{task.name}| + (task.has_subtasks? ? tasks_to_list(task.tasks) : "") + "</li>" }.join("") + "</ul>"
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Omniboard::Property
|
2
|
+
module PropertyClassMethods
|
3
|
+
def property p, allowed_values: nil, munge: nil
|
4
|
+
define_method(p) do |*args|
|
5
|
+
ivar = "@#{p}"
|
6
|
+
if args.empty?
|
7
|
+
instance_variable_get(ivar)
|
8
|
+
elsif args.size == 1
|
9
|
+
# Set values!
|
10
|
+
new_value = args.first
|
11
|
+
if allowed_values && !new_value.nil? && !allowed_values.include?(new_value)
|
12
|
+
raise(ArgumentError, "attempted to set property #{p} to forbidden value #{new_value.inspect}")
|
13
|
+
else
|
14
|
+
if new_value # Only run munge for non-nil values
|
15
|
+
case munge
|
16
|
+
when Symbol
|
17
|
+
new_value = new_value.send(munge)
|
18
|
+
when Proc
|
19
|
+
new_value = munge[new_value]
|
20
|
+
when nil
|
21
|
+
else
|
22
|
+
raise ArgumentError, "Property munge must be a symbol or a block - you supplied a #{munge.class}."
|
23
|
+
end
|
24
|
+
end
|
25
|
+
instance_variable_set(ivar, new_value)
|
26
|
+
end
|
27
|
+
else
|
28
|
+
raise ArgumentError, "wrong number of arguments (#{args.size} for 0,1)"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Block properties can also take non-block arguments
|
34
|
+
def block_property p
|
35
|
+
define_method(p) do |*args, &blck|
|
36
|
+
ivar = "@#{p}"
|
37
|
+
if blck
|
38
|
+
instance_variable_set(ivar, blck)
|
39
|
+
elsif args.size == 1
|
40
|
+
instance_variable_set(ivar, args.first)
|
41
|
+
elsif args.empty?
|
42
|
+
instance_variable_get(ivar)
|
43
|
+
else
|
44
|
+
raise ArgumentError, "wrong number of arguments (#{args.size} for 0,1)"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.included mod
|
51
|
+
mod.extend PropertyClassMethods
|
52
|
+
end
|
53
|
+
end
|