UNPKG

habitrpg-todo-sync

Version:

Two-way sync between task managers (e.g. Remember the Milk) and HabitRPG

672 lines (586 loc) 25.8 kB
#!/usr/bin/env node var TODO_SOURCE_RTM = "rtm"; // I hate long variable names, but one must do what one must do. var HRPG_INCOMPLETE = false; var HRPG_COMPLETE = true; var iniReader = require('inireader'); var parser = new iniReader.IniReader(), fs = require('fs'), path = require('path'), http = require('http'), https = require('https'), RtmNode = require('./lib/rtmnode'), prompt = require('prompt'), request = require('superagent'), url = require('url'), util = require('util'), moment = require('moment'), HabitRpg = require('./lib/node-habit'), _ = require('underscore'), // TODO: Implement .usage() and whatnot argv = require('optimist') .alias('f', 'force') // TODO: Support repeating --debug for regular-verbose (essential API requests/responses) and super-verbose (all kinds of stuff, kinda like now) output. // Optimist can' do bo .alias('debug', 'verbose') // Implies NOT quiet .alias('debug', 'v') .alias('a', 'full-sync') .alias('u', 'user-id') .alias('p', 'api-key') .alias('n', 'dry-run') .alias('dev', 'D') .alias('beta', 'B') .alias('q', 'silence') // Implies force .alias('q', 'SILENCE') .alias('q', 'quiet') .argv; if (argv.debug) { argv.q = false; } if (argv.q) { // Quiet means force. argv.f = true; } var hrpgConfigPath = path.resolve(path.join(process.env.HOME, '.habitrpgrc')); var debugMode = argv.debug ? true : false; var verboser = debugMode && argv.debug == "debug" || argv.debug == "2" ? true : false; var devServer = argv.dev ? true : false; var betaServer = argv.beta ? true : false; var mode = debugMode ? "on" : "off"; var requestStuff = { host: devServer ? 'localhost' : betaServer ? 'beta.habitrpg.com' : 'habitrpg.com', port: devServer ? 3000 : 443, protocol: devServer ? 'http' : 'https', path: "/api/v1/user/tasks" }; var habitResponse; var startTime = moment(); if (!argv.f) { prompt.start(); var properties = [ { name: 'okToContinue', type: 'string' } ]; console.log("You are about to synchronize tasks between HabitRPG and Remember the Milk.\n\n" + (argv.a ? "*** YOU HAVE REQUESTED A FULL SYNC OF ALL TASKS. ***\n\n" : "") + "Debugging output: " + mode + "\n" + "Server: " + requestStuff.host + "\n" + "Port: " + requestStuff.port + "\n\n" + (argv.n ? "*** This is a dry run. No data will be saved to HabitRPG or Remember the Milk. ***\n\n" : "") + "If this is OK with you, type the word yes in full"); prompt.get(properties, function(err, result) { if (err) { return onErr(err); } if (result.okToContinue == "yes") { startSync(); } }); } else { startSync(); } function startSync() { if (!argv.q) { console.log('Now syncing with ' + requestStuff.host + '...'); } var hrpgAuth = {}; if (fs.existsSync(hrpgConfigPath)) { if (argv.u && argv.p) { if (!argv.q) { console.log("Using HabitRPG credentials from the environment instead of reading ~/.habitrpgrc."); } hrpgAuth = { user_id: argv.u, api_token: argv.p }; } else { ////// START HABIT ////// parser.load(hrpgConfigPath); var userIdParam = "auth.user_id"; var apiTokenParam = "auth.api_token"; // Which keys should we read? if (devServer) { if (!argv.q) { console.log('Using [auth-dev] settings from ' + hrpgConfigPath); } userIdParam = "auth-dev.user_id"; apiTokenParam = "auth-dev.api_token"; } // Fall back gracefully-ish on the live settings even in DEBUG_MODE hrpgAuth = { user_id: parser.param(userIdParam) || parser.param('auth.user_id'), api_token: parser.param(apiTokenParam) || parser.param('auth.api_token') }; } habitRequestPath = requestStuff.protocol + '://' + requestStuff.host + ':' + requestStuff.port + requestStuff.path; // Oh, we're going to do stuff like this again later. var habitapi = new HabitRpg(hrpgAuth.user_id, hrpgAuth.api_token, requestStuff.protocol + '://' + requestStuff.host + ':' + requestStuff.port); // TODO: Need to write HabitRpg.getAllTasks() and use that instead of doing it manually here request.get(habitRequestPath) .query({type: 'todo'}) .type('application/json') .set('Accept: gzip, deflate') .set('x-api-user', hrpgAuth.user_id) .set('x-api-key', hrpgAuth.api_token) .end(function(res) { if (res.ok) { res.text = JSON.parse(res.text); habitResponse = moo(res.text); // I don't think Habit does this, but just in case. Also, I like calling moo(). if (!argv.q) { console.log('Finished getting HabitRPG tasks.'); } if (debugMode && verboser) { console.log("Massaged response from HabitRPG: " + util.inspect(habitResponse)); } if (!argv.q) { console.log("We're ready to sync with Remember the Milk. First, we have to make sure we're still authenticated..."); } ///// START RTM ////// // If we have the token, then first just grab all the tasks in their Inbox. // Delay all API calls by 1 second. // OK, let's see what RTM says I have to do...OK, first I need a signing function. I sign my requests with this. var tempRtmCreds = { apiKey: "1cca74e8b073112b8e5975ec3d797e1a", sharedSecret: "a253e6102be98e1d" }; // Will trigger event. No need to store it to a variable. var initialRtmApi = new RtmNode(tempRtmCreds.apiKey, tempRtmCreds.sharedSecret); tempRtmCreds.authToken = ""; // TODO: Check for a stored auth token. Do the following if we don't have it. if (fs.existsSync(path.join(process.env.HOME, '.htsrtmtoken.json'))) { pathToToken = path.join(process.env.HOME, '.htsrtmtoken.json'); tempRtmCreds.authToken = fs.readFileSync(pathToToken).toString(); } if (tempRtmCreds.authToken) { // Do a check to make sure it works also. Because it might have expired. initialRtmApi.checkToken(tempRtmCreds.authToken, function(result) { if (result) { rtmContinue(habitapi, initialRtmApi, tempRtmCreds.authToken); } else { if (!argv.f) { console.log("ACTION NEEDED: Looks like our authorization has expired. This happens sometimes; no big deal. I'm going to take you through the authentication process again now."); } else { console.log("HabitRPG Todo Synchronization exited because you weren't authenticated with Remember the Milk. Please run it manually and get set up."); return; } authorizeRtm(habitapi, initialRtmApi); } }); // Get tasks and stuff } else { authorizeRtm(habitapi, initialRtmApi); } ///// END RTM ////// } else { console.log("Got error: " + util.inspect(res.status) + ", " + util.inspect(res.header)); } }); ////// END HABIT ////// } else { console.log("Please create a file called .habitrpgrc in your home directory.\n\n" + "In it, put this:\n\n" + "[auth]\n" + "user_id = (your HabitRPG user ID)\n" + "api_token = (your HabitRPG API token)\n\n" + "You can find these on your HabitRPG settings page."); return; } } function authorizeRtm(habitapi, initialRtmApi) { // OK, wait, before the signing function, let's actually make something to sign. // Looks like the first thing I need is a frob. Uhh...oh wait, I need a way to call methods! I'll just do it straight up in the class to start and refactor later, maybe. existingFrob = undefined; skipSiteAuth = false; if (argv.frob !== undefined) { existingFrob = argv.frob; skipSiteAuth = true; } if (!skipSiteAuth) { if (argv.debug) { console.log('Existing frob: ' + existingFrob); } // TODO: Don't need existingFrob anymore. It was to avoid branching here. // Now I have, so kill it some itme. initialRtmApi.getFrob(existingFrob, function(theFrob) { // We have frob. Umm, so now what? Oh, OK. We have to build an // authentication URL. This is pretty easy. var authUrl = initialRtmApi.getAuthUrl(theFrob); console.log("\n" + 'Go here and authorize this app: ' + "\n\n" + authUrl + "\n\n" + "I'll wait. Just press enter when you're done or if you've already authorized and provided the frob as an environment variable (looking at you @wizonesolutions)."); prompt.start(); prompt.get("dummyEnter", function(err, result) { if (err) { return onErr(err); } onReturnFromRtmSite(habitapi, initialRtmApi, theFrob); }); }); } else { if (!argv.q) { console.log("WARNING: Skipping site authentication due to frob being provided on command line. This might send you into a callback loop. Press Ctrl+C if that happens."); } onReturnFromRtmSite(habitapi, initialRtmApi, existingFrob); } } function onReturnFromRtmSite(habitapi, initialRtmApi, theFrob) { initialRtmApi.getToken(theFrob, function(authToken) { if (!authToken) { console.log("ERROR: Looks like authentication didn't work out. No big deal. Let's try again."); // TODO: If they do this a lot, will the stack get too big? Unlikely to happen though, so I'm not going to think too hard about it... authorizeRtm(habitapi, initialRtmApi); return; } // Save the auth token, yeah? if (!argv.q) { console.log("Saving your auth token so you won't have to do this again for a while..."); } fs.writeFileSync(path.join(process.env.HOME, '.htsrtmtoken.json'), authToken); rtmContinue(habitapi, initialRtmApi, authToken); }); } function rtmContinue(habitapi, initialRtmApi, authToken) { initialRtmApi.setAuthToken(authToken); initialRtmApi.initializeTimeline(); initialRtmApi.on('RtmNodeReady', function(rtmapi) { if (!argv.q) { console.log("Alright, we're all good on the authentication front. Let's continue grabbing those tasks."); } var firstSyncFilter = 'status:incomplete AND addedWithin:"1 week of today"'; var prodPath = path.join(process.env.HOME, '.htsrtmlastsync'); var devPath = path.join(process.env.HOME, '.htsrtmlastsync-dev'); var rightPath = argv.dev ? devPath : prodPath; // TODO: Test that lastSync works when there is no file lastSync = undefined; filter = firstSyncFilter; // Something passed on the command line? if (argv.filter) { // OK, so in this case we AND their filter with status:incomplete. That's hardcoded until someone wants to override it. filter = 'status:incomplete AND ' + argv.filter; } // For the brave if (argv.a) { filter = 'status:incomplete'; if (argv.filter) { // OK, so in this case we AND their filter with status:incomplete. That's hardcoded until someone wants to override it. filter = 'status:incomplete AND ' + argv.filter; } lastSync = undefined; } else { // Figure out when we last synced. // TODO: Try combining this stuff floating around into one file. Either .habitrpgrc or my own. if ((!argv.dev && fs.existsSync(prodPath)) || (argv.dev && fs.existsSync(devPath))) { lastSync = fs.readFileSync(rightPath).toString(); if (filter == firstSyncFilter) { filter = undefined; // filter messes us up if we actually have a last_sync. } if (argv.filter) { filter = argv.filter; // We don't need status:incomplete here. You could almost say that we don't want it. } } } if (lastSync === undefined && !argv.a) { if (!argv.q) { console.log("This is the first run. A full sync was not requested, so we are getting tasks added within the last week."); } } else if (lastSync === undefined && argv.a) { if (!argv.q) { console.log("Doing a full sync!"); } } else { if (!argv.q) { console.log('We last synchronized on ' + lastSync); } } if (filter) { if (!argv.q) { console.log("We are filtering tasks with the following search criteria: " + filter); } } else { if (!argv.q) { console.log("We are not filtering tasks."); } } // For additional fun and profit (JUST KIDDING REMEMBER THE MILK; IT'S // STRICTLY ONLY FOR FUN), let's massage the Habit task data a little bit. var habitTaskMap = massageHabitTodos(habitResponse); if (!argv.n) { // TODO: Abstract path to this file. Quit duplicating code. // TODO: Roll back to the file's original time if something goes wrong. // OR: Emit an event when all the adding and deleting has finished, and only write the file then. fs.writeFileSync(rightPath, startTime.format()); } else { if (!argv.q) { console.log("DRY RUN: Not writing lastSync time to file."); } } rtmapi.getTasks(undefined, filter, lastSync, function(response) { // TODO: I would update the lastSync here // console.log(util.inspect(response.tasks)); response.tasks = moo(response.tasks); var tasksAdded = 0; response.tasks.every(function(item) { if (item === undefined) { return true; } item.list = moo(item.list); item.list.every(function(list) { if (list === undefined) { return true; } // console.log('taskseries for ' + item.id + ': ' + util.inspect(item.taskseries)); // We're pretty much done here, so it's fine for this to be async. I // think. It's probably going to say it's done too soon, but whatevs. list.taskseries = moo(list.taskseries); list.taskseries.every(function(taskseries) { if (debugMode && verboser) { console.log('Debug output for this taskseries: ' + util.inspect(taskseries)); } // Don't add completed tasks. if (taskseries === undefined || (taskseries && taskseries.task && taskseries.task.completed)) { return true; } // Add it. if (habitTaskMap && habitTaskMap[TODO_SOURCE_RTM] && habitTaskMap[TODO_SOURCE_RTM][taskseries.id]) { skipTask = true; putTask = false; thisTask = habitTaskMap[TODO_SOURCE_RTM][taskseries.id]; // So, has anything changed from what we have? taskSeriesDate = taskseries.task.due; thisTaskDate = thisTask.date; var dateTheSame = true; if (taskSeriesDate && thisTaskDate) { if (argv.debug) { console.log(taskseries.name + ": RTM's date is " + taskSeriesDate + " and ours is " + thisTaskDate); } dateTheSame = (moment(thisTask.date).format() == moment(taskseries.task.due).format()); } else { dateTheSame = false; if (!taskSeriesDate && !thisTaskDate) { if (argv.debug) { console.log(taskseries.name + ": Neither date is set."); } dateTheSame = true; } } // The date? Unset or changed? if (!dateTheSame || argv.refresh) { putTask = true; if (taskseries.task.due) { thisTask.date = moment(taskseries.task.due).format(); } else { thisTask.date = ''; } } // The name? if ((thisTask.text != taskseries.name) || argv.refresh) { putTask = true; thisTask.text = taskseries.name; } // TODO: Was it completed? if (skipTask) { if (argv.debug) { console.log('Skipping existing task: ' + taskseries.name); } } if (putTask) { if (!argv.q) { console.log('We know about "' + thisTask.text + '", but it was updated in Remember the Milk. Syncing changes.'); } habitapi.putTask(thisTask, function(err) { if (err) { console.log("ERROR: Saving task to Habit didn't work. We'll try again next time."); fs.writeFileSync(rightPath, lastSync); } }); } } else { if (!argv.n) { habitapi.addTask('todo', taskseries.name, { hts_external_id: taskseries.id, hts_external_source: TODO_SOURCE_RTM, hts_external_rtm_list_id: list.id, hts_external_rtm_task_id: taskseries.task.id, hts_last_known_state: HRPG_INCOMPLETE, api_source: TODO_SOURCE_RTM, up: true, down: false, value: 0, date: (moment(taskseries.task.due) ? moment(taskseries.task.due).format() : undefined) }, function(err, newTask) { if (!err) { habitTaskMap[TODO_SOURCE_RTM] = habitTaskMap[TODO_SOURCE_RTM] || {}; habitTaskMap[TODO_SOURCE_RTM][taskseries.id] = habitTaskMap[TODO_SOURCE_RTM][taskseries.id] || newTask; if (!argv.q) { console.log("Added: " + newTask.text); } } else { console.log("ERROR: We tried to add " + taskseries.name + ", but we had a problem. We'll try again next time."); fs.writeFileSync(rightPath, lastSync); } }); } else { if (!argv.q) { console.log('Dry run summary: Would add "' + taskseries.name + '". The API would tell us something like:' + "\n\n" + util.inspect({ type: "todo", text: taskseries.name, hts_external_id: taskseries.id, hts_external_source: TODO_SOURCE_RTM, hts_external_rtm_list_id: list.id, hts_external_rtm_task_id: taskseries.task.id, hts_last_known_state: HRPG_INCOMPLETE, api_source: TODO_SOURCE_RTM, completed: false, id: "not available in dry run mode", value: 0, date: (moment(taskseries.task.due) ? moment(taskseries.task.due).format() : undefined) })); } } tasksAdded++; if (argv.debug) { console.log('Total tasks found so far: ' + tasksAdded); } } return true; }); return true; }); return true; }); }); // In this one, we explicitly send a filter of undefined so we can check for deleted tasks rtmapi.getTasks(undefined, undefined, lastSync, function(response) { // TODO: I would update the lastSync here // console.log(util.inspect(response.tasks)); response.tasks = moo(response.tasks); var tasksAdded = 0; response.tasks.every(function(item) { if (item === undefined) { return true; } item.list = moo(item.list); item.list.every(function(list) { if (list === undefined) { return true; } // console.log('taskseries for ' + item.id + ': ' + util.inspect(item.taskseries)); // We're pretty much done here, so it's fine for this to be async. I // think. It's probably going to say it's done too soon, but whatevs. if (list.deleted) { list.deleted = moo(list.deleted); list.deleted.every(function(deleted) { if (deleted === undefined) { return false; } deleted.taskseries = moo(deleted.taskseries); deleted.taskseries.every(function(taskseries) { // TODO: OMG PUT THIS IN A VARIABLE STOP DUPING A TWO-LEVEL DEEP OBJECT // This comment left way too late at night if (habitTaskMap && habitTaskMap[TODO_SOURCE_RTM] && habitTaskMap[TODO_SOURCE_RTM][taskseries.id]) { if (!argv.q) { console.log('Deleting task: ' + habitTaskMap[TODO_SOURCE_RTM][taskseries.id].text); } if (!argv.n) { habitapi.deleteTask(habitTaskMap[TODO_SOURCE_RTM][taskseries.id].id, function(err, response) { if (!err) { if (!argv.q) { console.log("Deleted " + habitTaskMap[TODO_SOURCE_RTM][taskseries.id].text) } habitTaskMap[TODO_SOURCE_RTM][taskseries.id] = undefined; } else { console.log("Had a problem deleting " + habitTaskMap[TODO_SOURCE_RTM][taskseries.id].text + ". Will try again next time. If the problem persists, file a bug report at https://github.com/wizonesolutions/habitrpg-todo-sync/issues. It might be temporary though."); // Reset the lastSync time so it will try the delete again next time fs.writeFileSync(rightPath, lastSync); } }); } else { if (!argv.q) { console.log('Dry run, so not really deleting. Would delete Habit task ' + habitTaskMap[TODO_SOURCE_RTM][taskseries.id].id + ', called ' + habitTaskMap[TODO_SOURCE_RTM][taskseries.id].text); } } } else { if (argv.debug) { console.log("We have no record of task " + taskseries.id + ', so doing nothing.'); } } return true; }); return true; }); } return true; }); return true; }); }); processHabitTodos(habitTaskMap, habitapi, rtmapi); }); } function processHabitTodos(habitTaskMap, habitapi, rtmapi) { if (habitTaskMap && habitTaskMap[TODO_SOURCE_RTM]) { // This is pretty simple. Go through each Habit todo. Is it completed? Was it not completed last time? OK. Tell RTM that. var taskKeys = Object.keys(habitTaskMap[TODO_SOURCE_RTM]); taskKeys.forEach(function(taskKey) { var task = habitTaskMap[TODO_SOURCE_RTM][taskKey]; // Any falsy value is OK, hence no === if (task.hts_last_known_state == HRPG_INCOMPLETE && task.completed) { // Complete on RTM side. We do this blindly. It's OK. if (!argv.n) { rtmapi.completeTask(task.hts_external_rtm_list_id, task.hts_external_id, task.hts_external_rtm_task_id, undefined, function(err, rtmTask) { var harmlessError = false; if (err) { if (argv.debug) { console.log("err looks like: " + util.inspect(err)); } if (err.rsp.err.code == "340") { harmlessError = true; console.log("Remember the Milk said it doesn't know about " + task.hts_external_rtm_list_id + "." + task.hts_external_id + "." + task.hts_external_rtm_task_id + ". That's fine. We'll just update this task on our side so this doesn't happen again."); } } if (!err || harmlessError) { task.hts_last_known_state = HRPG_COMPLETE; habitapi.putTask(task); if (!argv.q) { console.log("Completed \"" + task.text + "\" in Remember the Milk. Good job!"); } } else { // Do nothing } }); } else { if (argv.debug) { console.log("Would complete list " + task.hts_external_rtm_list_id + ', taskseries ' + task.hts_external_id + ', ' + task.hts_external_rtm_task_id); console.log("In HabitRPG, this task is called: " + task.text); } } } else { if (debugMode) { if (verboser) { console.log('[HabitRPG] ' + task.text + ' still has the same status (' + (task.hts_last_known_state == HRPG_INCOMPLETE ? 'incomplete' : 'complete') + '), so doing nothing.'); } } } }); } } function onErr(err) { console.log(err); return 1; } function massageHabitTodos(habitResponse) { var massagedHabit = {}; // So, this is is pretty simple. Pretty sure we have an array at this point? habitResponse.every(function(item) { // Skip non-external tasks. if (item && item.hts_external_source) { // We want to sort them by service, then ID. So: massagedHabit[item.hts_external_source] = massagedHabit[item.hts_external_source] || {}; massagedHabit[item.hts_external_source][item.hts_external_id] = item; } return true; }); return massagedHabit; } // Looking at you, RTM's XML -> JSON conversion. // (This puts an object into a single-element array if it isn't an array // itself. Compensates for APIs that treat JSON like XML.) function moo(element) { return !_.isArray(element) ? [element] : element; } var arrayify = moo;