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.
@@ -0,0 +1,5 @@
1
+ require File.dirname(__FILE__) + '/../lib/notes-cli'
2
+
3
+ RSpec.configure do |config|
4
+ config.color_enabled = true
5
+ end
@@ -0,0 +1,34 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+ require 'tempfile'
3
+
4
+ describe 'stats' do
5
+ context 'compute' do
6
+ let(:file) { Tempfile.new('example') }
7
+ let(:options) { Notes::Options.defaults }
8
+ let(:tasks) { Notes::Tasks.for_file(file.path, options[:flags]) }
9
+
10
+ before do
11
+ File.open(file, 'w') do |f|
12
+ f.write "TODO: one\n"
13
+ f.write "two\n"
14
+ f.write "TODO: three\n"
15
+ f.write "OPTIMIZE: four\n"
16
+ f.write "five\n"
17
+ f.write "six\n"
18
+ f.write "seven FIXME\n"
19
+ end
20
+ end
21
+
22
+ it 'counts stats correctly' do
23
+ Notes::Stats.flag_counts(tasks).should == {
24
+ 'TODO' => 2,
25
+ 'OPTIMIZE' => 1,
26
+ 'FIXME' => 1
27
+ }
28
+ end
29
+
30
+ it 'aggregates found flags' do
31
+ Notes::Stats.found_flags(tasks).should == ['TODO', 'OPTIMIZE', 'FIXME']
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,90 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+ require 'tempfile'
3
+
4
+ describe 'tasks' do
5
+
6
+ context 'matching_flags' do
7
+ specify do
8
+ Notes::Tasks.matching_flags("TODO: foo", ["TODO"]).should == ["TODO"]
9
+ end
10
+
11
+ specify do
12
+ Notes::Tasks.matching_flags("TODO: bar", ["FIXME"]).should == []
13
+ end
14
+
15
+ specify do
16
+ Notes::Tasks.matching_flags("fixme", ["FIXME", "foo"]).should == ["FIXME"]
17
+ end
18
+
19
+ specify do
20
+ Notes::Tasks.matching_flags("TODO: foo FIXME", ["TODO","FIXME", "OPTIMIZE"])
21
+ .should == ["TODO","FIXME"]
22
+ end
23
+ end
24
+
25
+ context 'for_file' do
26
+ let(:file) { Tempfile.new('example') }
27
+ let(:options) { Notes::Options.defaults }
28
+ let(:tasks) { Notes::Tasks.for_file(file.path, options[:flags]) }
29
+
30
+ before do
31
+ File.open(file, 'w') do |f|
32
+ f.write "TODO: one\n"
33
+ f.write "two\n"
34
+ f.write "three\n"
35
+ f.write "findme: four\n"
36
+ f.write "five\n"
37
+ f.write "six\n"
38
+ f.write "seven FIXME\n"
39
+ end
40
+ end
41
+
42
+ specify { tasks.length.should == 2 }
43
+
44
+ it 'counts custom flags correctly' do
45
+ tasks = Notes::Tasks.for_file(file.path, options[:flags] + ["FINDME"])
46
+ tasks.length.should == 3
47
+ end
48
+
49
+ it 'parses lines correctly' do
50
+ tasks = Notes::Tasks.for_file(file.path, options[:flags] + ["FINDME"])
51
+ t0, t1, t2 = tasks
52
+
53
+ t0.line_num.should == 1
54
+ t0.line.should == "TODO: one"
55
+ t0.flags.should == ["TODO"]
56
+ t0.context.should == "two\nthree\nfindme: four\nfive\nsix"
57
+
58
+ t1.line_num.should == 4
59
+ t1.line.should == "findme: four"
60
+ t1.flags.should == ["FINDME"]
61
+ t1.context.should == "five\nsix\nseven FIXME"
62
+
63
+ t2.line_num.should == 7
64
+ t2.line.should == "seven FIXME"
65
+ t2.flags.should == ["FIXME"]
66
+ t2.context.should == ""
67
+ end
68
+
69
+ # This is kind of annoying - we have a perfectly valid file to parse,
70
+ # but it's not in a git repo. We could build an abstraction layer over
71
+ # the git call, but that doesn't seem worth it, so hand-wave the git parts
72
+ # and test simple field parsing
73
+ #
74
+ # Also directly testing private methods, somebody call the TDD police
75
+ it 'reads information from git' do
76
+ Notes.should_receive(:blame).and_return({
77
+ "author" => "Andrew Berls",
78
+ "author-time" => "1381862180",
79
+ "sha" => "705690fb747a2ae82a96edb21df7e424f5cc518b",
80
+ })
81
+
82
+ info = Notes::Tasks.send(:line_info, 'doesnt_matter_stubbed_out', 0)
83
+ info[:author].should == 'Andrew Berls'
84
+ info[:date].should == '2013-10-15 11:36:20 -0700'
85
+ info[:sha].should == '705690fb747a2ae82a96edb21df7e424f5cc518b'
86
+ end
87
+ end
88
+
89
+ end
90
+
@@ -0,0 +1,438 @@
1
+ // Change underscore templates to {{}} and {{= }} to play nice with ERB
2
+ _.templateSettings = {
3
+ interpolate: /\{\{\=(.+?)\}\}/g,
4
+ evaluate: /\{\{(.+?)\}\}/g
5
+ };
6
+
7
+
8
+ // Global namespace object
9
+ window.Notes = {};
10
+
11
+
12
+ Notes.escapeHtml = function(text) {
13
+ return $('<div>').text(text).html();
14
+ }
15
+
16
+
17
+ // Is Array A a subset of Array B?
18
+ Notes.isSubset = function(a,b) {
19
+ return a.length === _.intersection(a,b).length;
20
+ }
21
+
22
+
23
+ // How many tabs or spaces does a string start with?
24
+ Notes.leadingWhitespaceCount = function(str) {
25
+ var count = 0;
26
+ while(str.charAt(0) === " " || str.charAt(0) === "\t") {
27
+ str = str.substr(1);
28
+ count++;
29
+ }
30
+ return count;
31
+ }
32
+
33
+
34
+ // Take an array of lines and return the smallest number of leading whitespaces
35
+ // (tabs or spaces) from among them
36
+ Notes.minLtrim = function(lines) {
37
+ var counts = lines.map(function(line) { return Notes.leadingWhitespaceCount(line); })
38
+ return Math.min.apply(null, counts);
39
+ }
40
+
41
+
42
+ Notes.defaultFlags = ['TODO', 'OPTIMIZE', 'FIXME'];
43
+
44
+
45
+ Notes.getSelectedFlags = function() {
46
+ return Notes.sidebarView.collection
47
+ .filter(function(f) { return f.get('checked'); })
48
+ .map(function(f) { return f.get('name') });
49
+ }
50
+
51
+
52
+ // Color classes to be paired against distinct flags (for consistency)
53
+ Notes.colors = [
54
+ 'lightblue','purple','fuschia','lightgreen','orange','green','blue',
55
+ 'pink','turquoise','deepred',
56
+ ]
57
+
58
+ // TODO
59
+ Notes.buildColorMap = function(flags) {
60
+ var allFlags = _.uniq(flags.concat(Notes.defaultFlags));
61
+ Notes.colorMap = _.zip(allFlags, Notes.colors);
62
+ }
63
+
64
+
65
+ Notes.colorFor = function(flagName) {
66
+ var map;
67
+ for (var i=0; i<Notes.colorMap.length; i++) {
68
+ map = Notes.colorMap[i];
69
+ if (map[0] === flagName) {
70
+ return map[1];
71
+ } else if (map[0] === undefined) {
72
+ // No existing mapping found - add new flag to colorMap
73
+ map[0] = flagName;
74
+ return map[1];
75
+ }
76
+ }
77
+
78
+ return Notes.colors[Notes.colors.length-1]; // TODO - default to last color in list
79
+ }
80
+
81
+
82
+
83
+ Notes.Task = Backbone.Model.extend({
84
+ escapedLine: function() {
85
+ return Notes.escapeHtml(this.get('line'));
86
+ },
87
+
88
+ escapedContextLines: function() {
89
+ var ctx = this.get('context');
90
+ if (ctx === '') { return []; }
91
+
92
+ return ctx.split("\n")
93
+ .map(function(line) { return Notes.escapeHtml(line); });
94
+ },
95
+
96
+ allLines: function() {
97
+ return [this.escapedLine()].concat(this.escapedContextLines());
98
+ },
99
+
100
+ highlightedLine: function() {
101
+ var regex = new RegExp(this.get('flags').join('|'), 'gi');
102
+ return this.escapedLine().replace(regex, function(flag) {
103
+ return "<strong>"+flag+"</strong>";
104
+ });
105
+ },
106
+
107
+ formattedSha: function() {
108
+ var sha = this.get('sha');
109
+ return sha ? "@ " + sha.slice(0,7) : '';
110
+ },
111
+
112
+ formattedDate: function() {
113
+ var date = new Date(this.get('date')),
114
+ month = date.getMonth() + 1,
115
+ day = date.getDate(),
116
+ year = date.getFullYear().toString().slice(2);
117
+
118
+ return month + '/' + day + '/' + year;
119
+ }
120
+ });
121
+
122
+
123
+ // A view for a single task item
124
+ Notes.TaskView = Backbone.View.extend({
125
+ tagName: 'div',
126
+ className: 'task',
127
+ template: _.template($('#tmpl-task').html()),
128
+
129
+ render: function() {
130
+ $(this.el).html(this.template({ task: this.model }));
131
+ return this;
132
+ },
133
+
134
+ events: {
135
+ 'click .task-toggle': 'toggleContext'
136
+ },
137
+
138
+ toggleContext: function() {
139
+ var $el = $(this.el),
140
+ $toggle = $el.find('.task-toggle'),
141
+ $ctx = $el.find('.task-context');
142
+
143
+ if ($ctx.is(':visible')) {
144
+ $toggle.removeClass('fa-angle-up').addClass('fa-angle-down');
145
+ $ctx.slideUp();
146
+ } else {
147
+ $toggle.removeClass('fa-angle-down').addClass('fa-angle-up');
148
+ $ctx.slideDown();
149
+ }
150
+ }
151
+ });
152
+
153
+
154
+ Notes.TasksCollection = Backbone.Collection.extend({
155
+ model: Notes.Task,
156
+
157
+ initialize: function() {
158
+ this.filename = '';
159
+ }
160
+ });
161
+
162
+
163
+ // A view for a collection of tasks grouped under a common filename
164
+ Notes.TaskCollectionView = Backbone.View.extend({
165
+ tagName: 'div',
166
+ classname: 'tasks-container',
167
+
168
+ render: function() {
169
+ var $el = $(this.el);
170
+ $el.append("<h2 class='task-filename'>"+this.collection.filename+":</h2>");
171
+
172
+ this.collection.each(function(task) {
173
+ $el.append(new Notes.TaskView({ model: task }).render().el);
174
+ });
175
+ return this;
176
+ }
177
+ });
178
+
179
+
180
+
181
+ // A flag accompanied by a checkbox in the sidebar
182
+ Notes.Flag = Backbone.Model.extend({
183
+ defaults: { checked: true },
184
+
185
+ checkedClass: function() { return this.get('checked') ? 'checked' : ''; }
186
+ });
187
+
188
+
189
+ Notes.FlagView = Backbone.View.extend({
190
+ tagName: 'div',
191
+ className: 'flag-container',
192
+ template: _.template($('#tmpl-flag').html()),
193
+
194
+ render: function() {
195
+ $(this.el).html(this.template({ flag: this.model }));
196
+ return this;
197
+ },
198
+
199
+ events: {
200
+ 'click .checkbox': 'toggleCheck',
201
+ 'click .delete-flag-btn': 'deleteFlag'
202
+ },
203
+
204
+ $checkbox: function() { return $(this.el).find('.checkbox'); },
205
+ isChecked: function() { return this.$checkbox().hasClass('checked'); },
206
+ check: function() {
207
+ this.model.set('checked', true);
208
+ this.$checkbox().addClass('checked');
209
+ },
210
+ uncheck: function() {
211
+ this.model.set('checked', false);
212
+ this.$checkbox().removeClass('checked');
213
+ },
214
+ toggleCheck: function() { this.isChecked() ? this.uncheck() : this.check(); },
215
+
216
+ deleteFlag: function() {
217
+ Notes.sidebarView.collection.remove(this.model);
218
+ var $el = $(this.el);
219
+ $el.slideUp(200, function() { $el.remove(); });
220
+ }
221
+ });
222
+
223
+
224
+ Notes.FlagCollection = Backbone.Collection.extend({
225
+ model: Notes.Flag,
226
+
227
+ // Add a flag into the collection unless it's already present
228
+ merge: function(flag) {
229
+ var attrs = { name: flag.toUpperCase() }
230
+ match = this.findWhere(attrs);
231
+ if (!match) { this.add(attrs); }
232
+ }
233
+ });
234
+
235
+
236
+ Notes.FlagCollectionView = Backbone.View.extend({
237
+ tagName: 'div',
238
+
239
+ render: function() {
240
+ var $el = $(this.el);
241
+ $el.html('');
242
+
243
+ this.collection.each(function(flag) {
244
+ $el.append(new Notes.FlagView({ model: flag }).render().el);
245
+ });
246
+ return this;
247
+ }
248
+ });
249
+
250
+
251
+
252
+ // Merge a custom flag into the sidebar and re-render
253
+ Notes.addFlag = function(flag) {
254
+ if (flag === '') { return false; }
255
+ Notes.sidebarView.collection.merge(flag);
256
+ Notes.renderSidebar();
257
+ }
258
+
259
+
260
+ // Build (or rebuild) the sidebar from the flags
261
+ // used to query the server
262
+ Notes.buildSidebar = function(flags) {
263
+ var attrs = flags.map(function(f) { return { name: f }; });
264
+
265
+ Notes.sidebarView = new Notes.FlagCollectionView({
266
+ collection: new Notes.FlagCollection(attrs)
267
+ });
268
+ Notes.renderSidebar();
269
+ }
270
+
271
+ // TODO: calling this more than once breaks click handlers ???
272
+ Notes.renderSidebar = function() {
273
+ var $container = $('.flags-container');
274
+ $container.empty();
275
+ $container.append(Notes.sidebarView.render().el);
276
+ }
277
+
278
+
279
+ Notes.renderStats = function(stats) {
280
+ var flagCounts = stats.flag_counts;
281
+
282
+ // TODO: add in stats container
283
+ // <div class="stats-container">
284
+ // <div class="chart"></div>
285
+ // </div>
286
+ }
287
+
288
+
289
+ // tasks - Array[Notes.Task]
290
+ Notes.renderTasks = function(tasks) {
291
+ var $container = $('.main-content-container'),
292
+ filename, collection, collectionView;
293
+
294
+ $container.empty();
295
+
296
+ if (tasks.length === 0) {
297
+ $container.html($("<div class='empty-tasks-container'>").append(
298
+ "<h2>No tasks matching the criteria were found!</h2>"));
299
+ return;
300
+ }
301
+
302
+ // filename -> Array[Notes.Task]
303
+ var taskMap = _.groupBy(tasks, function(t) { return t.get('filename'); });
304
+
305
+ for (filename in taskMap) {
306
+ collection = new Notes.TasksCollection(taskMap[filename]);
307
+ collection.filename = filename;
308
+
309
+ collectionView = new Notes.TaskCollectionView({ collection: collection })
310
+ $container.append(collectionView.render().el);
311
+ }
312
+ }
313
+
314
+
315
+ Notes.addProgress = function() {
316
+ $('.loading-container').find('p').append('.');
317
+ }
318
+
319
+
320
+
321
+ // Check if a set of tasks can be filtered based on flags we've already searched for
322
+ // This allows us to avoid hitting the server when we don't need to
323
+ //
324
+ // flags - Array[String]
325
+ Notes.isSubsetQuery = function(flags) {
326
+ return Notes.isSubset(flags, Notes.lastQueryFlags);
327
+ }
328
+
329
+
330
+ // Find a subset of locally-cached tasks that match a set of search flags
331
+ // Returns Array[Notes.Task]
332
+ //
333
+ // TODO: this behaves weirdly if a task has multiple flags, punting for now
334
+ Notes.filterTasks = function(queryFlags) {
335
+ if (queryFlags.length === 0) { return []; }
336
+
337
+ return Notes.tasks.filter(function(task) {
338
+ var taskFlags = task.get('flags');
339
+ return (Notes.isSubset(taskFlags, queryFlags) ||
340
+ Notes.isSubset(queryFlags, taskFlags));
341
+ });
342
+ }
343
+
344
+
345
+ // Returns the URL to query the server at
346
+ Notes.queryPath = function() {
347
+ var path = window.location.pathname;
348
+ return (path === '/' ? '' : path) + "/tasks.json"
349
+ }
350
+
351
+
352
+ // Fetch tasks from the server and re-render
353
+ Notes.queryTasks = function(queryFlags) {
354
+ if (!Notes.colorMap) { Notes.buildColorMap(queryFlags); }
355
+
356
+ if (Notes.lastQueryFlags && Notes.isSubsetQuery(queryFlags)) {
357
+ // Subset query - don't need to hit server
358
+ var tasks = Notes.filterTasks(queryFlags);
359
+ Notes.renderTasks(tasks);
360
+ return;
361
+ }
362
+
363
+ // Can't filter client-side - need to hit server
364
+ $('.main-content-container').html("<div class='loading-container'><p>Loading </p></div>");
365
+ var progressInterval = setInterval(Notes.addProgress, 175);
366
+
367
+ if (!Notes.sidebarView) { Notes.buildSidebar(queryFlags); }
368
+
369
+ $.getJSON(Notes.queryPath(), { flags: queryFlags }, function(json) {
370
+ var stats = json.stats,
371
+ tasks = json.tasks.map(function(attrs) { return new Notes.Task(attrs) });
372
+
373
+ Notes.tasks = tasks;
374
+ Notes.lastQueryFlags = queryFlags; // Save the most recent search terms for checking subsets
375
+
376
+ clearInterval(progressInterval);
377
+ Notes.renderStats(stats);
378
+ Notes.renderTasks(tasks);
379
+
380
+ });
381
+ }
382
+
383
+
384
+
385
+ // Page Load
386
+ // ----------------------------------
387
+ Notes.queryTasks(Notes.defaultFlags);
388
+
389
+
390
+ $(function() {
391
+ var $doc = $(document);
392
+
393
+ $doc.on('keyup', '.add-flag', function(e) {
394
+ if (e.keyCode === 13) {
395
+ var $input = $(this);
396
+ Notes.addFlag($input.val());
397
+ $input.val('');
398
+ }
399
+ });
400
+
401
+
402
+ $doc.on('click', '.add-flag-btn', function() {
403
+ var $input = $('.add-flag');
404
+ Notes.addFlag($input.val());
405
+ $input.val('');
406
+ return false;
407
+ });
408
+
409
+
410
+ $doc.on('click', '.filter-btn', function() {
411
+ Notes.queryTasks(Notes.getSelectedFlags());
412
+ return false;
413
+ });
414
+
415
+
416
+ $doc.on('click', '.restore-defaults-btn', function() {
417
+ Notes.queryTasks(Notes.defaultFlags);
418
+ return false;
419
+ });
420
+
421
+
422
+ $doc.on('click', '.toggle-all-context-btn', function() {
423
+ var $toggle = $('.task-toggle'),
424
+ $ctx = $('.task-context');
425
+
426
+ if ($ctx.is(':visible')) {
427
+ $(this).html("Show all context <i class='fa fa-angle-down'></i>");
428
+ $toggle.removeClass('fa-angle-up').addClass('fa-angle-down');
429
+ $ctx.slideUp();
430
+ } else {
431
+ $(this).html("Hide all context <i class='fa fa-angle-up'></i>");
432
+ $toggle.removeClass('fa-angle-down').addClass('fa-angle-up');
433
+ $ctx.slideDown();
434
+ }
435
+
436
+ return false;
437
+ });
438
+ });