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.habitrpg;
25 
26 import std.net.curl;
27 import std.conv;
28 import std.algorithm;
29 import std.array; 
30 import core.thread;
31 import std.encoding;
32 
33 import std.regex;
34 
35 import std.stdio;
36 import std.file;
37 
38 import std.json;
39 import std.uuid;
40 
41 import todod.date;
42 import todod.commandline;
43 import todod.state;
44 import todod.tag;
45 import todod.todo;
46 
47 version (unittest) {
48 	import std.stdio;
49 }
50 
51 struct HabitRPG {
52 	string api_user = "-1";
53 	string api_key = "-1";
54 
55 	bool opCast( T : bool )() const {
56 		if ( api_user == "-1" || api_key == "-1" )
57 			return false;
58 		return true;
59 	}
60 }
61 
62 /// Read HabitRPG settings from config file
63 HabitRPG loadHRPG( string fileName ) {
64 	HabitRPG hrpg;
65 	if (exists( fileName )) {
66 		File file = File( fileName, "r" );
67 		string content;
68 		foreach( line; file.byLine() )
69 			content ~= line;
70 		if (content != "") {
71 			auto json = parseJSON( content );
72 			hrpg.api_user = json["api_user"].str;
73 			hrpg.api_key = json["api_key"].str;
74 		}
75 	} else {
76 		// Create empty config file.
77 		JSONValue[string] jsonHRPG;
78 		jsonHRPG["api_user"] = hrpg.api_user;
79 		jsonHRPG["api_key"] = hrpg.api_key;
80 		auto json = JSONValue( jsonHRPG );
81 		File file = File( fileName, "w" );
82 		file.writeln( json.toPrettyString );
83 	}
84 	return hrpg;
85 }
86 
87 HTTP connectHabitRPG( HabitRPG hrpg ) {
88 	assert( hrpg, "Need to provide a valid HabitRPG struct" );
89 	auto http = HTTP( "https://habitrpg.com/" );
90 	http.addRequestHeader( "x-api-user", hrpg.api_user );
91 	http.addRequestHeader( "x-api-key", hrpg.api_key );
92 	http.addRequestHeader( "Content-Type","application/json" );
93 	return http;
94 }
95 
96 string upHabit( const HabitRPG hrpg, string habit ) {
97 	string result;
98 
99 	if (hrpg) {
100 		auto http = connectHabitRPG( hrpg );
101 		auto url = "https://habitrpg.com/api/v2/user/tasks/" ~ habit ~ "/up";
102 		http.postData = "";
103 		http.method = HTTP.Method.post;
104 		http.url = url;
105 		http.onReceive = (ubyte[] data) { 
106 			result ~= array( map!(a => cast(char) a)( data ) );
107 			return data.length; 
108 		};
109 
110 		http.perform();
111 	}
112 
113 	return result;
114 }
115 
116 void putMessage( HabitRPG hrpg, string url, string msg ) {
117 	auto http = connectHabitRPG( hrpg );
118 	http.method = HTTP.Method.put;
119 	http.url = url;
120 	http.contentLength = msg.length;
121 	http.onSend = (void[] data)
122 	{
123 		auto m = cast(void[])msg;
124 		size_t len = m.length > data.length ? data.length : m.length;
125 		if (len == 0) return len;
126 		data[0..len] = m[0..len];
127 		msg = msg[len..$];
128 		return len;
129 	};
130 	http.perform();
131 }
132 
133 void postMessage( HabitRPG hrpg, string url, string msg ) {
134 	auto http = connectHabitRPG( hrpg );
135 	http.method = HTTP.Method.post;
136 	http.url = url;
137 	http.contentLength = msg.length;
138 	http.onSend = (void[] data)
139 	{
140 		auto m = cast(void[])msg;
141 		size_t len = m.length > data.length ? data.length : m.length;
142 		if (len == 0) return len;
143 		data[0..len] = m[0..len];
144 		msg = msg[len..$];
145 		return len;
146 	};
147 	http.perform();
148 }
149 
150 void postNewTag( HabitRPG hrpg, Tag tag )
151 	in {
152 		assert( !tag.id.empty, "Tag UUID needs to be initialized" );
153 	}
154 body {
155 	postMessage( hrpg, "https://habitrpg.com/api/v2/user/tags",
156 			tag.toJSON.toString);
157 }
158 
159 /// Sync tags with habitrpg. Ensures all tag ids are set properly and returns
160 /// list of all tags know to habitrpg
161 Tags syncTags( Tags tags, HabitRPG hrpg )
162 in 
163 {
164 	assert( hrpg );
165 }
166 body
167 {
168 	// Get tags from habitrpg
169 	auto http = connectHabitRPG( hrpg );
170 	auto url = "https://habitrpg.com/api/v2/user/";
171 	http.url = url;
172 	string result;
173 
174 	http.method = HTTP.Method.get;
175 	http.onReceive = (ubyte[] data) { 
176 		result ~= array( map!(a => cast(char) a)( data ) );
177 		return data.length; 
178 	};
179 
180 	http.perform();
181 
182 	Tags hrpgTags;
183 
184 	auto tagsJSON = parseJSON( result )["tags"].array;
185 	foreach ( tag; tagsJSON ) {
186 		hrpgTags.add( Tag.parseJSON( tag ) );
187 	}
188 
189 	// Remove those tags from all todo tags
190 	tags.remove( hrpgTags );
191 
192 	// Push all tags to habitrpg (and set id)
193 	foreach( tag; tags ) {
194 		// Create new tag id
195 		if ( tag.id.empty )
196 			tag.id = randomUUID();
197 		postNewTag( hrpg, tag );
198 	}
199 
200 	tags.add( hrpgTags );
201 	return tags;
202 }
203 
204 /// Convert Todo toHabitRPGJSON
205 /// Needs a copy of all tags to check habitrpg ids etc
206 /*
207 	   {
208     "date": "2014-05-04T23:00:00.000Z",
209     "text": "Blargh",
210     "attribute": "str",
211     "priority": 1,
212     "value": 0,
213     "notes": "",
214     "dateCreated": "2014-05-07T06:23:40.367Z",
215     "id": "c708e86a-3901-4c41-b9fb-6c29f2da7949",
216     "checklist": [],
217     "collapseChecklist": false,
218     "archived": false,
219     "completed": false,
220     "type": "todo"
221   },
222 	*/
223 string toHabitRPGJSON( Todo todo, Tags tags ) {
224 	JSONValue[string] json;
225 	json["text"] = todo.title;
226 	//json["dateCreated"] = todo.creation_date.toString; // Need to convert to proper format
227 	json["type"] = "todo";
228 	// add tags
229 	JSONValue[string] tagArray;
230 	foreach ( tag; todo.tags ) {
231 		auto id = find( tags, tag )[0].id;
232 		tagArray[id.toString] = JSONValue(true);
233 	}
234 	json["tags"] = JSONValue( tagArray );
235 	json["dateCreated"] = toStringDate( todo.creation_date );
236 	if (todo.due_date)
237 		json["date"] = toStringDate( todo.due_date );
238 	assert( !todo.id.empty );
239 	json["id"] = todo.id.toString;
240 	return JSONValue( json ).toString;
241 }
242 
243 Todos syncTodos( Todos ts, Tags allTags, HabitRPG hrpg ) 
244 in 
245 {
246 	assert( hrpg );
247 }
248 body
249 {
250 	debug writeln( "Debug: Starting Todo sync." );
251 	// Needed for tag ids for all todos 
252 	Tags tags = syncTags( allTags, hrpg );
253 
254 	Todos hrpgTodos;
255 	debug writeln( "Debug: Adding existing Todos to hbrgTodos." );
256 	foreach( todo; ts )
257 		hrpgTodos.add( ts );
258 
259 	// Get all habitrpg tasks of type Todo
260 	debug writeln( "Debug: Getting existing Todos from HabitRPG." );
261 	auto http = connectHabitRPG( hrpg );
262 	auto url = "https://habitrpg.com/api/v2/user/tasks";
263 	http.url = url;
264 	string result;
265 
266 	http.method = HTTP.Method.get;
267 	http.onReceive = (ubyte[] data) { 
268 		result ~= array( map!(a => cast(char) a)( data ) );
269 		return data.length; 
270 	};
271 
272 	http.perform();
273 
274 	debug writeln( "Debug: Converting HabitRPG tasks to Todos and remove them from the to sync list." );
275 	foreach ( task; parseJSON( result ).array ) {
276 		if ( task["type"].str == "todo" ) {
277 			JSONValue[string] taskArray = task.object;
278 			if ( !("completed" in taskArray) 
279 					|| taskArray["completed"].type == JSON_TYPE.FALSE) {
280 				// Convert to Todo
281 				auto todo = new Todo( task["text"].str );
282 				if ("dateCreated" in taskArray)
283 					todo.creation_date = Date( taskArray["dateCreated"].str );
284 				if ("date" in taskArray)
285 					todo.due_date = Date( taskArray["date"].str );
286 				todo.id = UUID( taskArray["id"].str );
287 
288 				// Remove from hrpgTodos
289 				hrpgTodos.remove( todo );
290 				ts.add( todo );
291 			}
292 		}
293 	}
294 
295 	// Foreach hrpgTodos still in the list
296 	debug writeln( "Debug: Pushing missing Todos to HabitRPG." );
297 	foreach ( todo; hrpgTodos ) {
298 		// Convert to HabitRPGTodo ( will need to pass along tags )
299 		if ( todo.id.empty )
300 			todo.id = randomUUID;
301 				
302 		auto msg = toHabitRPGJSON( todo, tags );
303 		// Post new Todo
304 		postMessage( hrpg, url, msg );
305 	}
306 	return ts;
307 }
308 
309 /// Mark give todo as completed in HabitRPG
310 Todo doneTodo( Todo todo, const HabitRPG hrpg ) {
311 	if (todo.id.empty)
312 		return todo;
313 
314 	upHabit( hrpg, todo.id.toString );
315 
316 	auto url = "https://habitrpg.com/api/v2/user/tasks/" ~ todo.id.toString;
317 
318 	putMessage( hrpg, url, "{ \"completed\": true }" );
319 
320 	return todo;
321 }
322 
323 Commands!( State delegate( State, string) ) addHabitRPGCommands( 
324 		ref Commands!( State delegate( State, string) ) main, string dirName ) {
325 	HabitRPG hrpg = loadHRPG( dirName ~ "habitrpg.json" );
326 	if (hrpg) {
327 		auto habitRPGCommands = Commands!( State delegate( State, string) )("Commands specifically used to interact with HabitRPG");
328 
329 		habitRPGCommands.add( 
330 				"tags", delegate( State state, string parameter ) {
331 			syncTags( state.tags, hrpg );
332 			return state;
333 		}, "Sync tags with HabitRPG" );
334 
335 		habitRPGCommands.add( 
336 				"todos", delegate( State state, string parameter ) {
337 			state.todos = syncTodos( state.todos, state.tags, hrpg );
338 			return state;
339 		}, "Sync todos (and tags) with HabitRPG" );
340 
341 		habitRPGCommands.add( 
342 				"help", delegate( State state, string parameter ) {
343 			state = main["clear"]( state, "" ); 
344 			writeln( habitRPGCommands.toString );
345 			return state;
346 		}, "Print this help message" );
347 
348 		main.add( 
349 				"habitrpg", delegate( State state, string parameter ) {
350 				auto split = parameter.findSplit( " " );
351 				state = habitRPGCommands[split[0]]( state, split[2] );
352 				return state;
353 				}, "Syncing with HabitRPG. Use habitrpg help for more help." );
354 
355 		main.addCompletion( "habitrpg",
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 = habitRPGCommands.commands;
362 					auto matching_commands =
363 						filter!( a => match( a, m.captures[1] ))( command_keys );
364 					foreach ( com; matching_commands ) {
365 						results ~= [cmd ~ " " ~ com];
366 					}
367 				}
368 				return results;
369 			}
370 		);
371 	}
372 	return main;
373 }
374 
375 /*unittest {
376 	HabitRPG hrpg;
377 	hrpg.api_user = "f55f430e-36b8-4ebf-b6fa-ad4ff552fe7e";
378 	hrpg.api_key = "3fca0d72-2f95-4e57-99e5-43ddb85b9780";
379 	Tag tag;
380 	tag.name = "1tag";
381 	tag.id = randomUUID;
382 	postNewTag( hrpg, tag );
383 }*/