//////////////////////////////////////////////////////////////////////////////// // // Copyright 2006 - 2019, Paul Beckingham, Federico Hernandez. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included // in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // // https://www.opensource.org/licenses/mit-license.php // //////////////////////////////////////////////////////////////////////////////// #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #define STRING_EDIT_START_MOD "Start date modified." #define STRING_EDIT_SCHED_MOD "Scheduled date modified." #define STRING_EDIT_DUE_MOD "Due date modified." #define STRING_EDIT_UNTIL_MOD "Until date modified." #define STRING_EDIT_WAIT_MOD "Wait date modified." //////////////////////////////////////////////////////////////////////////////// CmdEdit::CmdEdit () { _keyword = "edit"; _usage = "task edit"; _description = "Launches an editor to modify a task directly"; _read_only = false; _displays_id = false; _needs_gc = false; _uses_context = true; _accepts_filter = true; _accepts_modifications = false; _accepts_miscellaneous = false; _category = Command::Category::operation; } //////////////////////////////////////////////////////////////////////////////// // Introducing the Silver Bullet. This feature is the catch-all fixative for // various other ills. This is like opening up the hood and going in with a // wrench. To be used sparingly. int CmdEdit::execute (std::string&) { // Filter the tasks. handleUntil (); handleRecurrence (); Filter filter; std::vector filtered; filter.subset (filtered); if (! filtered.size ()) { Context::getContext ().footnote ("No matches."); return 1; } // Find number of matching tasks. for (auto& task : filtered) { auto result = editFile (task); if (result == CmdEdit::editResult::error) break; else if (result == CmdEdit::editResult::changes) Context::getContext ().tdb2.modify (task); } return 0; } //////////////////////////////////////////////////////////////////////////////// std::string CmdEdit::findValue ( const std::string& text, const std::string& name) { auto found = text.find (name); if (found != std::string::npos) { auto eol = text.find ('\n', found + 1); if (eol != std::string::npos) { std::string value = text.substr ( found + name.length (), eol - (found + name.length ())); return Lexer::trim (value, "\t "); } } return ""; } //////////////////////////////////////////////////////////////////////////////// std::string CmdEdit::findMultilineValue ( const std::string& text, const std::string& startMarker, const std::string& endMarker) { auto start = text.find (startMarker); if (start != std::string::npos) { auto end = text.find (endMarker, start); if (end != std::string::npos) { std::string value = text.substr (start + startMarker.length (), end - (start + startMarker.length ())); return Lexer::trim (value, "\\\t "); } } return ""; } //////////////////////////////////////////////////////////////////////////////// std::vector CmdEdit::findValues ( const std::string& text, const std::string& name) { std::vector results; std::string::size_type found = 0; while (found != std::string::npos) { found = text.find (name, found + 1); if (found != std::string::npos) { auto eol = text.find ('\n', found + 1); if (eol != std::string::npos) { auto value = text.substr ( found + name.length (), eol - (found + name.length ())); found = eol - 1; results.push_back (Lexer::trim (value, "\t ")); } } } return results; } //////////////////////////////////////////////////////////////////////////////// std::string CmdEdit::formatDate ( Task& task, const std::string& attribute, const std::string& dateformat) { auto value = task.get (attribute); if (value.length ()) value = Datetime (value).toString (dateformat); return value; } //////////////////////////////////////////////////////////////////////////////// std::string CmdEdit::formatDuration ( Task& task, const std::string& attribute) { auto value = task.get (attribute); if (value.length ()) value = Duration (value).formatISO (); return value; } //////////////////////////////////////////////////////////////////////////////// std::string CmdEdit::formatTask (Task task, const std::string& dateformat) { std::stringstream before; auto verbose = Context::getContext ().verbose ("edit"); if (verbose) before << "# The 'task edit' command allows you to modify all aspects of a task\n" "# using a text editor. Below is a representation of all the task details.\n" "# Modify what you wish, and when you save and quit your editor,\n" "# Taskwarrior will read this file, determine what changed, and apply\n" "# those changes. If you exit your editor without saving or making\n" "# modifications, Taskwarrior will do nothing.\n" "#\n" "# Lines that begin with # represent data you cannot change, like ID.\n" "# If you get too creative with your editing, Taskwarrior will send you\n" "# back to the editor to try again.\n" "#\n" "# Should you find yourself in an endless loop, re-editing the same file,\n" "# just quit the editor without making any changes. Taskwarrior will\n" "# notice this and stop the editing.\n" "#\n"; before << "# Name Editable details\n" << "# ----------------- ----------------------------------------------------\n" << "# ID: " << task.id << '\n' << "# UUID: " << task.get ("uuid") << '\n' << "# Status: " << Lexer::ucFirst (Task::statusToText (task.getStatus ())) << '\n' << "# Mask: " << task.get ("mask") << '\n' << "# iMask: " << task.get ("imask") << '\n' << " Project: " << task.get ("project") << '\n'; if (verbose) before << "# Separate the tags with spaces, like this: tag1 tag2\n"; before << " Tags: " << join (" ", task.getTags ()) << '\n' << " Description: " << task.get ("description") << '\n' << " Created: " << formatDate (task, "entry", dateformat) << '\n' << " Started: " << formatDate (task, "start", dateformat) << '\n' << " Ended: " << formatDate (task, "end", dateformat) << '\n' << " Scheduled: " << formatDate (task, "scheduled", dateformat) << '\n' << " Due: " << formatDate (task, "due", dateformat) << '\n' << " Until: " << formatDate (task, "until", dateformat) << '\n' << " Recur: " << task.get ("recur") << '\n' << " Wait until: " << formatDate (task, "wait", dateformat) << '\n' << "# Modified: " << formatDate (task, "modified", dateformat) << '\n' << " Parent: " << task.get ("parent") << '\n'; if (verbose) before << "# Annotations look like this: -- and there can be any number of them.\n" "# The ' -- ' separator between the date and text field should not be removed.\n" "# A \"blank slot\" for adding an annotation follows for your convenience.\n"; for (auto& anno : task.getAnnotations ()) { Datetime dt (strtol (anno.first.substr (11).c_str (), nullptr, 10)); before << " Annotation: " << dt.toString (dateformat) << " -- " << json::encode (anno.second) << '\n'; } Datetime now; before << " Annotation: " << now.toString (dateformat) << " -- \n"; // Add dependencies here. auto dependencies = task.getDependencyUUIDs (); std::stringstream allDeps; for (unsigned int i = 0; i < dependencies.size (); ++i) { if (i) allDeps << ","; Task t; Context::getContext ().tdb2.get (dependencies[i], t); if (t.getStatus () == Task::pending || t.getStatus () == Task::waiting) allDeps << t.id; else allDeps << dependencies[i]; } if (verbose) before << "# Dependencies should be a comma-separated list of task IDs/UUIDs or ID ranges, with no spaces.\n"; before << " Dependencies: " << allDeps.str () << '\n'; // UDAs std::vector udas; for (auto& col : Context::getContext ().columns) if (Context::getContext ().config.get ("uda." + col.first + ".type") != "") udas.push_back (col.first); if (udas.size ()) { before << "# User Defined Attributes\n"; std::sort (udas.begin (), udas.end ()); for (auto& uda : udas) { int pad = 13 - uda.length (); std::string padding = ""; if (pad > 0) padding = std::string (pad, ' '); std::string type = Context::getContext ().config.get ("uda." + uda + ".type"); if (type == "string" || type == "numeric") before << " UDA " << uda << ": " << padding << task.get (uda) << '\n'; else if (type == "date") before << " UDA " << uda << ": " << padding << formatDate (task, uda, dateformat) << '\n'; else if (type == "duration") before << " UDA " << uda << ": " << padding << formatDuration (task, uda) << '\n'; } } // UDA orphans auto orphans = task.getUDAOrphanUUIDs (); if (orphans.size ()) { before << "# User Defined Attribute Orphans\n"; std::sort (orphans.begin (), orphans.end ()); for (auto& orphan : orphans) { int pad = 6 - orphan.length (); std::string padding = ""; if (pad > 0) padding = std::string (pad, ' '); before << " UDA Orphan " << orphan << ": " << padding << task.get (orphan) << '\n'; } } before << "# End\n"; return before.str (); } //////////////////////////////////////////////////////////////////////////////// void CmdEdit::parseTask (Task& task, const std::string& after, const std::string& dateformat) { // project auto value = findValue (after, "\n Project:"); if (task.get ("project") != value) { if (value != "") { Context::getContext ().footnote ("Project modified."); task.set ("project", value); } else { Context::getContext ().footnote ("Project deleted."); task.remove ("project"); } } // tags value = findValue (after, "\n Tags:"); task.remove ("tags"); task.addTags (split (value, ' ')); // description. value = findMultilineValue (after, "\n Description:", "\n Created:"); if (task.get ("description") != value) { if (value != "") { Context::getContext ().footnote ("Description modified."); task.set ("description", value); } else throw std::string ("Cannot remove description."); } // entry value = findValue (after, "\n Created:"); if (value != "") { if (value != formatDate (task, "entry", dateformat)) { Context::getContext ().footnote ("Creation date modified."); task.set ("entry", Datetime (value, dateformat).toEpochString ()); } } else throw std::string ("Cannot remove creation date."); // start value = findValue (after, "\n Started:"); if (value != "") { if (task.get ("start") != "") { if (value != formatDate (task, "start", dateformat)) { Context::getContext ().footnote (STRING_EDIT_START_MOD); task.set ("start", Datetime (value, dateformat).toEpochString ()); } } else { Context::getContext ().footnote (STRING_EDIT_START_MOD); task.set ("start", Datetime (value, dateformat).toEpochString ()); } } else { if (task.get ("start") != "") { Context::getContext ().footnote ("Start date removed."); task.remove ("start"); } } // end value = findValue (after, "\n Ended:"); if (value != "") { if (task.get ("end") != "") { if (value != formatDate (task, "end", dateformat)) { Context::getContext ().footnote ("End date modified."); task.set ("end", Datetime (value, dateformat).toEpochString ()); } } else if (task.getStatus () != Task::deleted) throw std::string ("Cannot set a done date on a pending task."); } else { if (task.get ("end") != "") { Context::getContext ().footnote ("End date removed."); task.setStatus (Task::pending); task.remove ("end"); } } // scheduled value = findValue (after, "\n Scheduled:"); if (value != "") { if (task.get ("scheduled") != "") { if (value != formatDate (task, "scheduled", dateformat)) { Context::getContext ().footnote (STRING_EDIT_SCHED_MOD); task.set ("scheduled", Datetime (value, dateformat).toEpochString ()); } } else { Context::getContext ().footnote (STRING_EDIT_SCHED_MOD); task.set ("scheduled", Datetime (value, dateformat).toEpochString ()); } } else { if (task.get ("scheduled") != "") { Context::getContext ().footnote ("Scheduled date removed."); task.remove ("scheduled"); } } // due value = findValue (after, "\n Due:"); if (value != "") { if (task.get ("due") != "") { if (value != formatDate (task, "due", dateformat)) { Context::getContext ().footnote (STRING_EDIT_DUE_MOD); task.set ("due", Datetime (value, dateformat).toEpochString ()); } } else { Context::getContext ().footnote (STRING_EDIT_DUE_MOD); task.set ("due", Datetime (value, dateformat).toEpochString ()); } } else { if (task.get ("due") != "") { if (task.getStatus () == Task::recurring || task.get ("parent") != "") { Context::getContext ().footnote ("Cannot remove a due date from a recurring task."); } else { Context::getContext ().footnote ("Due date removed."); task.remove ("due"); } } } // until value = findValue (after, "\n Until:"); if (value != "") { if (task.get ("until") != "") { if (value != formatDate (task, "until", dateformat)) { Context::getContext ().footnote (STRING_EDIT_UNTIL_MOD); task.set ("until", Datetime (value, dateformat).toEpochString ()); } } else { Context::getContext ().footnote (STRING_EDIT_UNTIL_MOD); task.set ("until", Datetime (value, dateformat).toEpochString ()); } } else { if (task.get ("until") != "") { Context::getContext ().footnote ("Until date removed."); task.remove ("until"); } } // recur value = findValue (after, "\n Recur:"); if (value != task.get ("recur")) { if (value != "") { Duration p; std::string::size_type idx = 0; if (p.parse (value, idx)) { Context::getContext ().footnote ("Recurrence modified."); if (task.get ("due") != "") { task.set ("recur", value); task.setStatus (Task::recurring); } else throw std::string ("A recurring task must have a due date."); } else throw std::string ("Not a valid recurrence duration."); } else { Context::getContext ().footnote ("Recurrence removed."); task.setStatus (Task::pending); task.remove ("recur"); task.remove ("until"); task.remove ("mask"); task.remove ("imask"); } } // wait value = findValue (after, "\n Wait until:"); if (value != "") { if (task.get ("wait") != "") { if (value != formatDate (task, "wait", dateformat)) { Context::getContext ().footnote (STRING_EDIT_WAIT_MOD); task.set ("wait", Datetime (value, dateformat).toEpochString ()); task.setStatus (Task::waiting); } } else { Context::getContext ().footnote (STRING_EDIT_WAIT_MOD); task.set ("wait", Datetime (value, dateformat).toEpochString ()); task.setStatus (Task::waiting); } } else { if (task.get ("wait") != "") { Context::getContext ().footnote ("Wait date removed."); task.remove ("wait"); task.setStatus (Task::pending); } } // parent value = findValue (after, "\n Parent:"); if (value != task.get ("parent")) { if (value != "") { Context::getContext ().footnote ("Parent UUID modified."); task.set ("parent", value); } else { Context::getContext ().footnote ("Parent UUID removed."); task.remove ("parent"); } } // Annotations std::map annotations; std::string::size_type found = 0; while ((found = after.find ("\n Annotation:", found)) != std::string::npos) { found += 14; // Length of "\n Annotation:". auto eol = after.find ('\n', found + 1); if (eol != std::string::npos) { auto value = Lexer::trim (after.substr (found, eol - found), "\t "); auto gap = value.find (" -- "); if (gap != std::string::npos) { // TODO keeping the initial dates even if dateformat approximates them // is complex as finding the correspondence between each original line // and edited line may be impossible (bug #705). It would be simpler if // each annotation was put on a line with a distinguishable id (then // for each line: if the annotation is the same, then it is copied; if // the annotation is modified, then its original date may be kept; and // if there is no corresponding id, then a new unique date is created). Datetime when (value.substr (0, gap), dateformat); // If the map already contains a annotation for a given timestamp // we need to increment until we find an unused key int timestamp = (int) when.toEpoch (); std::stringstream name; do { name.str (""); // Clear name << "annotation_" << timestamp; timestamp++; } while (annotations.find (name.str ()) != annotations.end ()); auto text = Lexer::trim (value.substr (gap + 4), "\t "); annotations.insert (std::make_pair (name.str (), json::decode (text))); } } } task.setAnnotations (annotations); // Dependencies value = findValue (after, "\n Dependencies:"); auto dependencies = split (value, ','); task.remove ("depends"); for (auto& dep : dependencies) { if (dep.length () >= 7) task.addDependency (dep); else task.addDependency ((int) strtol (dep.c_str (), nullptr, 10)); } // UDAs for (auto& col : Context::getContext ().columns) { auto type = Context::getContext ().config.get ("uda." + col.first + ".type"); if (type != "") { auto value = findValue (after, "\n UDA " + col.first + ":"); if ((task.get (col.first) != value) && (type != "date" || (task.get (col.first) != Datetime (value, dateformat).toEpochString ())) && (type != "duration" || (task.get (col.first) != Duration (value).toString ()))) { if (value != "") { Context::getContext ().footnote (format ("UDA {1} modified.", col.first)); if (type == "string") { task.set (col.first, value); } else if (type == "numeric") { Pig pig (value); double d; if (pig.getNumber (d) && pig.eos ()) task.set (col.first, value); else throw format ("The value '{1}' is not a valid numeric value.", value); } else if (type == "date") { task.set (col.first, Datetime (value, dateformat).toEpochString ()); } else if (type == "duration") { task.set (col.first, Duration (value).toTime_t ()); } } else { Context::getContext ().footnote (format ("UDA {1} deleted.", col.first)); task.remove (col.first); } } } } // UDA orphans for (auto& orphan : findValues (after, "\n UDA Orphan ")) { auto colon = orphan.find (':'); if (colon != std::string::npos) { std::string name = Lexer::trim (orphan.substr (0, colon), "\t "); std::string value = Lexer::trim (orphan.substr (colon + 1), "\t "); if (value != "") task.set (name, value); else task.remove (name); } } } //////////////////////////////////////////////////////////////////////////////// CmdEdit::editResult CmdEdit::editFile (Task& task) { // Check for file permissions. Directory location (Context::getContext ().config.get ("data.location")); if (! location.writable ()) throw std::string ("Your data.location directory is not writable."); // Create a temp file name in data.location. std::stringstream file; file << "task." << task.get ("uuid").substr (0, 8) << ".task"; // Determine the output date format, which uses a hierarchy of definitions. // rc.dateformat.edit // rc.dateformat auto dateformat = Context::getContext ().config.get ("dateformat.edit"); if (dateformat == "") dateformat = Context::getContext ().config.get ("dateformat"); // Change directory for the editor auto current_dir = Directory::cwd (); int ignored = chdir (location._data.c_str ()); ++ignored; // Keep compiler quiet. // Check if the file already exists, if so, bail out Path filepath = Path (file.str ()); if (filepath.exists ()) throw std::string ("Task is already being edited."); // Format the contents, T -> text, write to a file. auto before = formatTask (task, dateformat); auto before_orig = before; File::write (file.str (), before); // Determine correct editor: .taskrc:editor > $VISUAL > $EDITOR > vi auto editor = Context::getContext ().config.get ("editor"); char* peditor = getenv ("VISUAL"); if (editor == "" && peditor) editor = std::string (peditor); peditor = getenv ("EDITOR"); if (editor == "" && peditor) editor = std::string (peditor); if (editor == "") editor = "vi"; // Complete the command line. editor += ' '; editor += '"' + file.str () + '"'; ARE_THESE_REALLY_HARMFUL: bool changes = false; // No changes made. // Launch the editor. std::cout << format ("Launching '{1}' now...\n", editor); int exitcode = system (editor.c_str ()); auto captured_errno = errno; if (0 == exitcode) std::cout << "Editing complete.\n"; else { std::cout << format ("Editing failed with exit code {1}.\n", exitcode); if (-1 == exitcode) std::cout << std::strerror (captured_errno) << '\n'; File::remove (file.str ()); return CmdEdit::editResult::error; } // Slurp file. std::string after; File::read (file.str (), after); // Update task based on what can be parsed back out of the file, but only // if changes were made. if (before_orig != after) { std::cout << "Edits were detected.\n"; std::string problem = ""; auto oops = false; try { parseTask (task, after, dateformat); } catch (const std::string& e) { problem = e; oops = true; } if (oops) { std::cerr << "Error: " << problem << '\n'; // Preserve the edits. before = after; File::write (file.str (), before); if (confirm ("Taskwarrior couldn't handle your edits. Would you like to try again?")) goto ARE_THESE_REALLY_HARMFUL; } else changes = true; } else { std::cout << "No edits were detected.\n"; changes = false; } // Cleanup. File::remove (file.str ()); ignored = chdir (current_dir.c_str ()); return changes ? CmdEdit::editResult::changes : CmdEdit::editResult::nochanges; } ////////////////////////////////////////////////////////////////////////////////