notes-cli 1.1.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.
- checksums.yaml +6 -14
- data/.gitignore +44 -0
- data/.ruby-gemset +1 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.md +71 -0
- data/bin/notes +17 -1
- data/config.ru +4 -0
- data/lib/notes-cli.rb +111 -1
- data/lib/notes-cli/cli.rb +10 -52
- data/lib/notes-cli/{opts.rb → options.rb} +27 -10
- data/lib/notes-cli/server.rb +34 -0
- data/lib/notes-cli/stats.rb +40 -0
- data/lib/notes-cli/tasks.rb +170 -0
- data/lib/notes-cli/version.rb +1 -1
- data/lib/notes-cli/web.rb +39 -0
- data/notes-cli.gemspec +23 -0
- data/spec/notes_spec.rb +12 -0
- data/spec/options_spec.rb +125 -0
- data/spec/spec_helper.rb +5 -0
- data/spec/stats_spec.rb +34 -0
- data/spec/tasks_spec.rb +90 -0
- data/web/assets/javascripts/application.js +438 -0
- data/web/assets/javascripts/lib/backbone.min.js +1 -0
- data/web/assets/javascripts/lib/d3.min.js +5 -0
- data/web/assets/javascripts/lib/jquery-1.10.2.min.js +5 -0
- data/web/assets/javascripts/lib/underscore.min.js +5 -0
- data/web/assets/stylesheets/application.css +359 -0
- data/web/assets/stylesheets/reset.css +48 -0
- data/web/views/index.erb +40 -0
- data/web/views/layout.erb +92 -0
- metadata +53 -13
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require 'optparse'
|
3
|
+
require 'notes-cli/web'
|
4
|
+
|
5
|
+
module Notes
|
6
|
+
class Server
|
7
|
+
SERVER_DEFAULTS = {
|
8
|
+
:Host => '127.0.0.1',
|
9
|
+
:Port => '9292'
|
10
|
+
}
|
11
|
+
|
12
|
+
def initialize(argv)
|
13
|
+
@options = SERVER_DEFAULTS.merge(parse_options(argv))
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse_options(args)
|
17
|
+
options = {}
|
18
|
+
OptionParser.new do |opts|
|
19
|
+
opts.on('-p', '--port [PORT]', 'The port to run on') do |port|
|
20
|
+
options[:Port] = port
|
21
|
+
end
|
22
|
+
end.parse!(args)
|
23
|
+
|
24
|
+
options
|
25
|
+
end
|
26
|
+
|
27
|
+
def start
|
28
|
+
Rack::Handler::WEBrick.run(Notes::Web, @options) do |server|
|
29
|
+
[:INT, :TERM].each { |sig| trap(sig) { server.stop } }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module Notes
|
4
|
+
module Stats
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def compute(tasks)
|
8
|
+
{
|
9
|
+
flag_counts: flag_counts(tasks),
|
10
|
+
found_flags: found_flags(tasks)
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
# Take in a set of tasks and compute aggregate stats such as counts per
|
15
|
+
# flag. Intended to augment a JSON set
|
16
|
+
#
|
17
|
+
# tasks: Array[Notes::Task]
|
18
|
+
#
|
19
|
+
# Returns Hash
|
20
|
+
def flag_counts(tasks)
|
21
|
+
counts = Hash.new(0)
|
22
|
+
tasks.each do |task|
|
23
|
+
task.flags.each { |flag| counts[flag] += 1 }
|
24
|
+
end
|
25
|
+
counts
|
26
|
+
end
|
27
|
+
|
28
|
+
# Compute the distinct flags found in a a set of tasks
|
29
|
+
#
|
30
|
+
# tasks: Array[Notes::Task]
|
31
|
+
#
|
32
|
+
# Returns Array[String] of flag names
|
33
|
+
def found_flags(tasks)
|
34
|
+
flags = Set.new
|
35
|
+
tasks.each { |task| flags.merge(task.flags) }
|
36
|
+
flags.to_a
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
module Notes
|
2
|
+
|
3
|
+
class Task
|
4
|
+
attr_accessor :author, :date, :filename, :line_num,
|
5
|
+
:line, :flags, :context
|
6
|
+
|
7
|
+
def initialize(options={})
|
8
|
+
@author = options[:author]
|
9
|
+
@date = options[:date]
|
10
|
+
@sha = options[:sha]
|
11
|
+
@filename = options[:filename]
|
12
|
+
@line_num = options[:line_num]
|
13
|
+
@line = options[:line]
|
14
|
+
@flags = options[:flags]
|
15
|
+
@context = options[:context]
|
16
|
+
end
|
17
|
+
|
18
|
+
# Return a String in a format suitable for printing
|
19
|
+
# to the console that includes the line number
|
20
|
+
# and matched flag highlighted in color
|
21
|
+
#
|
22
|
+
# TODO: different colors for different flags
|
23
|
+
def to_s
|
24
|
+
flag_regex = Regexp.new(@flags.join('|'), true)
|
25
|
+
line = @line.gsub(flag_regex) do |flag|
|
26
|
+
Notes.colorize('yellow', flag)
|
27
|
+
end
|
28
|
+
|
29
|
+
"ln #{@line_num}: #{line.strip}"
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_json
|
33
|
+
{
|
34
|
+
filename: @filename,
|
35
|
+
line_num: @line_num,
|
36
|
+
line: @line,
|
37
|
+
flags: @flags,
|
38
|
+
context: @context,
|
39
|
+
author: @author,
|
40
|
+
date: @date,
|
41
|
+
sha: @sha
|
42
|
+
}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
module Tasks
|
48
|
+
extend self
|
49
|
+
|
50
|
+
# Return array of flags matched in a line
|
51
|
+
#
|
52
|
+
# line - A String to match against
|
53
|
+
# flags - An Array of String flags to search for
|
54
|
+
#
|
55
|
+
# Returns Array of string flags found
|
56
|
+
def matching_flags(line, flags)
|
57
|
+
words = line.split(/\W/).map(&:upcase)
|
58
|
+
words & flags.map(&:upcase)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Parse a file and construct Task objects for each line matching
|
62
|
+
# one of the patterns specified in `flags`
|
63
|
+
#
|
64
|
+
# filename - A String filename to read
|
65
|
+
# flags - Array of String flags to match against
|
66
|
+
#
|
67
|
+
# Returns Array<Notes::Task>
|
68
|
+
def for_file(filename, flags)
|
69
|
+
counter = 1
|
70
|
+
tasks = []
|
71
|
+
|
72
|
+
begin
|
73
|
+
lines = File.readlines(filename).map(&:chomp)
|
74
|
+
|
75
|
+
lines.each_with_index do |line, idx|
|
76
|
+
matched_flags = matching_flags(line, flags)
|
77
|
+
|
78
|
+
if matched_flags.any?
|
79
|
+
task_options = {
|
80
|
+
filename: Notes.shortname(filename),
|
81
|
+
line_num: counter,
|
82
|
+
line: line,
|
83
|
+
flags: matched_flags,
|
84
|
+
context: context_lines(lines, idx)
|
85
|
+
}
|
86
|
+
# Extract line information from git
|
87
|
+
info = line_info(filename, idx)
|
88
|
+
task_options[:author] = info[:author]
|
89
|
+
task_options[:date] = info[:date]
|
90
|
+
task_options[:sha] = info[:sha]
|
91
|
+
tasks << Notes::Task.new(task_options)
|
92
|
+
end
|
93
|
+
counter += 1
|
94
|
+
end
|
95
|
+
rescue
|
96
|
+
# Error occurred reading the file (ex: invalid byte sequence in UTF-8)
|
97
|
+
# Move on quietly
|
98
|
+
end
|
99
|
+
|
100
|
+
tasks
|
101
|
+
end
|
102
|
+
|
103
|
+
# Compute all tasks for a set of files and flags
|
104
|
+
#
|
105
|
+
# files - Array of String filenames
|
106
|
+
# options - Hash of options
|
107
|
+
# :flags - Array of String flags to match against
|
108
|
+
#
|
109
|
+
# Returns Array[Notes::Task]
|
110
|
+
def for_files(files, options)
|
111
|
+
flags = options[:flags]
|
112
|
+
result = []
|
113
|
+
files.each do |filename|
|
114
|
+
tasks = Notes::Tasks.for_file(filename, flags)
|
115
|
+
result += tasks
|
116
|
+
end
|
117
|
+
|
118
|
+
result
|
119
|
+
end
|
120
|
+
|
121
|
+
# Return list of tasks for the provided options
|
122
|
+
# If no options are provided, will use default
|
123
|
+
# file locations and flags
|
124
|
+
#
|
125
|
+
# Returns Array[Notes::Task]
|
126
|
+
def all(options = Notes::Options.defaults)
|
127
|
+
files = Notes.valid_files(options)
|
128
|
+
for_files(files, options)
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
# Return up to 5 lines following the line at idx
|
134
|
+
# TODO: it might be better to have before_context and after_context
|
135
|
+
def context_lines(lines, idx)
|
136
|
+
ctx = []
|
137
|
+
1.upto(5) do |i|
|
138
|
+
num = idx+i
|
139
|
+
break unless lines[num]
|
140
|
+
ctx << lines[num]
|
141
|
+
end
|
142
|
+
ctx.join("\n")
|
143
|
+
end
|
144
|
+
|
145
|
+
# Information about a line from git (author, date, etc)
|
146
|
+
#
|
147
|
+
# filename - A String filename
|
148
|
+
# idx - a 0-based line number
|
149
|
+
#
|
150
|
+
# Returns Hash
|
151
|
+
def line_info(filename, idx)
|
152
|
+
result = {}
|
153
|
+
return result unless Notes.git?
|
154
|
+
|
155
|
+
fields = Notes.blame(filename, idx+1)
|
156
|
+
|
157
|
+
author = fields["author"]
|
158
|
+
result[:author] = author if author && !author.empty?
|
159
|
+
|
160
|
+
time = fields["author-time"] # ISO 8601
|
161
|
+
result[:date] = Time.at(time.to_i).to_s if time && !time.empty?
|
162
|
+
|
163
|
+
sha = fields["sha"]
|
164
|
+
result[:sha] = sha if sha
|
165
|
+
|
166
|
+
result
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
end
|
data/lib/notes-cli/version.rb
CHANGED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'sinatra'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
# A web dashboard for annotations
|
5
|
+
# This is intended to be mounted within an application, e.g.
|
6
|
+
#
|
7
|
+
# mount Notes::Web => '/notes'
|
8
|
+
#
|
9
|
+
module Notes
|
10
|
+
class Web < Sinatra::Base
|
11
|
+
|
12
|
+
set :root, File.expand_path(File.dirname(__FILE__) + "/../../web")
|
13
|
+
set :public_folder, Proc.new { "#{root}/assets" }
|
14
|
+
set :views, Proc.new { "#{root}/views" }
|
15
|
+
|
16
|
+
get '/' do
|
17
|
+
# TODO: there has to be a better way to get the mounted root
|
18
|
+
@root = request.env["SCRIPT_NAME"]
|
19
|
+
|
20
|
+
erb :index
|
21
|
+
end
|
22
|
+
|
23
|
+
get '/tasks.json' do
|
24
|
+
# TODO: cache this somehow
|
25
|
+
options = Notes::Options.defaults
|
26
|
+
|
27
|
+
if flags = params[:flags]
|
28
|
+
options[:flags] = flags
|
29
|
+
end
|
30
|
+
|
31
|
+
tasks = Notes::Tasks.all(options)
|
32
|
+
@stats = Notes::Stats.compute(tasks)
|
33
|
+
@tasks = tasks.map(&:to_json)
|
34
|
+
|
35
|
+
{ stats: @stats, tasks: @tasks }.to_json
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
data/notes-cli.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require File.expand_path('../lib/notes-cli/version', __FILE__)
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = 'notes-cli'
|
5
|
+
s.date = '2013-01-13'
|
6
|
+
s.summary = "A tool for managing source code annotations"
|
7
|
+
s.description = %q{
|
8
|
+
notes-cli lets you manage source code annotations such as
|
9
|
+
todo or fixme comments, providing a command-line interface as well as a web
|
10
|
+
dashboard.
|
11
|
+
}.strip.gsub(/\s+/, ' ')
|
12
|
+
s.authors = ["Andrew Berls"]
|
13
|
+
s.email = 'andrew.berls@gmail.com'
|
14
|
+
s.homepage = 'https://github.com/andrewberls/notes-cli'
|
15
|
+
s.license = 'MIT'
|
16
|
+
|
17
|
+
s.executables << 'notes'
|
18
|
+
s.files = `git ls-files`.split($/)
|
19
|
+
s.version = Notes::VERSION
|
20
|
+
|
21
|
+
s.add_runtime_dependency 'sinatra'
|
22
|
+
s.add_development_dependency 'rspec', '~> 2.14.1'
|
23
|
+
end
|
data/spec/notes_spec.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe 'Notes' do
|
4
|
+
|
5
|
+
context 'shortname' do
|
6
|
+
specify do
|
7
|
+
Dir.should_receive(:pwd).and_return('/path/to/notes-cli')
|
8
|
+
Notes.shortname('/path/to/notes-cli/bin/notes').should == 'bin/notes'
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe 'arg groups' do
|
4
|
+
context 'with no options' do
|
5
|
+
it "accepts a list of files" do
|
6
|
+
Notes::Options.arg_groups(['one.rb', 'two.rb']).should == [['one.rb', 'two.rb']]
|
7
|
+
end
|
8
|
+
|
9
|
+
it "accepts a directory" do
|
10
|
+
Notes::Options.arg_groups(['app/']).should == [['app/']]
|
11
|
+
end
|
12
|
+
|
13
|
+
it "looks in the current directory if none provided" do
|
14
|
+
Notes::Options.arg_groups(['-f', 'src/']).should == [[Dir.pwd], ['-f', 'src/']]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'with options' do
|
19
|
+
it "accepts a list of files" do
|
20
|
+
['-f', '--flags'].each do |flag|
|
21
|
+
Notes::Options.arg_groups(['one.rb', 'two.rb', flag, 'broken']).should == [['one.rb', 'two.rb'], [flag, 'broken']]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
it "accepts a directory" do
|
26
|
+
['-e', '--exclude'].each do |flag|
|
27
|
+
Notes::Options.arg_groups(['app/', flag, 'log/']).should == [['app/'], [flag, 'log/']]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it "groups flags correctly" do
|
32
|
+
Notes::Options.arg_groups(['app/', '-f', 'broken', '-e', 'log/']).should == [['app/'], ['-f', 'broken'], ['-e', 'log/']]
|
33
|
+
Notes::Options.arg_groups(['one.rb', 'two.rb', '-e', 'tmp', '-f', 'findme']).should == [['one.rb', 'two.rb'], ['-e', 'tmp'], ['-f', 'findme']]
|
34
|
+
end
|
35
|
+
|
36
|
+
it "accepts mixed length flags" do
|
37
|
+
expected = [['one.rb', 'two.rb'], ['--exclude', 'tmp'], ['-f', 'findme']]
|
38
|
+
Notes::Options.arg_groups(['one.rb', 'two.rb', '--exclude', 'tmp', '-f', 'findme']).should == expected
|
39
|
+
end
|
40
|
+
|
41
|
+
it "handles multiple flag arguments" do
|
42
|
+
expected = [['src/'], ['-f', 'broken', 'findme'], ['-e', 'log/', 'tmp/']]
|
43
|
+
Notes::Options.arg_groups(['src/', '-f', 'broken', 'findme', '-e', 'log/', 'tmp/']).should == expected
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
describe "opt parsing" do
|
50
|
+
context 'with no options' do
|
51
|
+
it "accepts a list of files" do
|
52
|
+
files = ['one.rb', 'two.rb']
|
53
|
+
opts = Notes::Options.parse(files)
|
54
|
+
opts[:locations].should == files
|
55
|
+
end
|
56
|
+
|
57
|
+
it "accepts a directory" do
|
58
|
+
opts = Notes::Options.parse(['app/'])
|
59
|
+
opts[:locations].should == ['app/']
|
60
|
+
end
|
61
|
+
|
62
|
+
it "looks in the current directory if none provided" do
|
63
|
+
opts = Notes::Options.parse(['-f', 'src/'])
|
64
|
+
opts[:flags].should include('src')
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context 'with options' do
|
69
|
+
it "accepts a list of files" do
|
70
|
+
['-f', '--flags'].each do |flag|
|
71
|
+
opts = Notes::Options.parse(['one.rb', 'two.rb', flag, 'broken'])
|
72
|
+
opts[:locations].should == ['one.rb', 'two.rb']
|
73
|
+
opts[:flags].should include('broken')
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
it "accepts a directory" do
|
78
|
+
['-e', '--exclude'].each do |flag|
|
79
|
+
opts = Notes::Options.parse(['app/', flag, 'log/'])
|
80
|
+
opts[:exclude].should include('log')
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
it "groups flags correctly" do
|
85
|
+
opts = Notes::Options.parse(['app/', '-f', 'broken', '-e', 'log/'])
|
86
|
+
opts[:flags].should include('broken')
|
87
|
+
opts[:exclude].should include('log')
|
88
|
+
|
89
|
+
opts = Notes::Options.parse(['one.rb', 'two.rb', '-e', 'tmp', '-f', 'findme'])
|
90
|
+
opts[:flags].should include('findme')
|
91
|
+
opts[:exclude].should include('tmp')
|
92
|
+
end
|
93
|
+
|
94
|
+
it "accepts mixed length flags" do
|
95
|
+
opts = Notes::Options.parse(['one.rb', 'two.rb', '--exclude', 'tmp', '-f', 'findme'])
|
96
|
+
opts[:flags].should include('findme')
|
97
|
+
opts[:exclude].should include('tmp')
|
98
|
+
end
|
99
|
+
|
100
|
+
it "handles multiple flag arguments" do
|
101
|
+
opts = Notes::Options.parse(['src/', '-f', 'broken', 'findme', '-e', 'log/', 'tmp/'])
|
102
|
+
opts[:flags].should include('broken', 'findme')
|
103
|
+
opts[:exclude].should include('log', 'tmp')
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe 'defaults' do
|
109
|
+
context 'when Rails is not defined' do
|
110
|
+
it 'does not exclude anything by default' do
|
111
|
+
Notes::Options.default_excludes.should == []
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'uses the current directory by default' do
|
115
|
+
Notes.root.should == Dir.pwd
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
context 'when Rails is defined' do
|
120
|
+
it 'excludes slow directories by default' do
|
121
|
+
Notes.should_receive(:rails?).and_return(true)
|
122
|
+
Notes::Options.default_excludes.should == %w(tmp log)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|