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 }*/