1 /*
2 	 -------------------------------------------------------------------
3 
4 	 Copyright (C) 2014, Edwin van Leeuwen
5 
6 	 This file is part of todod todo list manager.
7 
8 	 Todod is free software; you can redistribute it and/or modify
9 	 it under the terms of the GNU General Public License as published by
10 	 the Free Software Foundation; either version 3 of the License, or
11 	 (at your option) any later version.
12 
13 	 Todod is distributed in the hope that it will be useful,
14 	 but WITHOUT ANY WARRANTY; without even the implied warranty of
15 	 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 	 GNU General Public License for more details.
17 
18 	 You should have received a copy of the GNU General Public License
19 	 along with Todod. If not, see <http://www.gnu.org/licenses/>.
20 
21 	 -------------------------------------------------------------------
22 	 */
23 
24 module todod.shell;
25 
26 import std.stdio;
27 import std.array;
28 import std.range;
29 
30 import std.string;
31 import std.regex;
32 import std.container;
33 import std.algorithm;
34 import std.conv;
35 
36 import std.typecons : tuple;
37 
38 import colorize;
39 
40 import todod.commandline;
41 import todod.date;
42 import todod.dependency;
43 import todod.random;
44 import todod.state;
45 import todod.tag;
46 import todod.todo;
47 
48 auto addTagRegex = regex(r"(?:^|\s)\+(\w+)");
49 auto delTagRegex = regex(r"(?:^|\s)\-(\w+)");
50 auto allTagRegex = regex(r"(?:^|\s)[+-](\w+)");
51 
52 unittest {
53 	assert( match( "+tag", addTagRegex ) );
54 	assert( !match( "-tag", addTagRegex ) );
55 	assert( match( "bla +tag bla", addTagRegex ) );
56 	assert( !match( "bla+tag", addTagRegex ) );
57 
58 	assert( !match( "+tag", delTagRegex ) );
59 	assert( match( "-tag", delTagRegex ) );
60 	assert( match( "bla -tag blaat", delTagRegex ) );
61 	assert( !match( "bla-tag", delTagRegex ) );
62 
63 	assert( match( "+tag", allTagRegex ) );
64 	assert( match( "-tag", allTagRegex ) );
65 	assert( match( "bla -tag", allTagRegex ) );
66 	assert( !match( "bla-tag", allTagRegex ) );
67 }
68 
69 /// parse a string and return a tuple with TagDelta and the rest of the string
70 auto parseAndRemoveTags( string str ) {
71 	TagDelta td;
72 	auto m = matchAll( str, addTagRegex );
73 	foreach ( hits ; m ) {
74 		td.add_tags.add( new Tag( hits[1] ) );
75 	}
76 	m = matchAll( str, delTagRegex );
77 	foreach ( hits ; m ) {
78 		td.delete_tags.add( new Tag( hits[1] ) );
79 	}
80 	
81 	// Should be possible to do matching 
82 	// and replacing with one call to replaceAll!( dg ) but didn't
83 	// work for me
84 	str = replaceAll( str, allTagRegex, "" );
85 	// Replace multiple spaces
86 	str = replaceAll( str, regex(r"(?:^|\s) +"), "" );
87 	return tuple(td, str);
88 }
89 
90 
91 
92 TagDelta parseTags( string str ) {
93 	TagDelta td;
94 	auto m = matchAll( str, addTagRegex );
95 	foreach ( hits ; m ) {
96 		td.add_tags.add( new Tag( hits[1] ) );
97 	}
98 	m = matchAll( str, delTagRegex );
99 	foreach ( hits ; m ) {
100 		td.delete_tags.add( new Tag( hits[1] ) );
101 	}
102 	return td;
103 }
104 
105 unittest {
106 	auto td = parseTags( "+tag1" );
107 	assert( std.algorithm.equal(td.add_tags.array, [new Tag("tag1")]) );
108 	td = parseTags( "+tag1 +tag2" );
109 	assert( std.algorithm.equal(td.add_tags.array, [new Tag("tag1"),
110 				new Tag("tag2")]) );
111 	td = parseTags( "+tag1+tag2" );
112 	assert( std.algorithm.equal(td.add_tags.array, [new Tag("tag1")]) ); 
113 
114 	// Same for negative tags
115 	td = parseTags( "-tag1" );
116 	assert( std.algorithm.equal(td.delete_tags.array, [new Tag("tag1")]) );
117 	td = parseTags( "-tag1 -tag2" );
118 	assert( std.algorithm.equal(td.delete_tags.array, [new Tag("tag1"), 
119 				new Tag("tag2")]) );
120 	td = parseTags( "-tag1-tag2" );
121 	assert( std.algorithm.equal(td.delete_tags.array, [new Tag("tag1")]) );
122 
123 	td = parseTags( "-tag1+tag2" );
124 	assert( std.algorithm.equal(td.delete_tags.array, [new Tag("tag1")]) );
125 	assert( td.add_tags.length == 0 );
126 	td = parseTags( "+tag1-tag2" );
127 	assert( std.algorithm.equal(td.add_tags.array, [new Tag("tag1")]) );
128 	assert( td.delete_tags.length == 0 );
129 
130 	td = parseTags( "-tag1 +tag2" );
131 	assert( std.algorithm.equal(td.delete_tags.array, [new Tag("tag1")]) );
132 	assert( std.algorithm.equal(td.add_tags.array, [new Tag("tag2")]) ); 
133 	td = parseTags( "+tag1 -tag2" );
134 	assert( std.algorithm.equal(td.delete_tags.array, [new Tag("tag2")]) );
135 	assert( std.algorithm.equal(td.add_tags.array, [new Tag("tag1")]) ); 
136 }
137 
138 /// Return tuple witch string with due date removed. Due date is something along
139 /// D2014-01-12
140 auto parseAndRemoveDueDate( string str ) {
141 	// Due dates
142 	auto date_regex = regex( r"D(\d\d\d\d-\d\d-\d\d)" );
143 	Date dt;
144 	auto due_m = matchFirst( str, date_regex );
145 	if (due_m) {
146 		dt = Date( due_m.captures[1] );
147 		str = replaceAll( str, date_regex, "" );
148 	} else {
149 		date_regex = regex( r"(?:^|\s)D\+(\d+)" );
150 		due_m = matchFirst( str, date_regex );
151 		if (due_m) {
152 			dt = Date.now;
153 			dt.addDays( to!long( due_m.captures[1] ) );
154 			str = replaceAll( str, date_regex, "" );
155 		}
156 	}
157 	// Replace multiple spaces
158 	str = replaceAll( str, regex(r"(?:^|\s) +"), "" );
159 	return tuple( dt, str );
160 }
161 
162 unittest {
163 	auto tup = parseAndRemoveDueDate( "D2014-01-12" );
164 	assert( tup[0].substract( Date( "2014-01-08" ) ) == 4 );
165 	assert( tup[1] == "" );
166 
167 	tup = parseAndRemoveDueDate( "Bla D2014-01-12" );
168 	assert( tup[0].substract( Date( "2014-01-08" ) ) == 4 );
169 	assert( tup[1] == "Bla " );
170 
171 	tup = parseAndRemoveDueDate( "Bla" );
172 	assert( !tup[0] );
173 	assert( tup[1] == "Bla" );
174 }
175 
176 /// Return date from string. Date is something along 2014-01-12
177 auto parseDate( string str, Date from = Date.now ) {
178 	// Due dates
179 	auto date_regex = regex( r"(\d\d\d\d-\d\d-\d\d)" );
180 	Date dt;
181 	auto due_m = matchFirst( str, date_regex );
182 	if (due_m) {
183 		dt = Date( due_m.captures[1] );
184 		return dt;
185 	} else {
186 		due_m = matchFirst( str, r"(?:^|\s|D)\+(\d+)" );
187 		if (due_m) {
188 			from.addDays( to!long( due_m.captures[1] ) );
189 		}
190 		return from.dup;
191 	}
192 }
193 
194 unittest {
195 	auto fromDate = Date("2014-01-16");
196 	auto dt = parseDate( "Bla +4 Bla", fromDate );
197 	assert( dt.substract( fromDate ) == 4 );
198 
199 	dt = parseDate( "Bla D+31 Bla", fromDate );
200 	assert( dt.substract( fromDate ) == 31 );
201 
202 	dt = parseDate( "+11 Bla", fromDate );
203 	assert( dt.substract( fromDate ) == 11 );
204 }
205 
206 
207 
208 Todo applyTags( Todo td, TagDelta delta ) {
209 	td.tags.add( delta.add_tags );
210 	td.tags.remove( delta.delete_tags );
211 	return td;
212 }
213 
214 unittest {
215 	TagDelta delta;
216 	delta.add_tags.add( [new Tag("tag2"), new Tag("tag1")] );
217 	Todo td = new Todo("Todo1"); 
218 	td = applyTags( td, delta );
219 	assert( equal( td.tags.array, [new Tag("tag1"), new Tag("tag2")] ) );
220 	td = applyTags( td, delta );
221 	assert( equal( td.tags.array, [new Tag("tag1"), new Tag("tag2")] ) );
222 	delta.delete_tags.add( [new Tag("tag3"), new Tag("tag1")] );
223 	td = applyTags( td, delta );
224 	assert( equal( td.tags.array, [new Tag("tag2")] ) );
225 }
226 
227 /// Wrap string in color used for tags
228 string tagColor( string str ) {
229 	return color( str, fg.red );
230 }
231 
232 /// Wrap string in color used for emphasizing titles 
233 string titleEmphasize( string str ) {
234 	return color( str, fg.green );
235 }
236 
237 /// Produce colored string from tags
238 string prettyStringTags( Tags tags ) {
239 	string line;
240 	foreach( tag; tags ) {
241 		line ~= tag.name ~ " ";
242 	}
243 	return tagColor( line );
244 }
245 
246 string prettyStringTodo( Todo t ) {
247 	Date currentDate = Date.now;
248 	string description = titleEmphasize(t.title) ~ "\n";
249 	description ~= "\t  Tags:       " ~ prettyStringTags( t.tags ) ~"\n";
250 	description ~= "\t  " ~ "Last progress    " 
251 		~ tagColor(to!string( lastProgress( t ) ).rightJustify( 4 )) ~
252 		" days ago\n";
253 
254 	if ( t.due_date )
255 		description ~= "\t  " ~ "Due in           " ~ tagColor( 
256 				to!string( t.due_date.substract( currentDate ) ).rightJustify( 4 ) ) 
257 			~ " days\n";
258 
259 	return description;
260 }
261 
262 string prettyStringTodos(RANGE)( RANGE ts, Todos allTodos, Tags allTags,
263 		TagDelta selectedTags, string searchString, in Dependencies deps, in double[string] defaultWeights,
264 		bool showWeight = false ) {
265 	string str;
266 	size_t id = 0;
267 	foreach( t; ts ) {
268 		str = str ~ to!string( id ) ~ "\t" ~ prettyStringTodo( t );
269 		if (showWeight)
270 			str ~= "Weight: " ~ to!string( weight( t, selectedTags, searchString,
271 						allTodos.length, allTodos.tagOccurence( allTags ), deps, 
272 						defaultWeights ) ) ~ "\n";
273 		str ~= "\n";
274 		id++;
275 	}
276 	return str;
277 }
278 
279 string prettyStringState( State state, bool showWeight = false ) {
280 	string str = "Tags and number of todos associated with that tag.\n";
281 	auto tags = state.todos.tagOccurence( state.tags );
282 	foreach( tag, count; tags ) {
283 		if (!state.selectedTags.delete_tags.canFind( tag )) {
284 			if (state.selectedTags.add_tags.canFind( tag ))
285 				str ~= tagColor(tag.name) ~ " (" ~ count.to!string ~ "),  ";
286 			else
287 				str ~= tag.name ~ " (" ~ count.to!string ~ "),  ";
288 		}
289 	}
290 	str ~= "\n\n";
291 	str ~= prettyStringTodos( state.selectedTodos, state.todos, state.tags, 
292 				state.selectedTags, state.searchString, state.dependencies, 
293 				state.defaultWeights, showWeight );
294 	debug {
295 		str ~= "Debug: Selected " ~ state.selectedTags.add_tags.to!string ~ "\n";
296 		str ~= "Debug: Deselected " ~ state.selectedTags.delete_tags.to!string ~ "\n";
297 	}
298 	return str;
299 }
300 
301 Commands!( State delegate( State, string) ) addShowCommands( 
302 		ref Commands!( State delegate( State, string) ) main ) {
303 	auto showCommands = Commands!( State delegate( State, string) )(
304 			"Show different views. When called without parameters shows a (randomly) selected list of Todos.");
305 
306 	showCommands.add( 
307 			"tags", delegate( State state, string parameter ) {
308 				writeln( prettyStringTags( state.tags ) );
309 				return state;
310 			}, "List of tags" );
311 
312 	showCommands.add( 
313 			"dependencies", delegate( State state, string parameter ) {
314 				auto groups = groupByChild( state.dependencies );
315 				foreach ( child, parents; groups ) {
316 					auto childT = state.todos.find!( (a) => a.id == child );
317 					writeln( prettyStringTodo(childT[0]) );
318 					writeln( "depends on:" );
319 					foreach( parent; parents ) {
320 						auto parentT = state.todos.find!( (a) => a.id == parent );
321 						writeln( prettyStringTodo(parentT[0]) );
322 					}
323 					writeln("");
324 				}
325 			return state;
326 		}, "Show list of current dependencies." );
327 
328 	showCommands.add( 
329 			"help", delegate( State state, string parameter ) {
330 			state = main["clear"]( state, "" ); 
331 			writeln( showCommands.toString );
332 			return state;
333 		}, "Print this help message" );
334 
335 	showCommands.add(
336 			"weight", delegate( State state, string parameter ) {
337 			// Weight is actually captured in main["show"].
338 			// This is a dummy for help and tabcompletion
339 			return state; }, "Show weights for selected Todos" );
340 
341 	main.add( 
342 			"show", delegate( State state, string parameter ) {
343 				state = main["clear"]( state, "" ); 
344 				if ( parameter == "" ) {
345 					writeln( prettyStringState( state ) );
346 				} else if (parameter == "weight") {
347 					writeln( prettyStringState( state, true ) );
348 				} else {
349 					auto split = parameter.findSplit( " " );
350 					state = showCommands[split[0]]( state, split[2] );
351 				}
352 				return state;
353 			}, "Show different views. When called without parameters shows a (randomly) selected list of Todos. See show help for more options" );
354 
355 	main.addCompletion( "show",
356 		delegate( string cmd, string parameter ) {
357 			string[] results;
358 			auto m = match( parameter, "^([A-z]*)$" );
359 			if (m) {
360 				// Main commands
361 				string[] command_keys = showCommands.commands;
362 				auto matchingCommands =
363 					filter!( a => match( a, m.captures[1] ))( command_keys );
364 				foreach ( com; matchingCommands ) {
365 					results ~= [cmd ~ " " ~ com];
366 				}
367 			}
368 			return results;
369 		}
370 	);
371 	return main;
372 }
373 
374 /// Range that either returns elements from the array targets or returns infinitively increasing range (when all is set)
375 struct Targets {
376 	bool all = false;
377 	int[] targets;
378 	int count = 0;
379 
380 	@property bool empty() const
381 	{
382 		if (all)
383 			return false;
384 		else 
385 			return targets.empty;
386 	}
387 
388 	@property int front()  const
389 	{
390 		if (all)
391 			return count;
392 		else
393 			return targets.front();
394 	}
395 	void popFront() 
396 	{
397 		if (all)
398 			count++;
399 		else
400 			targets.popFront;
401 	}
402 
403 	/// Apply a delegate to all todos specified by targets
404 	void apply( void delegate( ref Todo ) dg, Todo[] selectedTodos  ) {
405 		auto first = front;
406 		size_t count = 0;
407 		popFront;
408 		foreach ( ref t; selectedTodos ) {
409 			if (count == first) {
410 				dg( t );
411 				if ( empty )
412 					break;
413 				else {
414 					first = front;
415 					popFront;
416 				}
417 			}
418 			count++;
419 		}
420 	}
421 }
422 
423 /// Convert all or 1,.. into Targets
424 Targets parseTarget( string target ) {
425 	int[] targets;
426 	Targets ts;
427 	auto last_term = matchFirst( target, r"\S+$" )[0];
428 	if (match(last_term, r"(\d+,*){1,}$")) {
429 		auto map_result = (map!(a => to!int( a ))( split( last_term, regex(",")) ));
430 		foreach( a; map_result )
431 			targets ~= a;
432 		std.algorithm.sort( targets );
433 	} else if ( last_term == "all" ) {
434 		ts.all = true;
435 		return ts;
436 	}
437 	ts.targets = targets;
438 	return ts;
439 }
440 
441 unittest {
442 	auto targets = parseTarget( "bla 2" );
443 	assert( equal( targets.take(2), [2] ) );
444 	targets = parseTarget( "bla 2,1" );
445 	assert( equal( targets, [1,2] ) );
446 
447 	targets = parseTarget( "bla 2,a" );
448 	assert( targets.walkLength == 0 );
449 	targets = parseTarget( "bla all" );
450 	assert( equal(targets.take(5), [0,1,2,3,4]) );
451 
452 	targets = parseTarget( "bla" );
453 	assert( targets.take(5).walkLength == 0 );
454 }
455 
456