├── MANIFEST.in ├── .gitignore ├── tests ├── __init__.py ├── test_utils.py ├── test_interactive_merge.py ├── helpers.py ├── test_parser.py ├── test_globalization.py ├── test_merge_repeated.py └── test_merge.py ├── michel ├── __main__.py ├── utils.py ├── console.py ├── __init__.py ├── mergeconf.py ├── gtasks.py ├── mergetask.py └── tasktree.py ├── setup.py ├── README.md └── LICENSE.txt /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.md 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.egg-info 3 | htmlcov 4 | *.pyc 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | from tests.helpers import TestAdapter, TestMergeConf, createTestTree, getLocaleAlias 12 | -------------------------------------------------------------------------------- /michel/__main__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the 2 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 3 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import os 6 | import sys 7 | 8 | if __package__ == '': 9 | path = os.path.dirname(os.path.dirname(__file__)) 10 | sys.path.insert(0, path) 11 | 12 | import michel 13 | 14 | if __name__ == "__main__": 15 | michel.main() 16 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | import unittest 12 | 13 | import michel as m 14 | 15 | class TestMichel(unittest.TestCase): 16 | 17 | def test_parse_provider_url(self): 18 | protocol, path, params = m.parse_provider_url("test://node1/node2?param1=2¶m2=1") 19 | self.assertEqual(protocol, "test") 20 | self.assertEqual(path, ["node1", "node2"]) 21 | self.assertEqual(params, {"param1": "2", "param2": "1"}) 22 | 23 | 24 | def test_parse_provider_url_wo_params(self): 25 | protocol, path, params = m.parse_provider_url("test://node1/à faire") 26 | self.assertEqual(protocol, "test") 27 | self.assertEqual(path, ["node1", "à faire"]) 28 | self.assertEqual(params, {}) 29 | 30 | 31 | def test_get_provider(self): 32 | provider = m.get_provider("gtask://__default/default") 33 | self.assertIsNotNone(provider) 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the 2 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 3 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from __future__ import print_function 6 | from setuptools import setup, find_packages 7 | from codecs import open 8 | from os import path 9 | import sys 10 | 11 | here = path.abspath(path.dirname(__file__)) 12 | 13 | if sys.version_info < (3, 3): 14 | print('Sorry, only python>=3.3 is supported', file=sys.stderr) 15 | sys.exit(1) 16 | 17 | with open(path.join(here, 'LICENSE.txt'), encoding='utf-8') as f: 18 | license = f.read() 19 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 20 | long_description = f.read() 21 | 22 | setup( 23 | name='michel2', 24 | version='0.8.0', 25 | 26 | description='push/pull/sync an org-mode file to other task-list systems', 27 | long_description=long_description, 28 | 29 | url='https://github.com/anticodeninja/michel2', 30 | 31 | maintainer='anticodeninja', 32 | author='anticodeninja', 33 | 34 | license=license, 35 | 36 | packages=find_packages(), 37 | install_requires=[ 38 | 'google-api-python-client', 39 | 'oauth2client' 40 | ], 41 | python_requires='>=3.3', 42 | 43 | entry_points={ 44 | 'console_scripts' : [ 45 | 'michel=michel:main', 46 | ], 47 | }, 48 | ) 49 | -------------------------------------------------------------------------------- /michel/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This Source Code Form is subject to the terms of the 5 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 6 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import datetime 9 | import time 10 | import os 11 | import locale 12 | import sys 13 | 14 | from importlib.machinery import SourceFileLoader 15 | 16 | import michel as m 17 | 18 | 19 | def parse_provider_url(url): 20 | protocol, extra = url.split('://') 21 | extra = extra.split('?') 22 | 23 | path = extra[0] 24 | path = path.split("/") 25 | 26 | params = dict(x.split("=") for x in extra[1].split("&")) if len(extra) > 1 else {} 27 | 28 | return protocol, path, params 29 | 30 | def get_provider(url): 31 | protocol, path, params = m.parse_provider_url(url) 32 | dirname = os.path.dirname(__file__) 33 | provider_name = (protocol + "provider").lower() 34 | 35 | for filename in os.listdir(dirname): 36 | name, ext = os.path.splitext(os.path.basename(filename)) 37 | if name == "__init__" or name == "__main__" or ext != ".py": 38 | continue 39 | 40 | temp = SourceFileLoader(name, os.path.join(dirname, filename)).load_module() 41 | for entryname in dir(temp): 42 | if entryname.lower() != provider_name: 43 | continue 44 | 45 | return getattr(temp, entryname)(path, params) 46 | 47 | raise Exception("Provider does not found") 48 | 49 | def save_data_path(file_name): 50 | data_path = os.path.join(os.path.expanduser('~'), ".michel") 51 | if not os.path.exists(data_path): 52 | os.makedirs(data_path) 53 | return os.path.join(data_path, file_name) 54 | 55 | def get_index(items, pred): 56 | for i, v in enumerate(items): 57 | if pred(v): 58 | return i 59 | return None 60 | 61 | def uprint(*objects, sep=' ', end='\n', file=sys.stdout): 62 | enc = file.encoding 63 | if enc == 'UTF-8': 64 | print(*objects, sep=sep, end=end, file=file, flush=True) 65 | else: 66 | f = lambda obj: str(obj).encode(enc, errors='backslashreplace').decode(enc) 67 | print(*map(f, objects), sep=sep, end=end, file=file, flush=True) 68 | -------------------------------------------------------------------------------- /michel/console.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This Source Code Form is subject to the terms of the 5 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 6 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import os 9 | import sys 10 | 11 | if os.name == 'posix': 12 | UP_CURSOR_CODE = "\033[A" 13 | CLEAN_ROW_CODE = "\033[K" 14 | 15 | def cleanLastRows(amount): 16 | print((UP_CURSOR_CODE + CLEAN_ROW_CODE) * amount + UP_CURSOR_CODE) 17 | 18 | elif os.name == 'nt': 19 | import ctypes 20 | from ctypes import LibraryLoader 21 | from ctypes import wintypes 22 | 23 | class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): 24 | """struct in wincon.h.""" 25 | _fields_ = [ 26 | ("dwSize", wintypes._COORD), 27 | ("dwCursorPosition", wintypes._COORD), 28 | ("wAttributes", wintypes.WORD), 29 | ("srWindow", wintypes._SMALL_RECT), 30 | ("dwMaximumWindowSize", wintypes._COORD)] 31 | 32 | def __str__(self): 33 | return '(%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d)' % ( 34 | self.dwSize.Y, self.dwSize.X, 35 | self.dwCursorPosition.Y, self.dwCursorPosition.X, 36 | self.wAttributes, 37 | self.srWindow.Top, self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right, 38 | self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X, 39 | ) 40 | 41 | STDOUT = -11 42 | 43 | windll = LibraryLoader(ctypes.WinDLL) 44 | stdout_handle = windll.kernel32.GetStdHandle(STDOUT) 45 | 46 | def cleanLastRows(amount): 47 | csbi = CONSOLE_SCREEN_BUFFER_INFO() 48 | windll.kernel32.GetConsoleScreenBufferInfo(stdout_handle, ctypes.byref(csbi)) 49 | 50 | pos = wintypes._COORD(0, csbi.dwCursorPosition.Y-amount) 51 | written = wintypes.DWORD(0) 52 | windll.kernel32.FillConsoleOutputCharacterA(stdout_handle, 53 | ctypes.c_char(32), 54 | wintypes.DWORD(csbi.dwSize.X * amount), 55 | pos, 56 | ctypes.byref(written)) 57 | windll.kernel32.SetConsoleCursorPosition(stdout_handle, pos) 58 | 59 | else: 60 | print("Sorry, your OS is not supported, please contact with a developer") 61 | sys.exit(2) 62 | -------------------------------------------------------------------------------- /tests/test_interactive_merge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | import unittest 12 | import textwrap 13 | import os 14 | import sys 15 | import tempfile 16 | import datetime 17 | import locale 18 | 19 | import michel as m 20 | import tests 21 | 22 | @unittest.skipUnless(os.getenv('MANUAL_TESTING'), 'Enable only for manual-testing') 23 | class InteractiveMergeTests(unittest.TestCase): 24 | 25 | def test_select_org_task(self): 26 | conf = m.InteractiveMergeConf(tests.TestAdapter()) 27 | tasks = [m.TasksTree('Correct Task'), m.TasksTree('Absolutely Another Task')] 28 | 29 | self.assertEqual(conf.select_org_task(m.TasksTree('Select Correct Task'), tasks), 0) 30 | self.assertEqual(conf.select_org_task(m.TasksTree('Create new'), tasks), 'new') 31 | self.assertEqual(conf.select_org_task(m.TasksTree('Discard it'), tasks), 'discard') 32 | 33 | def test_merge_title(self): 34 | conf = m.InteractiveMergeConf(tests.TestAdapter()) 35 | tasks = [m.TasksTree('Choose it'), m.TasksTree('Do not chouse it')] 36 | 37 | self.assertEqual(conf.merge_title(m.MergeEntry(tasks[0], tasks[1])), tasks[0].title) 38 | 39 | def test_merge_schedule_time(self): 40 | conf = m.InteractiveMergeConf(tests.TestAdapter()) 41 | tasks = [ 42 | m.TasksTree('!!!Choose earlier!!!').update( 43 | schedule_time=m.OrgDate(datetime.date(2015, 12, 15))), 44 | m.TasksTree('Press Ctrl-D').update( 45 | schedule_time=m.OrgDate(datetime.date(2015, 12, 10)))] 46 | 47 | self.assertEqual(conf.merge_schedule_time(m.MergeEntry(tasks[0], tasks[1])), tasks[1].schedule_time) 48 | 49 | def test_merge_notes(self): 50 | conf = m.InteractiveMergeConf(tests.TestAdapter()) 51 | tasks = [ 52 | m.TasksTree('Simply choose necessary block').update( 53 | notes=["Choose this block", ":) :) :)"]), 54 | m.TasksTree('Press Ctrl-D').update( 55 | notes=["Do not choose it", ":( :( :("])] 56 | 57 | self.assertEqual(conf.merge_notes(m.MergeEntry(tasks[0], tasks[1])), tasks[0].notes) 58 | 59 | tasks = [ 60 | m.TasksTree('Merge it by external editor').update( 61 | notes=["Choose this block"]), 62 | m.TasksTree('Press Ctrl-D').update( 63 | notes=["Remove this block", "Remove part from this block"])] 64 | self.assertEqual(conf.merge_notes(m.MergeEntry(tasks[0], tasks[1])), ["Choose this block", "Remove part from this block"]) 65 | 66 | if __name__ == '__main__': 67 | unittest.main() 68 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This Source Code Form is subject to the terms of the 5 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 6 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import os 9 | import locale 10 | import michel 11 | 12 | class TestAdapter: 13 | pass 14 | 15 | class TestMergeConf: 16 | def __init__(self, renames = None): 17 | self.__renames = renames 18 | 19 | def is_needed(self, task): 20 | return task.todo and not task.completed 21 | 22 | def select_org_task(self, unmapped_task, tasklist): 23 | if self.__renames: 24 | for old, new, _ in self.__renames: 25 | if unmapped_task.title == new: 26 | if old: 27 | return michel.utils.get_index( 28 | tasklist, 29 | lambda item: item.title == old) 30 | else: 31 | return 'new' 32 | 33 | raise Exception("Unconfigured select for {0} => {1}".format( 34 | unmapped_task.title, 35 | ','.join(x.title for x in tasklist))) 36 | 37 | def merge_title(self, mapping): 38 | if self.__renames: 39 | for _, new, sel in self.__renames: 40 | if mapping.remote.title == new: 41 | return sel 42 | 43 | raise Exception("Undefined behavior") 44 | 45 | def merge_completed(self, mapping): 46 | return mapping.org.completed or mapping.remote.completed 47 | 48 | def merge_closed_time(self, mapping): 49 | return self.__select_from([mapping.org.closed_time, mapping.remote.closed_time]) 50 | 51 | def merge_schedule_time(self, mapping): 52 | return self.__select_from([mapping.org.schedule_time, mapping.remote.schedule_time]) 53 | 54 | def __select_from(self, items): 55 | items = [x for x in items if x is not None] 56 | if len(items) == 1: 57 | return items[0] 58 | 59 | raise Exception("Unconfigured choose for {0}".format(', '.join(str(x) for x in items))) 60 | 61 | def merge_notes(self, mapping): 62 | items = [x for x in [mapping.org.notes, mapping.remote.notes] if len(x) > 0] 63 | if len(items) == 1: 64 | return items[0] 65 | 66 | raise Exception("Undefined behavior") 67 | 68 | def merge_links(self, mapping): 69 | return michel.BaseMergeConf.merge_links(mapping) 70 | 71 | 72 | def createTestTree(nodes): 73 | result = michel.TasksTree(None) 74 | 75 | indexes = [] 76 | leefs = [result] 77 | for node in nodes: 78 | if isinstance(node, str): 79 | pad = len(node) - len(node.lstrip()) 80 | leefs = leefs[:pad+1] 81 | newTask = leefs[-1].add_subtask(node.strip()) 82 | leefs.append(newTask) 83 | indexes.append(newTask) 84 | else: 85 | leefs[-1].update(**node) 86 | 87 | return result, indexes 88 | 89 | def getLocaleAlias(lang_code): 90 | result = None 91 | if os.name == 'nt': 92 | if lang_code == 'ru': 93 | result = 'Russian_Russia.1251' 94 | elif lang_code == 'us': 95 | result = 'English_United States.1252' 96 | elif lang_code == 'de': 97 | result = 'German_Germany.1252' 98 | else: 99 | if lang_code == 'ru': 100 | result = 'ru_RU.utf-8' 101 | elif lang_code == 'us': 102 | result = 'en_US.utf-8' 103 | elif lang_code == 'de': 104 | result = 'de_DE.utf-8' 105 | 106 | if result is None: 107 | return None 108 | 109 | try: 110 | old_locale = locale.setlocale(locale.LC_TIME) 111 | locale.setlocale(locale.LC_TIME, result) 112 | except: 113 | return None 114 | finally: 115 | locale.setlocale(locale.LC_TIME, old_locale) 116 | 117 | return result 118 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | import unittest 12 | import textwrap 13 | import datetime 14 | import locale 15 | 16 | import michel as m 17 | 18 | class ParserTests(unittest.TestCase): 19 | 20 | def test_text_to_tasktree(self): 21 | org_text = textwrap.dedent("""\ 22 | * Headline 1 23 | Body 1a 24 | Body 1b 25 | * DONE Headline 2 26 | ** Headline 2.1 27 | """) 28 | result_text = textwrap.dedent("""\ 29 | * Headline 1 30 | Body 1a 31 | Body 1b 32 | * DONE Headline 2 33 | ** Headline 2.1 34 | """) 35 | tasktree = m.TasksTree.parse_text(org_text) 36 | self.assertEqual(str(tasktree), result_text) 37 | 38 | 39 | def test_initial_non_headline_text(self): 40 | """ 41 | Test the case with org-mode properties 42 | """ 43 | 44 | org_text = textwrap.dedent("""\ 45 | Some non-headline text... 46 | Another line of it. 47 | * Headline 1 48 | Body 1a 49 | Body 1b 50 | * DONE Headline 2 51 | ** Headline 2.1 52 | """) 53 | 54 | tasktree = m.TasksTree.parse_text(org_text) 55 | self.assertEqual(str(tasktree), org_text) 56 | 57 | 58 | def test_no_headlines(self): 59 | """ 60 | Test the cases where there are no headlines at all in the file. 61 | """ 62 | 63 | # text should have trailing "\n" character, like most textfiles 64 | org_text = textwrap.dedent("""\ 65 | Some non-headline text... 66 | Another line of it. 67 | """) 68 | 69 | tasktree = m.TasksTree.parse_text(org_text) 70 | self.assertEqual(str(tasktree), org_text) 71 | 72 | 73 | def test_empty_file(self): 74 | org_text = "" # empty file 75 | m.TasksTree.parse_text(org_text) 76 | 77 | 78 | def test_parsing_special_comments(self): 79 | m.default_locale = locale.locale_alias['ru'] 80 | 81 | org_text = textwrap.dedent("""\ 82 | * TODO Headline A 83 | CLOSED: [2015-12-09 Wed 12:34] SCHEDULED: <2015-12-09 Wed> 84 | * TODO Headline B 85 | SCHEDULED: <2015-12-09 Wed 20:00-21:00> 86 | https://anticode.ninja 87 | [[https://anticode.ninja][#anticode.ninja# blog]] 88 | [[https://github.com/anticodeninja/michel2][michel2 #repo #github]] 89 | Normal note 90 | """) 91 | 92 | org_tree = m.TasksTree.parse_text(org_text) 93 | 94 | self.assertEqual(len(org_tree), 2) 95 | 96 | self.assertEqual(org_tree[0].title, "Headline A") 97 | self.assertEqual(len(org_tree[0].links), 0) 98 | self.assertEqual(org_tree[0].schedule_time, 99 | m.OrgDate(datetime.date(2015, 12, 9))) 100 | self.assertEqual(org_tree[0].closed_time, 101 | m.OrgDate(datetime.date(2015, 12, 9), 102 | datetime.time(12, 34))) 103 | self.assertEqual(org_tree[0].notes, []) 104 | 105 | self.assertEqual(org_tree[1].title, "Headline B") 106 | self.assertEqual(len(org_tree[1].links), 3) 107 | self.assertEqual(org_tree[1].links[0], m.TaskLink('https://anticode.ninja')) 108 | self.assertEqual(org_tree[1].links[1], m.TaskLink('https://anticode.ninja', '#anticode.ninja# blog')) 109 | self.assertEqual(org_tree[1].links[2], m.TaskLink('https://github.com/anticodeninja/michel2', 'michel2', ['repo', 'github'])) 110 | self.assertEqual(org_tree[1].schedule_time, 111 | m.OrgDate(datetime.date(2015, 12, 9), 112 | datetime.time(20, 0), 113 | datetime.timedelta(hours=1))) 114 | self.assertEqual(org_tree[1].notes, ["Normal note"]) 115 | 116 | self.assertEqual(str(org_tree), org_text) 117 | 118 | 119 | if __name__ == '__main__': 120 | unittest.main() 121 | -------------------------------------------------------------------------------- /tests/test_globalization.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | import unittest 12 | import textwrap 13 | import os 14 | import tempfile 15 | import datetime 16 | import locale 17 | import sys 18 | 19 | import michel as m 20 | from michel.utils import * 21 | from tests import getLocaleAlias 22 | 23 | class GlobalizationTests(unittest.TestCase): 24 | 25 | def test_format_emacs_dates(self): 26 | test_locale = getLocaleAlias('ru') 27 | if test_locale: 28 | m.OrgDate.default_locale = test_locale 29 | self.assertEqual( 30 | "2015-12-09 Ср", 31 | m.OrgDate(datetime.date(2015, 12, 9)).to_org_format()) 32 | 33 | test_locale = getLocaleAlias('us') 34 | if test_locale: 35 | m.OrgDate.default_locale = test_locale 36 | self.assertEqual( 37 | "2015-11-18 Wed", 38 | m.OrgDate(datetime.date(2015, 11, 18)).to_org_format()) 39 | 40 | test_locale = getLocaleAlias('de') 41 | if test_locale: 42 | m.OrgDate.default_locale = test_locale 43 | self.assertEqual( 44 | "2015-12-10 Do", 45 | m.OrgDate(datetime.date(2015, 12, 10)).to_org_format()) 46 | 47 | def test_unicode_print(self): 48 | """ 49 | Test ability to print unicode text 50 | """ 51 | 52 | tasks_tree = m.TasksTree(None) 53 | task = tasks_tree.add_subtask('السلام عليكم') 54 | task.notes = ['viele Grüße'] 55 | 56 | test_stdout = open(os.devnull, 'w') 57 | try: 58 | uprint(tasks_tree, file=test_stdout) 59 | except UnicodeDecodeError: 60 | self.fail("TasksTree._print() raised UnicodeDecodeError") 61 | test_stdout.close() 62 | 63 | 64 | def test_unicode_dump_to_file(self): 65 | """ 66 | Test ability to pull unicode text into orgfile 67 | """ 68 | 69 | tasks_tree = m.TasksTree(None) 70 | task = tasks_tree.add_subtask('السلام عليكم') 71 | task.notes = ['viele Grüße'] 72 | 73 | with tempfile.NamedTemporaryFile() as temp_file: 74 | temp_file_name = temp_file.name 75 | 76 | try: 77 | tasks_tree.write_file(temp_file_name) 78 | except UnicodeDecodeError: 79 | self.fail("TasksTree.write_to_orgfile() raised UnicodeDecodeError") 80 | 81 | 82 | def test_parse_scheduled_and_closed_time(self): 83 | m.OrgDate.default_locale = getLocaleAlias('us') 84 | 85 | org_text = textwrap.dedent("""\ 86 | * Headline 1 87 | Normal notes 88 | * Headline 2 89 | SCHEDULED: <2015-11-18 Wed> 90 | * Headline 3 91 | SCHEDULED: <2015-12-09 Wed 19:00-20:00> 92 | * DONE Headline 4 93 | CLOSED: [2015-12-10 Thu 03:25] 94 | * DONE Headline 5 95 | CLOSED: [2015-12-10 Thu 03:25] SCHEDULED: <2015-12-09 Wed 03:00> 96 | """) 97 | tasktree = m.TasksTree.parse_text(org_text) 98 | 99 | self.assertEqual(tasktree[0].closed_time, None) 100 | self.assertEqual(tasktree[0].schedule_time, None) 101 | 102 | self.assertEqual(tasktree[1].closed_time, None) 103 | self.assertEqual(tasktree[1].schedule_time, 104 | m.OrgDate(datetime.date(2015, 11, 18))) 105 | 106 | self.assertEqual(tasktree[2].closed_time, None) 107 | self.assertEqual(tasktree[2].schedule_time, 108 | m.OrgDate(datetime.date(2015, 12, 9), 109 | datetime.time(19, 0), 110 | datetime.timedelta(hours=1))) 111 | 112 | self.assertEqual(tasktree[3].closed_time, 113 | m.OrgDate(datetime.date(2015, 12, 10), 114 | datetime.time(3, 25))) 115 | self.assertEqual(tasktree[3].schedule_time, None) 116 | 117 | self.assertEqual(tasktree[4].closed_time, 118 | m.OrgDate(datetime.date(2015, 12, 10), 119 | datetime.time(3, 25))) 120 | self.assertEqual(tasktree[4].schedule_time, 121 | m.OrgDate(datetime.date(2015, 12, 9), 122 | datetime.time(3, 0))) 123 | 124 | self.assertEqual(str(tasktree), org_text) 125 | 126 | 127 | if __name__ == '__main__': 128 | unittest.main() 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Description 2 | =========== 3 | 4 | **Michel2** is fork 5 | of [michel-orgmode](https://bitbucket.org/edgimar/michel-orgmode) which serves 6 | as a bridge between an [org-mode](http://orgmode.org/) textfile and cloud based 7 | task trackers such as Google Task. It can push/pull/bidirectionally sync and 8 | merge org-mode text files to/from/with a cloud based task tracker. 9 | 10 | p.s. Really, only Google Tasks are supported now. 11 | 12 | 13 | Usage 14 | ===== 15 | 16 | Supported Integrations 17 | ---------------------- 18 | 19 | Your Google task list will be represented as a particular URL that follows 20 | the format: 21 | 22 | gtask:/// 23 | 24 | If you have lists that contain spaces or special characters, just add single 25 | quotes around the URL to avoid having the command line interpret any of these. 26 | 27 | 28 | Examples 29 | -------- 30 | 31 | - Pull your default task-list to an org-mode file: 32 | 33 | michel pull myfile.org gtask://profile/default 34 | 35 | - Push an org-mode file to your default task-list: 36 | 37 | michel push myfile.org gtask://profile/default 38 | 39 | - Synchronize an org-mode file with your default task-list: 40 | 41 | michel sync myfile.org gtask://profile/default 42 | 43 | - Synchronize an org-mode file with your task-list named "Shopping": 44 | 45 | michel sync shopping.org gtask://profile/Shopping 46 | 47 | 48 | Installation 49 | ------------ 50 | 51 | The `michel` script runs under Linux, Windows and should work under MacOS, 52 | although it hasn't been tested yet. To install script, you need to clone 53 | repository and install via `pip`: 54 | 55 | git clone git@github.com:anticodeninja/michel2.git 56 | cd michel2 57 | pip install -e . 58 | 59 | 60 | Configuration 61 | ------------- 62 | 63 | The first time **Michel2** is run under a particular profile, you will be shown a 64 | URL. Click it, and authorize **Michel2** to access google-tasks data for whichever 65 | google-account you want to associate with the profile. You're done! If no 66 | profile is specified when running michel, a default profile will be used. 67 | 68 | The authorization token is stored in 69 | `$XDG_DATA_HOME/.michel/_oauth.dat`. No other information is 70 | stored, since the authorization token is the only information needed for michel 71 | to authenticate with google and access your tasks data. 72 | 73 | 74 | Command Line Options 75 | -------------------- 76 | 77 | usage: michel [-h] (push|pull|sync|print|repair|run) ... 78 | 79 | optional arguments: 80 | -h, --help show this help message and exit 81 | 82 | Commands: 83 | push FILE URL replace task list in URL with the contents of FILE. 84 | pull FILE URL replace FILE with the contents of tasks at URL. 85 | sync FILE URL synchronize changes between FILE and tasks at URL. 86 | print URL displays the tasks in URL as org format to the console. 87 | repair FILE combines the FILE with conficted copies. 88 | run SCRIPTFILE runs the commands stored in FILE as JSON. 89 | 90 | 91 | Script File Syntax 92 | ------------------ 93 | 94 | If you often work with the same files you can prefer using a script file like 95 | this (really it is JSON): 96 | 97 | [ 98 | { "action": "repair", "org_file": "~/Dropbox/org/work.org" }, 99 | { "action": "repair", "org_file": "~/Dropbox/org/personal.org" }, 100 | { "action": "sync", "org_file": "~/Dropbox/org/work.org", "url": "gtask://__default/work", "only_todo": true }, 101 | { "action": "sync", "org_file": "~/Dropbox/org/personal.org", "url": "gtask://__default/personal", "only_todo": true } 102 | ] 103 | 104 | And run it with `michel run script.json`. If you constantly use the same files 105 | you can save this file as `$XDG_DATA_HOME/.michel/profile` and run it very 106 | quickly with the shortest variant `michel`. 107 | 108 | 109 | Emacs Integrations 110 | ------------------ 111 | 112 | As a continuation of a previous step, you can integrate **Michel2** in Emacs by the 113 | following elisp snippet (do not forget to correct the encoding if you try to use it 114 | under Windows): 115 | 116 | (defun michel() 117 | (interactive) 118 | (let ((michel-buf (generate-new-buffer "Emacs Michel"))) 119 | (switch-to-buffer michel-buf) 120 | (insert "=== Emacs Michel ===\n") 121 | (let* ((michel-proc (start-process "michel" michel-buf "michel"))) 122 | (if (string-equal system-type "windows-nt") ;; HACK, use latin-1 or your 123 | (set-buffer-process-coding-system 'cp1251 'cp1251)) ;; encoding here 124 | (comint-mode) 125 | (set-process-sentinel michel-proc 126 | `(lambda (p e) (if (eq (current-buffer) ,michel-buf) 127 | (progn 128 | (insert "====================\n") 129 | (sit-for 3) 130 | (kill-buffer)))))))) 131 | 132 | It is not the best way to integrate **Michel2** to Emacs, but it works the same 133 | way under Linux and Windows. 134 | 135 | 136 | Org-mode Syntax 137 | --------------- 138 | 139 | Currently, this script supports only a subset of the org-mode format. The 140 | following elements are mapped between a google-tasks list and an org-mode file: 141 | 142 | * Task Indentation <--> Number of asterisks preceding a headline 143 | * Task Notes <--> Headline's body text 144 | * Checked-off / crossed-out <--> Headline is marked as DONE 145 | 146 | 147 | About 148 | ===== 149 | 150 | 151 | Author/License 152 | -------------- 153 | 154 | - License: MPL2 155 | - Original author: Christophe-Marie Duquesne ([blog post](http://blog.chmd.fr/releasing-michel-a-flat-text-file-to-google-tasks-uploader.html)) 156 | - Author of org-mode version: Mark Edgington ([bitbucket site](https://bitbucket.org/edgimar/michel-orgmode)) 157 | - Author of Michel2 version: @anticodeninja ([github site](https://github.com/anticodeninja/michel2)) 158 | 159 | 160 | Contributing 161 | ------------ 162 | 163 | Patches/issues/other feedback are welcome. 164 | -------------------------------------------------------------------------------- /michel/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | michel-orgmode -- a script to push/pull an org-mode text file to/from a google 6 | tasks list. 7 | """ 8 | 9 | # This Source Code Form is subject to the terms of the 10 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 11 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 12 | 13 | import codecs 14 | import os.path 15 | import shutil 16 | import sys 17 | import json 18 | import locale 19 | import argparse 20 | 21 | from michel.utils import * 22 | from michel.mergetask import * 23 | from michel.mergeconf import * 24 | from michel.tasktree import * 25 | 26 | def print_todolist(url): 27 | """Print an orgmode-formatted string representing a google tasks list. 28 | 29 | The Google Tasks list named *list_name* is used. If *list_name* is not 30 | specified, then the default Google-Tasks list will be used. 31 | 32 | """ 33 | 34 | provider = get_provider(url) 35 | provider.pull() 36 | print(provider.get_tasks()) 37 | 38 | def write_todolist(path, url): 39 | """Create an orgmode-formatted file representing a google tasks list. 40 | 41 | The Google Tasks list named *list_name* is used. If *list_name* is not 42 | specified, then the default Google-Tasks list will be used. 43 | 44 | """ 45 | 46 | provider = get_provider(url) 47 | provider.pull() 48 | provider.get_tasks().write_file(path) 49 | 50 | def push_todolist(org_path, url, only_todo): 51 | """Pushes the specified file to the specified todolist""" 52 | 53 | org_path = os.path.expanduser(org_path) 54 | if not os.path.exists(org_path): 55 | raise Exception("The org-file you want to synchronize does not exist.") 56 | org_tree = TasksTree.parse_file(org_path) 57 | 58 | provider = get_provider(url) 59 | provider.pull() 60 | remote_tree = provider.get_tasks() 61 | 62 | sync_plan = treemerge(org_tree, remote_tree, None, PushMergeConf(provider, only_todo)) 63 | provider.sync(sync_plan) 64 | 65 | def sync_todolist(org_path, url, only_todo): 66 | """Synchronizes the specified file with the specified todolist""" 67 | 68 | org_path = os.path.expanduser(org_path) 69 | if not os.path.exists(org_path): 70 | raise Exception("The org-file you want to synchronize does not exist.") 71 | org_tree = TasksTree.parse_file(org_path) 72 | 73 | provider = get_provider(url) 74 | provider.pull() 75 | remote_tree = provider.get_tasks() 76 | 77 | base_path = os.path.splitext(org_path)[0] + ".base" 78 | base_tree = TasksTree.parse_file(base_path) if os.path.exists(base_path) else None 79 | 80 | sync_plan = treemerge(org_tree, remote_tree, base_tree, InteractiveMergeConf(provider, only_todo)) 81 | provider.sync(sync_plan) 82 | codecs.open(org_path, "w", "utf-8").write(str(org_tree)) 83 | codecs.open(base_path, "w", "utf-8").write(str(org_tree)) 84 | 85 | def repair_todolist(org_path): 86 | """Combine the file with conflicted copies""" 87 | 88 | org_path = os.path.abspath(os.path.expanduser(org_path)) 89 | if not os.path.exists(org_path): 90 | raise Exception("The org-file you want to synchronize does not exist.") 91 | 92 | file_dir = os.path.dirname(org_path) 93 | file_fullname = os.path.basename(org_path) 94 | file_name, file_ext = os.path.splitext(file_fullname) 95 | 96 | conflicts = [x for x in os.listdir(file_dir) 97 | if x.startswith(file_name) and x.endswith(file_ext) and x != file_fullname] 98 | 99 | if len(conflicts) == 0: 100 | return 101 | 102 | org_tree = TasksTree.parse_file(org_path) 103 | 104 | for conflict in conflicts: 105 | print("Fixing conflicts with {0}".format(conflict)) 106 | conflict_path = os.path.join(file_dir, conflict) 107 | conflict_tree = TasksTree.parse_file(conflict_path) 108 | treemerge(org_tree, conflict_tree, None, InteractiveMergeConf({}, False)) 109 | os.remove(conflict_path) 110 | 111 | codecs.open(org_path, "w", "utf-8").write(str(org_tree)) 112 | 113 | def main(): 114 | parser = argparse.ArgumentParser(description="Synchronize org-mode files with cloud.") 115 | 116 | subparsers = parser.add_subparsers(dest='command', title='commands') 117 | 118 | push_command = subparsers.add_parser('push', help='push the file to cloud.') 119 | push_command.add_argument('orgfile') 120 | push_command.add_argument('url') 121 | push_command.add_argument("--only_todo", action='store_true', 122 | help='push only TODO tasks to cloud.') 123 | 124 | print_command = subparsers.add_parser('print', help='print list from cloud.') 125 | print_command.add_argument('url') 126 | 127 | pull_command = subparsers.add_parser('pull', help='pull the file from cloud.') 128 | pull_command.add_argument('orgfile') 129 | pull_command.add_argument('url') 130 | 131 | sync_command = subparsers.add_parser('sync', help='sync the file with cloud.') 132 | sync_command.add_argument('orgfile') 133 | sync_command.add_argument('url') 134 | sync_command.add_argument("--only_todo", action='store_true', 135 | help='synchronize only TODO tasks with cloud.') 136 | 137 | repair_command = subparsers.add_parser('repair', help='combine the file with conflicted copies.') 138 | repair_command.add_argument('orgfile') 139 | 140 | run_command = subparsers.add_parser('run', help='run actions from script.') 141 | run_command.add_argument('script', nargs='?') 142 | 143 | args = parser.parse_args() 144 | 145 | if args.command == 'print': 146 | print_todolist(args.url) 147 | elif args.command == 'pull': 148 | write_todolist(args.orgfile, args.url) 149 | elif args.command == 'push': 150 | push_todolist(args.orgfile, args.url, args.only_todo) 151 | elif args.command == 'sync': 152 | sync_todolist(args.orgfile, args.url, args.only_todo) 153 | elif args.command == 'repair': 154 | repair_todolist(args.orgfile) 155 | elif args.command == 'run' or not args.command: 156 | script_file = getattr(args, 'script', None) or save_data_path("profile") 157 | if not os.path.exists(script_file): 158 | print("The script file does not exist.") 159 | sys.exit(2) 160 | 161 | print("Use actions from script {0}".format(script_file)) 162 | 163 | with codecs.open(script_file, 'r', 'utf-8') as actions_file: 164 | actions = json.load(actions_file) 165 | 166 | for entry in actions: 167 | if entry['action'] == 'sync': 168 | print ("Sync {0} <-> {1}".format(entry['org_file'], entry['url'])) 169 | sync_todolist(entry['org_file'], entry['url'], entry['only_todo']) 170 | elif entry['action'] == 'push': 171 | print ("Push {0} -> {1}".format(entry['org_file'], entry['url'])) 172 | push_todolist(entry['org_file'], entry['url'], entry['only_todo']) 173 | elif entry['action'] == 'pull': 174 | print ("Pull {0} <- {1}".format(entry['org_file'], entry['url'])) 175 | write_todolist(entry['org_file'], entry['url']) 176 | elif entry['action'] == 'repair': 177 | print ("Repair {0}".format(entry['org_file'])) 178 | repair_todolist(entry['org_file']) 179 | 180 | if __name__ == "__main__": 181 | main() 182 | -------------------------------------------------------------------------------- /tests/test_merge_repeated.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | import unittest 12 | import textwrap 13 | import os 14 | import sys 15 | import tempfile 16 | import datetime 17 | import locale 18 | 19 | import michel as m 20 | import tests 21 | 22 | class MergeRepeatedTests(unittest.TestCase): 23 | 24 | def __init__(self, *args, **kargs): 25 | super().__init__(*args, **kargs) 26 | 27 | self.now = m.OrgDate.now() 28 | m.OrgDate.default_locale = tests.getLocaleAlias('us') 29 | 30 | 31 | def __gen_org(self, *days): 32 | temp = [] 33 | if 1 in days: temp += ["* TODO RepeatedTask"," SCHEDULED: <2015-12-01 Tue>"] 34 | if 3 in days: temp += ["* TODO RepeatedTask"," SCHEDULED: <2015-12-03 Thu>"] 35 | if 5 in days: temp += ["* TODO RepeatedTask"," SCHEDULED: <2015-12-05 Sat>"] 36 | return "\n".join(temp + [""]) 37 | 38 | 39 | def __gen_remote(self, *days): 40 | temp = [] 41 | if 1 in days: temp += ["RepeatedTask", 42 | dict(todo=True, schedule_time=m.OrgDate(datetime.date(2015, 12, 1)))] 43 | if 3 in days: temp += ["RepeatedTask", 44 | dict(todo=True, schedule_time=m.OrgDate(datetime.date(2015, 12, 3)))] 45 | if 5 in days: temp += ["RepeatedTask", 46 | dict(todo=True, schedule_time=m.OrgDate(datetime.date(2015, 12, 5)))] 47 | return temp 48 | 49 | 50 | def test_repeated_scheduled_task_new_remote_merge(self): 51 | 52 | # Arrange 53 | base_tree = m.TasksTree.parse_text(self.__gen_org(3)) 54 | org_tree = m.TasksTree.parse_text(self.__gen_org(3)) 55 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(3,5)) 56 | 57 | # Act 58 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 59 | [None, "RepeatedTask", None] 60 | ])) 61 | 62 | # Assert 63 | self.assertEqual(str(org_tree), self.__gen_org(3,5)) 64 | self.assertEqual(len(remote_sync_plan), 0) 65 | 66 | 67 | def test_repeated_scheduled_task_new_remote_addin_merge(self): 68 | 69 | # Arrange 70 | base_tree = m.TasksTree.parse_text(self.__gen_org(1,3)) 71 | org_tree = m.TasksTree.parse_text(self.__gen_org(1,3)) 72 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(1,3,5)) 73 | 74 | # Act 75 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 76 | [None, "RepeatedTask", None] 77 | ])) 78 | 79 | # Assert 80 | self.assertEqual(str(org_tree), self.__gen_org(1,3,5)) 81 | self.assertEqual(len(remote_sync_plan), 0) 82 | 83 | 84 | def test_repeated_scheduled_task_new_org_merge(self): 85 | 86 | # Arrange 87 | base_tree = m.TasksTree.parse_text(self.__gen_org(3)) 88 | org_tree = m.TasksTree.parse_text(self.__gen_org(3,5)) 89 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(3)) 90 | 91 | # Act 92 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 93 | [None, "RepeatedTask", None] 94 | ])) 95 | 96 | # Assert 97 | self.assertEqual(str(org_tree), self.__gen_org(3,5)) 98 | self.assertEqual(len(remote_sync_plan), 1) 99 | 100 | # Add RepeatedTask <2015-12-5 Fri> 101 | assertObj = next(x for x in remote_sync_plan if x['item'] not in indexes) 102 | self.assertEqual(assertObj['action'], 'append') 103 | 104 | 105 | def test_repeated_scheduled_task_new_org_addin_merge(self): 106 | 107 | # Arrange 108 | base_tree = m.TasksTree.parse_text(self.__gen_org(1,3)) 109 | org_tree = m.TasksTree.parse_text(self.__gen_org(1,3,5)) 110 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(1,3)) 111 | 112 | # Act 113 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 114 | [None, "RepeatedTask", None] 115 | ])) 116 | 117 | # Assert 118 | self.assertEqual(str(org_tree), self.__gen_org(1,3,5)) 119 | self.assertEqual(len(remote_sync_plan), 1) 120 | 121 | # Add RepeatedTask <2015-12-5 Fri> 122 | assertObj = next(x for x in remote_sync_plan if x['item'] not in indexes) 123 | self.assertEqual(assertObj['action'], 'append') 124 | 125 | 126 | def test_repeated_scheduled_task_reschedule_org_merge(self): 127 | 128 | # Arrange 129 | base_tree = m.TasksTree.parse_text(self.__gen_org(1,3)) 130 | org_tree = m.TasksTree.parse_text(self.__gen_org(1,5)) 131 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(1,3)) 132 | 133 | # Act 134 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 135 | [None, "RepeatedTask", None] 136 | ])) 137 | 138 | # Assert 139 | self.assertEqual(str(org_tree), self.__gen_org(1,5)) 140 | self.assertEqual(len(remote_sync_plan), 1) 141 | 142 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[1]) 143 | self.assertEqual(assertObj['action'], 'update') 144 | self.assertEqual(assertObj['changes'], ['schedule_time']) 145 | 146 | 147 | def test_repeated_scheduled_task_reschedule_remote_merge(self): 148 | 149 | # Arrange 150 | base_tree = m.TasksTree.parse_text(self.__gen_org(1,3)) 151 | org_tree = m.TasksTree.parse_text(self.__gen_org(1,3)) 152 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(1,5)) 153 | 154 | # Act 155 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 156 | [None, "RepeatedTask", None] 157 | ])) 158 | 159 | # Assert 160 | self.assertEqual(str(org_tree), self.__gen_org(1,5)) 161 | self.assertEqual(len(remote_sync_plan), 0) 162 | 163 | 164 | def test_repeated_scheduled_task_reschedule_new_merge(self): 165 | 166 | # Arrange 167 | base_tree = m.TasksTree.parse_text(self.__gen_org(1)) 168 | 169 | org_tree = m.TasksTree.parse_text("""\ 170 | * DONE RepeatedTask 171 | CLOSED: [2015-12-01 Tue] SCHEDULED: <2015-12-01 Tue> 172 | * TODO RepeatedTask 173 | SCHEDULED: <2015-12-03 Sat> 174 | """) 175 | 176 | remote_tree, indexes = tests.createTestTree(self.__gen_remote(1)) 177 | 178 | # Act 179 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 180 | [None, "RepeatedTask", None] 181 | ])) 182 | 183 | # Assert 184 | result_text = textwrap.dedent("""\ 185 | * DONE RepeatedTask 186 | CLOSED: [2015-12-01 Tue] SCHEDULED: <2015-12-01 Tue> 187 | * TODO RepeatedTask 188 | SCHEDULED: <2015-12-03 Thu> 189 | """) 190 | self.assertEqual(str(org_tree), result_text) 191 | self.assertEqual(len(remote_sync_plan), 2) 192 | 193 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[0]) 194 | self.assertEqual(assertObj['action'], 'remove') 195 | 196 | assertObj = next(x for x in remote_sync_plan if x['item'] not in indexes) 197 | self.assertEqual(assertObj['action'], 'append') 198 | 199 | def test_repeated_scheduled_one_day_issue(self): 200 | 201 | # Arrange 202 | base_tree = m.TasksTree.parse_text("""\ 203 | * TODO RepeatedTask 204 | SCHEDULED: <2018-09-17 Mon> 205 | * TODO RepeatedTask 206 | SCHEDULED: <2018-10-08 Mon> 207 | """) 208 | 209 | org_tree = m.TasksTree.parse_text("""\ 210 | * TODO RepeatedTask 211 | SCHEDULED: <2018-10-08 Mon> 212 | * TODO RepeatedTask 213 | SCHEDULED: <2018-10-08 Mon> 214 | """) 215 | 216 | remote_tree, indexes = tests.createTestTree([ 217 | "RepeatedTask", 218 | dict(todo=True, schedule_time=m.OrgDate(datetime.date(2018, 9, 17))), 219 | "RepeatedTask", 220 | dict(todo=True, schedule_time=m.OrgDate(datetime.date(2018, 10, 8))), 221 | ]) 222 | 223 | # Act 224 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf()) 225 | 226 | # Assert 227 | result_text = textwrap.dedent("""\ 228 | * TODO RepeatedTask 229 | SCHEDULED: <2018-10-08 Mon> 230 | * TODO RepeatedTask 231 | SCHEDULED: <2018-10-08 Mon> 232 | """) 233 | self.assertEqual(str(org_tree), result_text) 234 | self.assertEqual(len(remote_sync_plan), 1) 235 | 236 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[0]) 237 | self.assertEqual(assertObj['action'], 'update') 238 | 239 | 240 | 241 | if __name__ == '__main__': 242 | unittest.main() 243 | 244 | -------------------------------------------------------------------------------- /michel/mergeconf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This Source Code Form is subject to the terms of the 5 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 6 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import codecs 9 | import collections 10 | import re 11 | import io 12 | import datetime 13 | import subprocess 14 | import tempfile 15 | import difflib 16 | 17 | from michel.utils import * 18 | import michel.console as console 19 | 20 | class BaseMergeConf: 21 | 22 | def __init__(self, adapter, only_todo = True): 23 | self._adapter = adapter 24 | self._only_todo = only_todo 25 | 26 | 27 | def is_needed(self, task): 28 | if hasattr(self._adapter, 'is_needed'): 29 | return self._adapter.is_needed(self._is_needed, task) 30 | return self._is_needed(task) 31 | 32 | 33 | def select_org_task(self, unmapped_task, tasklist): 34 | if hasattr(self._adapter, 'select_org_task'): 35 | return self._adapter.select_org_task(self._select_org_task, unmapped_task, tasklist) 36 | return self._select_org_task(unmapped_task, tasklist) 37 | 38 | 39 | def merge_title(self, mapping): 40 | if hasattr(self._adapter, 'merge_title'): 41 | return self._adapter.merge_title(self._merge_title, mapping) 42 | return self._merge_title(mapping) 43 | 44 | 45 | def merge_completed(self, mapping): 46 | if hasattr(self._adapter, 'merge_completed'): 47 | return self._adapter.merge_completed(self._merge_completed, mapping) 48 | return self._merge_completed(mapping) 49 | 50 | 51 | def merge_closed_time(self, mapping): 52 | if hasattr(self._adapter, 'merge_closed_time'): 53 | return self._adapter.merge_closed_time(self._merge_closed_time, mapping) 54 | return self._merge_closed_time(mapping) 55 | 56 | 57 | def merge_schedule_time(self, mapping): 58 | if hasattr(self._adapter, 'merge_schedule_time'): 59 | return self._adapter.merge_schedule_time(self._merge_schedule_time, mapping) 60 | return self._merge_schedule_time(mapping) 61 | 62 | 63 | def merge_notes(self, mapping): 64 | if hasattr(self._adapter, 'merge_notes'): 65 | return self._adapter.merge_notes(self._merge_notes, mapping) 66 | return self._merge_notes(mapping) 67 | 68 | 69 | def merge_links(self, mapping): 70 | if hasattr(self._adapter, 'merge_links'): 71 | return self._adapter.merge_links(self._merge_notes, mapping) 72 | return self._merge_links(mapping) 73 | 74 | 75 | def _is_needed(self, task): 76 | if task.completed: 77 | return False 78 | 79 | if self._only_todo: 80 | return task.todo 81 | 82 | return True 83 | 84 | 85 | def _merge_closed_time(self, mapping): 86 | if mapping.org.completed: 87 | if mapping.remote.closed_time and mapping.org.closed_time: 88 | return min(mapping.org.closed_time, mapping.remote.closed_time) 89 | elif mapping.org.closed_time or mapping.remote.closed_time: 90 | return mapping.org.closed_time or mapping.remote.closed_time 91 | else: 92 | return m.OrgDate.now() 93 | else: 94 | return None 95 | 96 | 97 | @classmethod 98 | def merge_links(cls, mapping): 99 | # TODO Make it interactive 100 | total = collections.OrderedDict() 101 | 102 | def update(links): 103 | for link in links: 104 | temp = total.setdefault(link.link, m.TaskLink(link.link)) 105 | if link.title: 106 | temp.title = link.title 107 | if len(link.tags) > 0: 108 | temp.tags = link.tags 109 | 110 | update(mapping.org.links) 111 | update(mapping.remote.links) 112 | 113 | return [x for x in total.values()] 114 | 115 | 116 | 117 | class InteractiveMergeConf(BaseMergeConf): 118 | 119 | def _select_org_task(self, unmapped_task, tasklist): 120 | uprint("\"{0}\" has no exact mapping in your local org-tree.".format(unmapped_task.title)) 121 | uprint("Please manually choose the wanted item:") 122 | count = 2 123 | 124 | items = [[i, v, difflib.SequenceMatcher(a=unmapped_task.title, b=v.title).ratio()] 125 | for i, v in enumerate(tasklist)] 126 | items.sort(key=lambda v: v[2], reverse=True) 127 | items_count = len(items) 128 | items_count_for_showing = 10 129 | 130 | while True: 131 | for i in range(min(items_count, items_count_for_showing)): 132 | uprint("[{0}] {1}".format(i, items[i][1].title)) 133 | count += 1 134 | 135 | if items_count > items_count_for_showing: 136 | uprint("[m] ...") 137 | count += 1 138 | 139 | uprint("[n] -- create new") 140 | uprint("[d] -- discard new") 141 | count += 2 142 | 143 | result = input() 144 | count += 1 145 | 146 | try: 147 | if result == 'm': 148 | items_count_for_showing = items_count 149 | continue 150 | elif result == 'n': 151 | result = 'new' 152 | break 153 | elif result == 'd': 154 | result = 'discard' 155 | break 156 | 157 | result = int(result) 158 | if result >= 0 and result <= items_count: 159 | result = items[result][0] 160 | break 161 | except: 162 | pass 163 | 164 | uprint("Incorrect input!") 165 | count += 1 166 | 167 | console.cleanLastRows(count) 168 | return result 169 | 170 | 171 | def _merge_title(self, mapping): 172 | return self.__select_from([ 173 | "Tasks has different titles", 174 | "Please manualy choose necessary value:" 175 | ], [ 176 | mapping.org.title, 177 | mapping.remote.title 178 | ]) 179 | 180 | 181 | def _merge_completed(self, mapping): 182 | return self.__select_from([ 183 | "Task \"{0}\" has different values for attribute \"completed\"".format(mapping.org.title), 184 | "Please manualy choose necessary value:" 185 | ], [ 186 | mapping.org.completed, 187 | mapping.remote.completed 188 | ]) 189 | 190 | 191 | def _merge_schedule_time(self, mapping): 192 | return self.__select_from([ 193 | "Task \"{0}\" has different values for attribute \"schedule_time\"".format(mapping.org.title), 194 | "Please manualy choose necessary value:" 195 | ], [ 196 | mapping.org.schedule_time, 197 | mapping.remote.schedule_time 198 | ]) 199 | 200 | 201 | def _merge_notes(self, mapping): 202 | uprint("Task \"{0}\" has different values for attribute \"notes\"".format(mapping.org.title)) 203 | uprint("Please manualy choose necessary:") 204 | count = 2 205 | 206 | items = [mapping.org.notes, mapping.remote.notes] 207 | while True: 208 | for i, v in enumerate(items): 209 | uprint("[{0}] Use this block:".format(i)) 210 | count += 1 211 | 212 | for line in v: 213 | uprint(line) 214 | count += 1 215 | 216 | uprint("-------------------------------------") 217 | count += 1 218 | 219 | uprint("[e] Edit in external editor") 220 | count += 1 221 | 222 | result = input() 223 | count += 1 224 | 225 | try: 226 | if result == 'e': 227 | result = None 228 | break 229 | 230 | result = int(result) 231 | if result >= 0 and result <= i: 232 | result = items[result] 233 | break 234 | except: 235 | pass 236 | 237 | uprint("Incorrect input!") 238 | count += 1 239 | 240 | console.cleanLastRows(count) 241 | if result is not None: 242 | return result 243 | 244 | # External editor 245 | temp_fid, temp_name = tempfile.mkstemp() 246 | try: 247 | with codecs.open(temp_name, "w", encoding="utf-8") as temp_file: 248 | for item in items: 249 | for line in item: 250 | temp_file.write(line) 251 | temp_file.write('\n') 252 | 253 | subprocess.call('vim -n {0}'.format(temp_name), shell=True) 254 | 255 | with codecs.open(temp_name, "r", encoding="utf-8") as temp_file: 256 | result = [x.strip() for x in temp_file.readlines()] 257 | 258 | except Exception as e: 259 | uprint(e) 260 | 261 | os.close(temp_fid) 262 | os.remove(temp_name) 263 | return result 264 | 265 | 266 | def _merge_links(self, mapping): 267 | return BaseMergeConf.merge_links(mapping) 268 | 269 | 270 | def __select_from(self, message, items): 271 | for l in message: 272 | uprint(l) 273 | count = len(message) 274 | 275 | while True: 276 | for i, v in enumerate(items): 277 | uprint("[{0}] {1}".format(i, v)) 278 | count += len(items) 279 | 280 | result = input() 281 | count += 1 282 | 283 | try: 284 | result = int(result) 285 | if result >= 0 and result <= i: 286 | result = items[result] 287 | break 288 | except: 289 | pass 290 | 291 | uprint("Incorrect input!") 292 | count += 1 293 | 294 | console.cleanLastRows(count) 295 | return result 296 | 297 | 298 | 299 | class PushMergeConf(BaseMergeConf): 300 | 301 | def _select_org_task(self, unmapped_task, tasklist): 302 | items = [[i, v, difflib.SequenceMatcher(a=unmapped_task.title, b=v.title).ratio()] 303 | for i, v in enumerate(tasklist)] 304 | items.sort(key=lambda v: v[2], reverse=True) 305 | return items[0][0] 306 | 307 | 308 | def _merge_title(self, mapping): 309 | return mapping.org.title 310 | 311 | 312 | def _merge_completed(self, mapping): 313 | return mapping.org.completed 314 | 315 | 316 | def _merge_schedule_time(self, mapping): 317 | return mapping.org.schedule_time 318 | 319 | 320 | def _merge_notes(self, mapping): 321 | return mapping.org.notes 322 | 323 | 324 | def _merge_links(self, mapping): 325 | return mapping.org.links 326 | -------------------------------------------------------------------------------- /michel/gtasks.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the 2 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 3 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import httplib2 6 | 7 | import os 8 | import sys 9 | import datetime 10 | import argparse 11 | import re 12 | 13 | from apiclient import discovery 14 | import oauth2client 15 | import oauth2client.file 16 | from oauth2client import client 17 | from oauth2client import tools 18 | 19 | from michel.tasktree import TaskLink, TasksTree, OrgDate 20 | from michel import utils 21 | 22 | if 'HTTP_PROXY' in os.environ: 23 | try: 24 | import socks 25 | http_proxy = re.match("^(?Phttp|https|socks):\/\/(?:(?P[^:]+):(?P[^@]+)@)?(?P
[^:]+)(?::(?P\d+))?$", os.environ['HTTP_PROXY']) 26 | socks.set_default_proxy(socks.HTTP, 27 | http_proxy.group('address'), 28 | int(http_proxy.group('port')), 29 | username=http_proxy.group('username'), 30 | password=http_proxy.group('password')) 31 | socks.wrap_module(httplib2) 32 | except: 33 | print("HTTP Proxy cannot be used, please install pysocks", file=sys.stderr) 34 | sys.exit(1) 35 | 36 | 37 | class GtaskProvider: 38 | _sys_regex = re.compile(":PARENT: (.*)") 39 | _google_time_regex = re.compile("(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+).+") 40 | 41 | def __init__(self, path, params): 42 | self._profile_name = path[0] 43 | self._list_name = path[1] 44 | 45 | self._tasks_tree = None 46 | self._task_id_map = None 47 | self._id_task_map = None 48 | 49 | self._init_service() 50 | 51 | def merge_schedule_time(self, default, mapping): 52 | remote = mapping.remote.schedule_time 53 | org = mapping.org.schedule_time 54 | 55 | if not remote or not org: 56 | return default(mapping) 57 | 58 | remote = remote.get_date() 59 | org = org.get_date() 60 | 61 | if remote.year != org.year or remote.month != org.month or remote.day != org.day: 62 | return default(mapping) 63 | 64 | mapping.remote.schedule_time = mapping.org.schedule_time 65 | return mapping.org.schedule_time 66 | 67 | def get_tasks(self): 68 | return self._tasks_tree 69 | 70 | 71 | def sync(self, sync_plan): 72 | for item in sync_plan: 73 | task = item['item'] 74 | if item['action'] == 'append': 75 | if task.title is None: 76 | continue 77 | 78 | notes = [x for x in task.notes] 79 | parent = self._tasks_tree.find_parent(task) 80 | gparent = None 81 | 82 | if parent: 83 | if parent in self._task_id_map: 84 | gparent = self._task_id_map[parent] 85 | elif parent.title: 86 | notes.insert(0, ':PARENT: ' + parent_task) 87 | 88 | gtask = { 89 | 'title': task.title, 90 | 'notes': '\n'.join(notes), 91 | 'status': 'completed' if task.completed else 'needsAction' 92 | } 93 | 94 | if task.closed_time is not None: 95 | gtask['completed'] = self._to_google_date_format(task.closed_time) 96 | 97 | if task.schedule_time is not None: 98 | gtask['due'] = self._to_google_date_format(task.schedule_time) 99 | 100 | if len(task.links) > 0: 101 | gtask['links'] = GtaskProvider.convert_links(task.links) 102 | 103 | res = self._service.tasks().insert( 104 | tasklist=self._list_id, 105 | parent=gparent, 106 | body=gtask 107 | ).execute() 108 | 109 | self._task_id_map[task] = res['id'] 110 | 111 | elif item['action'] == 'update': 112 | gtask = {} 113 | if 'title' in item['changes']: 114 | gtask['title'] = task.title 115 | if 'notes' in item['changes']: 116 | gtask['notes'] = '\n'.join(task.notes) 117 | if 'completed' in item['changes']: 118 | if task.completed: 119 | gtask['status'] = 'completed' 120 | gtask['completed'] = self._to_google_date_format(task.closed_time) 121 | else: 122 | gtask['status'] = 'needsAction' 123 | gtask['completed'] = None 124 | if 'schedule_time' in item['changes']: 125 | if task.schedule_time: 126 | gtask['due'] = self._to_google_date_format(task.schedule_time) 127 | else: 128 | gtask['due'] = None 129 | if 'links' in item['changes']: 130 | gtask['links'] = GtaskProvider.convert_links(task.links) 131 | 132 | if len(gtask) == 0: 133 | continue 134 | 135 | self._service.tasks().patch( 136 | tasklist=self._list_id, 137 | task=self._task_id_map[task], 138 | body=gtask 139 | ).execute() 140 | 141 | elif item['action'] == 'remove': 142 | task_id = self._task_id_map[task] 143 | 144 | self._service.tasks().delete( 145 | tasklist=self._list_id, 146 | task=task_id 147 | ).execute() 148 | 149 | parent = self._tasks_tree.find_parent(task) 150 | if parent is not None: 151 | parent.remove_subtask(task) 152 | 153 | del self._id_task_map[task_id] 154 | del self._task_id_map[task] 155 | 156 | def pull(self): 157 | """Get a TaskTree object representing a google tasks list. 158 | 159 | The Google Tasks list named *list_name* is retrieved, and converted into a 160 | TaskTree object which is returned. If *list_name* is not specified, then 161 | the default Google-Tasks list will be used. 162 | 163 | """ 164 | 165 | pageToken = None 166 | tasklist = [] 167 | while True: 168 | tasks = self._service.tasks().list(tasklist=self._list_id, pageToken=pageToken).execute() 169 | tasklist += [t for t in tasks.get('items', [])] 170 | 171 | pageToken = tasks.get('nextPageToken', None) 172 | if not pageToken: 173 | break 174 | 175 | self._tasks_tree = TasksTree(None) 176 | self._task_id_map = {} 177 | self._id_task_map = {} 178 | 179 | fail_count = 0 180 | while tasklist != [] and fail_count < 1000: 181 | gtask = tasklist.pop(0) 182 | try: 183 | title = gtask['title'].strip() 184 | if len(title) == 0: 185 | continue 186 | 187 | if 'parent' in gtask and gtask['parent'] in self._id_task_map: 188 | parent_task = self._id_task_map[gtask['parent']] 189 | else: 190 | parent_task = self._tasks_tree 191 | 192 | task = parent_task.add_subtask(title) 193 | 194 | self._id_task_map[gtask['id']] = task 195 | self._task_id_map[task] = gtask['id'] 196 | 197 | task.todo = True 198 | task.completed = gtask['status'] == 'completed' 199 | task.schedule_time = self._from_google_date_format(gtask['due']) if 'due' in gtask else None 200 | task.closed_time = self._from_google_date_format(gtask['completed']) if 'completed' in gtask else None 201 | 202 | if 'notes' in gtask: 203 | for note_line in gtask['notes'].split('\n'): 204 | note_line = note_line.strip() 205 | if self._sys_regex.match(note_line): 206 | continue 207 | 208 | if len(note_line) > 0: 209 | task.notes.append(note_line) 210 | 211 | if 'links' in gtask: 212 | for link in gtask['links']: 213 | task.links.append(TaskLink( 214 | link['link'], 215 | link['description'], 216 | [link['type']])) 217 | 218 | except ValueError: 219 | fail_count += 1 220 | 221 | def _init_service(self): 222 | """ 223 | Handle oauth's shit (copy-paste from 224 | http://code.google.com/apis/tasks/v1/using.html) 225 | Yes I do publish a secret key here, apparently it is normal 226 | http://stackoverflow.com/questions/7274554/why-google-native-oauth2-flow-require-client-secret 227 | """ 228 | 229 | storage = oauth2client.file.Storage(utils.save_data_path("gtasks_oauth.dat")) 230 | credentials = storage.get() 231 | if not credentials or credentials.invalid: 232 | flow = client.OAuth2WebServerFlow( 233 | client_id='617841371351.apps.googleusercontent.com', 234 | client_secret='_HVmphe0rqwxqSR8523M6g_g', 235 | scope='https://www.googleapis.com/auth/tasks', 236 | user_agent='michel/0.0.1') 237 | flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args("") 238 | credentials = tools.run_flow(flow, storage, flags) 239 | 240 | http = httplib2.Http() 241 | http = credentials.authorize(http) 242 | self._service = discovery.build(serviceName='tasks', version='v1', http=http, cache_discovery=False) 243 | 244 | if self._list_name is None or self._list_name == "default": 245 | self._list_id = "@default" 246 | else: 247 | tasklists = self._service.tasklists().list().execute() 248 | for tasklist in tasklists['items']: 249 | if tasklist['title'] == self._list_name: 250 | self._list_id = tasklist['id'] 251 | break 252 | 253 | if not self._list_id: 254 | raise Exception('ERROR: No google task-list named "{0}"'.format(self._list_name)) 255 | 256 | @classmethod 257 | def _from_google_date_format(self, value): 258 | time = [int(x) for x in self._google_time_regex.findall(value)[0] if len(x) > 0] 259 | return OrgDate(datetime.date(time[0], time[1], time[2])) 260 | 261 | @classmethod 262 | def _to_google_date_format(self, value): 263 | return value.get_date().strftime("%Y-%m-%dT00:00:00Z") 264 | 265 | @classmethod 266 | def convert_links(self, links): 267 | return [{ 268 | 'link': x.link, 269 | 'description': x.title or x.link, 270 | 'type': x.tags[0] if len(x.tags) > 0 else 'url' 271 | } for x in links] 272 | -------------------------------------------------------------------------------- /michel/mergetask.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This Source Code Form is subject to the terms of the 5 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 6 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import sys 9 | import datetime 10 | import michel.tasktree 11 | 12 | class PartTree: 13 | def __init__(self, parent, task): 14 | self.task = task 15 | self.parent = parent 16 | self.repeated = False 17 | self.no_auto = False 18 | 19 | self.hash_sum = 0 20 | if self.task.title: 21 | for char in self.task.title: 22 | self.hash_sum += ord(char) 23 | 24 | def is_title_equal(self, another): 25 | return self.task.title == another.task.title 26 | 27 | def is_fully_equal(self, another): 28 | return\ 29 | self.task.title == another.task.title and\ 30 | self.task.schedule_time == another.task.schedule_time 31 | 32 | def __str__(self): 33 | return "[ {0} {1} {{{2}}}, p: {3} ]".format( 34 | self.task.title, 35 | self.hash_sum, 36 | self.task.schedule_time, 37 | self.parent.task.title if self.parent else None) 38 | 39 | def __repr__(self): 40 | return str(self) 41 | 42 | 43 | class MergeEntry: 44 | def __init__(self, org, remote, base = None): 45 | self.org = org 46 | self.remote = remote 47 | self.base = base 48 | 49 | def __str__(self): 50 | return "org:{0} remote:{1} base:{2}".format(self.org, self.remote, self.base) 51 | 52 | def __repr__(self): 53 | return str(self) 54 | 55 | 56 | def _disassemble_tree(tree, disassemblies): 57 | def _disassemble(tree, parent, groups): 58 | current = PartTree(parent, tree) 59 | 60 | prior_task = groups.get(tree.title, None) 61 | if prior_task is None: 62 | groups[tree.title] = current 63 | else: 64 | prior_task.repeated = True 65 | current.repeated = True 66 | 67 | disassemblies.append(current) 68 | for i in range(len(tree)): 69 | _disassemble(tree[i], current, groups) 70 | 71 | for i in range(len(tree)): 72 | _disassemble(tree[i], None, {}) 73 | disassemblies.sort(key=lambda node: node.hash_sum) 74 | 75 | def __extract_from_base(mapping, name): 76 | if mapping.base is None: 77 | return None 78 | 79 | value_org = getattr(mapping.org, name) 80 | value_remote = getattr(mapping.remote, name) 81 | value_base = getattr(mapping.base, name) 82 | 83 | if value_base == value_org: 84 | return value_remote 85 | if value_base == value_remote: 86 | return value_org 87 | return None 88 | 89 | def merge_attr(mapping, attr_name, merge_func, changes_list): 90 | if getattr(mapping.org, attr_name) != getattr(mapping.remote, attr_name): 91 | new_value = None 92 | if attr_name in ['title', 'completed', 'schedule_time', 'notes', 'links']: 93 | new_value = __extract_from_base(mapping, attr_name) 94 | if new_value is None: 95 | new_value = merge_func(mapping) 96 | setattr(mapping.org, attr_name, new_value) 97 | 98 | if getattr(mapping.remote, attr_name) != getattr(mapping.org, attr_name): 99 | setattr(mapping.remote, attr_name, getattr(mapping.org, attr_name)) 100 | changes_list.append(attr_name) 101 | 102 | def copy_attr(task_dst, task_src): 103 | for attr_name in ['todo', 'completed', 'closed_time', 'schedule_time', 'notes', 'links']: 104 | setattr(task_dst, attr_name, getattr(task_src, attr_name)) 105 | 106 | def _merge_repeated_tasks(mapped_tasks, tasks_org, tasks_remote, index_org, index_remote): 107 | def __extract_group(tasks, index): 108 | group_shed, group_noshed = [], [] 109 | reference_task = tasks[index] 110 | 111 | while index < len(tasks) and reference_task.is_title_equal(tasks[index]): 112 | node = tasks.pop(index) 113 | group = group_shed if node.task.schedule_time is not None else group_noshed 114 | group.append(node) 115 | 116 | group_shed.sort(key=lambda node: node.task.schedule_time) 117 | return group_shed, group_noshed 118 | 119 | def __return_back(tasks_from, tasks_to, index_to): 120 | for entry in tasks_from: 121 | entry.no_auto = True 122 | tasks_to.insert(index_to, entry) 123 | index_to += 1 124 | return index_to 125 | 126 | # Get task groups sorted by schedule time and remove them from tasks collections 127 | group_org_shed, group_org_noshed = __extract_group(tasks_org, index_org) 128 | group_remote_shed, group_remote_noshed = __extract_group(tasks_remote, index_remote) 129 | 130 | # Map tasks which have schedule_time 131 | gos = len(group_org_shed) 132 | grs = len(group_remote_shed) 133 | 134 | while True: 135 | goi, gri = 0, 0 136 | max_delta = sys.maxsize 137 | merge_list = [] 138 | 139 | while True: 140 | while True: 141 | if goi >= gos or gri >= grs: 142 | break 143 | 144 | if group_org_shed[goi] is not None and group_remote_shed[gri] is not None: 145 | break 146 | 147 | if group_org_shed[goi] is None: 148 | while gri < grs and group_remote_shed[gri] is not None: 149 | gri += 1 150 | elif group_remote_shed[gri] is None: 151 | while goi < gos and group_org_shed[goi] is not None: 152 | goi += 1 153 | 154 | goi += 1 155 | gri += 1 156 | 157 | if goi >= gos or gri >= grs: 158 | break 159 | 160 | delta = group_org_shed[goi].task.schedule_time.get_hash() -\ 161 | group_remote_shed[gri].task.schedule_time.get_hash() 162 | abs_delta = abs(delta) 163 | 164 | if abs_delta < max_delta: 165 | max_delta = abs(delta) 166 | merge_list.clear() 167 | 168 | if abs_delta == max_delta: 169 | merge_list.append((goi, gri)) 170 | goi += 1 171 | gri += 1 172 | else: 173 | if delta > 0: 174 | gri += 1 175 | else: 176 | goi += 1 177 | 178 | if len(merge_list) == 0: 179 | break 180 | 181 | for entry in merge_list: 182 | mapped_tasks.append(MergeEntry(group_org_shed[entry[0]], group_remote_shed[entry[1]])) 183 | group_org_shed[entry[0]] = None 184 | group_remote_shed[entry[1]] = None 185 | 186 | # Map not scheduled tasks 187 | while len(group_org_noshed) > 0 and len(group_remote_noshed) > 0: 188 | mapped_tasks.append(MergeEntry(group_org_noshed.pop(0), group_remote_noshed.pop(0))) 189 | 190 | # Return not-mapped tasks back in tasks collection 191 | index_org = __return_back((x for x in group_org_shed if x is not None), tasks_org, index_org) 192 | index_org = __return_back(group_org_noshed, tasks_org, index_org) 193 | index_remote = __return_back((x for x in group_remote_shed if x is not None), tasks_remote, index_remote) 194 | index_remote = __return_back(group_remote_noshed, tasks_remote, index_remote) 195 | 196 | 197 | def treemerge(tree_org, tree_remote, tree_base, conf): 198 | tasks_base = [] 199 | tasks_org = [] 200 | tasks_remote = [] 201 | sync_plan = [] 202 | 203 | _disassemble_tree(tree_org, tasks_org) 204 | _disassemble_tree(tree_remote, tasks_remote) 205 | if tree_base is not None: 206 | _disassemble_tree(tree_base, tasks_base) 207 | 208 | mapped_tasks = [] 209 | 210 | # first step, exact matching 211 | index_remote, index_org = 0, 0 212 | while index_remote < len(tasks_remote): 213 | if tasks_remote[index_remote].no_auto: 214 | index_remote += 1 215 | continue 216 | 217 | is_mapped = False 218 | index_org = 0 219 | 220 | while index_org < len(tasks_org): 221 | if tasks_org[index_org].no_auto: 222 | index_org += 1 223 | continue 224 | 225 | if tasks_remote[index_remote].is_title_equal(tasks_org[index_org]): 226 | if not tasks_org[index_org].repeated and not tasks_remote[index_remote].repeated: 227 | mapped_tasks.append(MergeEntry(tasks_org.pop(index_org), tasks_remote.pop(index_remote))) 228 | else: 229 | _merge_repeated_tasks(mapped_tasks, tasks_org, tasks_remote, index_org, index_remote) 230 | 231 | is_mapped = True 232 | break 233 | else: 234 | index_org += 1 235 | 236 | if not is_mapped: 237 | index_remote += 1 238 | 239 | # second step, manual matching 240 | index_remote, index_org = 0, 0 241 | while index_remote < len(tasks_remote) and len(tasks_org) > 0: 242 | index_org = conf.select_org_task(tasks_remote[index_remote].task, (x.task for x in tasks_org)) 243 | 244 | if index_org == 'discard': 245 | tasks_remote[index_remote].task.completed = True 246 | elif index_org != 'new': 247 | mapped_tasks.append(MergeEntry(tasks_org.pop(index_org), tasks_remote.pop(index_remote))) 248 | continue 249 | 250 | index_remote += 1 251 | 252 | # second and half step, base entry exact matching 253 | index_mapping, index_base = 0, 0 254 | while index_mapping < len(mapped_tasks): 255 | index_base = 0 256 | 257 | while index_base < len(tasks_base): 258 | if mapped_tasks[index_mapping].org.is_fully_equal(tasks_base[index_base]) or\ 259 | mapped_tasks[index_mapping].remote.is_fully_equal(tasks_base[index_base]): 260 | mapped_tasks[index_mapping].base = tasks_base.pop(index_base) 261 | break 262 | else: 263 | index_base += 1 264 | 265 | index_mapping += 1 266 | 267 | # third step, patching org tree 268 | for map_entry in mapped_tasks: 269 | diff_notes = [] 270 | changes_list = [] 271 | 272 | merge_entry = MergeEntry( 273 | map_entry.org.task, 274 | map_entry.remote.task, 275 | map_entry.base.task if map_entry.base is not None else None) 276 | 277 | merge_attr(merge_entry, "title", lambda a: conf.merge_title(a), changes_list) 278 | merge_attr(merge_entry, "completed", lambda a: conf.merge_completed(a), changes_list) 279 | merge_attr(merge_entry, "closed_time", lambda a: conf.merge_closed_time(a), changes_list) 280 | merge_attr(merge_entry, "schedule_time", lambda a: conf.merge_schedule_time(a), changes_list) 281 | merge_attr(merge_entry, "notes", lambda a: conf.merge_notes(a), changes_list) 282 | merge_attr(merge_entry, "links", lambda a: conf.merge_links(a), changes_list) 283 | 284 | if conf.is_needed(map_entry.remote.task): 285 | if len(changes_list) > 0: 286 | sync_plan.append({ 287 | "action": "update", 288 | "changes": changes_list, 289 | "item": map_entry.remote.task 290 | }) 291 | else: 292 | if map_entry.remote.task.title is not None: 293 | sync_plan.append({ 294 | "action": "remove", 295 | "item": map_entry.remote.task 296 | }) 297 | 298 | # fourth step, append new items to org tree 299 | for i in range(len(tasks_remote)): 300 | new_task = tasks_remote[i] 301 | 302 | try: 303 | parent_task = next(x for x in mapped_tasks if x.remote == new_task.parent).org.task 304 | except StopIteration: 305 | parent_task = tree_org 306 | 307 | created_task = parent_task.add_subtask(new_task.task.title) 308 | copy_attr(created_task, new_task.task) 309 | 310 | if not conf.is_needed(new_task.task): 311 | sync_plan.append({ 312 | "action": "remove", 313 | "item": new_task.task 314 | }) 315 | 316 | # fifth step, append new items to remote tree 317 | for i in range(len(tasks_org)): 318 | new_task = tasks_org[i] 319 | 320 | if not conf.is_needed(new_task.task): 321 | continue 322 | 323 | try: 324 | parent_task = next(x for x in mapped_tasks if x.org == new_task.parent).remote.task 325 | except StopIteration: 326 | parent_task = tree_remote 327 | 328 | created_task = parent_task.add_subtask(new_task.task.title) 329 | copy_attr(created_task, new_task.task) 330 | 331 | sync_plan.append({ 332 | "action": "append", 333 | "item": created_task 334 | }) 335 | 336 | return sync_plan 337 | -------------------------------------------------------------------------------- /michel/tasktree.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This Source Code Form is subject to the terms of the 5 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 6 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import codecs 9 | import re 10 | import io 11 | import datetime 12 | 13 | from michel.utils import * 14 | 15 | headline_regex = re.compile("^(\*+) *(DONE|TODO)? *(.*)") 16 | timeline_regex = re.compile("(?:CLOSED: \[(.*)\]|(?:SCHEDULED: <(.*)>) *)+") 17 | shortlink_regex = re.compile("^([\w-]+:\/\/[^\s\]]*)$") 18 | fulllink_regex = re.compile("^\[\[([\w-]+:\/\/[^\s\]]*)\]\[([^\]]+)\]\]$") 19 | tags_regex = re.compile("\s+#(\S+)$") 20 | 21 | class OrgDate: 22 | default_locale = None 23 | _regex = re.compile("(\d+)-(\d+)-(\d+) \S+(?: (\d+):(\d+)(?:-(\d+):(\d+))?)?") 24 | 25 | def __init__(self, date, start_time = None, duration = None): 26 | if start_time is None and duration is not None: 27 | raise ValueError("duration cannot be defined without start_time") 28 | 29 | self._date = date 30 | self._start_time = start_time 31 | self._duration = duration 32 | 33 | 34 | @classmethod 35 | def parse_org_format(self, org_time = None): 36 | if org_time is None: 37 | return None 38 | 39 | temp = [int(x) for x in self._regex.findall(org_time)[0] if len(x) > 0] 40 | if len(temp) < 3: 41 | return None 42 | 43 | date = datetime.date(temp[0], temp[1], temp[2]) 44 | start_time = datetime.time(temp[3], temp[4]) if len(temp) > 3 else None 45 | duration = self._calc_duration(start_time, datetime.time(temp[5], temp[6])) if len(temp) > 5 else None 46 | 47 | return self(date, start_time, duration) 48 | 49 | @classmethod 50 | def now(self): 51 | temp = datetime.datetime.now() 52 | 53 | return self(datetime.date(temp.year, temp.month, temp.day), 54 | datetime.time(temp.hour, temp.minute)) 55 | 56 | def to_org_format(self): 57 | try: 58 | old_locale = locale.getlocale(locale.LC_TIME) 59 | locale.setlocale(locale.LC_TIME, type(self).default_locale) 60 | res = self._date.strftime("%Y-%m-%d %a") 61 | 62 | if self._start_time: 63 | res += self._start_time.strftime(" %H:%M") 64 | 65 | if self._duration: 66 | res += self._calc_end_time(self._start_time, self._duration).strftime("-%H:%M") 67 | 68 | if os.name == 'nt': 69 | # It's hell... 70 | res = res.encode('latin-1').decode(locale.getpreferredencoding()) 71 | 72 | return res 73 | finally: 74 | locale.setlocale(locale.LC_TIME, old_locale) 75 | 76 | def get_date(self): 77 | return self._date 78 | 79 | def get_time(self): 80 | return self._time 81 | 82 | def get_hash(self): 83 | total_days2 = (self._date.year * 12 + self._date.month) * 31 + self._date.day 84 | total_minutes2 = (self._start_time.hour * 60 + self._start_time.minute) if self._start_time else 0 85 | return total_days2 * 24 * 60 + total_minutes2 86 | 87 | def __eq__(self, other): 88 | return isinstance(other, type(self)) and \ 89 | self._date == other._date and \ 90 | self._start_time == other._start_time and \ 91 | self._duration == other._duration 92 | 93 | def __ne__(self, other): 94 | return not self.__eq__(other) 95 | 96 | def __lt__(self, other): 97 | if self._date < other._date: 98 | return True 99 | elif self._date > other._date: 100 | return False 101 | 102 | if self._start_time is None or other._start_time is None: 103 | return False 104 | 105 | if self._start_time < other._start_time: 106 | return True 107 | elif self._start_time > other._start_time: 108 | return False 109 | 110 | return False 111 | 112 | def __repr__(self): 113 | return self.to_org_format() 114 | 115 | def __str__(self): 116 | return self.to_org_format() 117 | 118 | @classmethod 119 | def _calc_duration(self, time1, time2): 120 | return datetime.timedelta(minutes = (time2.hour - time1.hour) * 60 + (time2.minute - time1.minute)) 121 | 122 | @classmethod 123 | def _calc_end_time(self, time, duration): 124 | duration_hours, remainder = divmod(duration.seconds, 3600) 125 | duration_minutes, _ = divmod(remainder, 60) 126 | 127 | hours = time.hour + duration_hours 128 | minutes = time.minute + duration_minutes 129 | 130 | while minutes >= 60: 131 | hours += 1 132 | minutes -= 60 133 | 134 | return datetime.time(hours, minutes) 135 | 136 | 137 | class TaskLink: 138 | 139 | def __init__(self, link, title=None, tags=[]): 140 | self.link = link 141 | self.title = title 142 | self.tags = tags 143 | 144 | def __repr__(self): 145 | return '{} {} {}'.format(self.link, self.title, self.tags) 146 | 147 | def __str__(self): 148 | if self.title or len(self.tags) > 0: 149 | title = self.title or self.link 150 | if len(self.tags) > 0: 151 | title = '{} {}'.format(title, ' '.join('#' + tag for tag in self.tags)) 152 | return '[[{}][{}]]'.format(self.link, title) 153 | else: 154 | return self.link 155 | 156 | def __eq__(self, another): 157 | return (self.link == another.link and 158 | self.title == another.title and 159 | self.tags == another.tags) 160 | 161 | @classmethod 162 | def try_parse(self, line): 163 | shortlink_match = shortlink_regex.match(line) 164 | if shortlink_match: 165 | return TaskLink(shortlink_match.group(1)) 166 | 167 | fulllink_match = fulllink_regex.match(line) 168 | if fulllink_match: 169 | title = fulllink_match.group(2) 170 | tags = [] 171 | 172 | # Parse tags in end of line 173 | while True: 174 | tags_match = tags_regex.search(title) 175 | if not tags_match: 176 | break 177 | tags.append(tags_match.group(1)) 178 | title = title[:tags_match.start()] 179 | tags.reverse() 180 | 181 | return TaskLink(fulllink_match.group(1), title, tags) 182 | 183 | return None 184 | 185 | 186 | class TasksTree(object): 187 | """ 188 | Tree for holding tasks 189 | 190 | A TasksTree: 191 | - is a task (except the root, which just holds the list) 192 | - has subtasks 193 | - may have a title 194 | """ 195 | 196 | def __init__(self, title): 197 | self.title = title 198 | self.subtasks = [] 199 | self.notes = [] 200 | self.links = [] 201 | 202 | self.todo = False 203 | self.completed = False 204 | 205 | self.closed_time = None 206 | self.schedule_time = None 207 | 208 | def __getitem__(self, key): 209 | return self.subtasks[key] 210 | 211 | def __setitem__(self, key, val): 212 | self.subtasks[key] = val 213 | 214 | def __delitem__(self, key): 215 | del(self.subtasks[key]) 216 | 217 | def __repr__(self): 218 | return self.title or "" 219 | 220 | def __len__(self): 221 | return len(self.subtasks) 222 | 223 | def update(self, todo=None, completed=None, closed_time=None, schedule_time=None, notes=None, links=None): 224 | if todo is not None: 225 | self.todo = todo 226 | 227 | if completed is not None: 228 | self.todo = True 229 | self.completed = completed 230 | 231 | if notes is not None: 232 | self.notes = notes 233 | 234 | if links is not None: 235 | self.links = links 236 | 237 | if closed_time is not None: 238 | self.closed_time = closed_time 239 | 240 | if schedule_time is not None: 241 | self.schedule_time = schedule_time 242 | 243 | return self 244 | 245 | def add_subtask(self, title): 246 | """ 247 | Adds a subtask to the tree 248 | """ 249 | 250 | task = TasksTree(title) 251 | self.subtasks.append(task) 252 | return task 253 | 254 | def remove_subtask(self, task): 255 | """ 256 | Remove the subtask from the tree 257 | """ 258 | 259 | self.subtasks.remove(task) 260 | return task 261 | 262 | def find_parent(self, task): 263 | for item in self: 264 | if item == task: 265 | return self 266 | else: 267 | result = item.find_parent(task) 268 | if result is not None: 269 | return result 270 | 271 | def parse_system_notes(self): 272 | for subtask in self.subtasks: 273 | subtask.parse_system_notes() 274 | 275 | real_notes = [] 276 | for line in self.notes: 277 | timeline_matches = timeline_regex.findall(line) 278 | 279 | if len(timeline_matches) > 0: 280 | for timeline_match in timeline_matches: 281 | if timeline_match[0]: 282 | self.closed_time = OrgDate.parse_org_format(timeline_match[0]) 283 | 284 | if timeline_match[1]: 285 | self.schedule_time = OrgDate.parse_org_format(timeline_match[1]) 286 | 287 | continue 288 | 289 | link = TaskLink.try_parse(line) 290 | if link: 291 | self.links.append(link) 292 | continue 293 | 294 | real_notes.append(line) 295 | 296 | while (len(real_notes) > 0) and (len(real_notes[0].strip()) == 0): 297 | real_notes.pop(0) 298 | while (len(real_notes) > 0) and (len(real_notes[-1].strip()) == 0): 299 | real_notes.pop(-1) 300 | 301 | self.notes = real_notes 302 | 303 | def _append_tree(self, res, level): 304 | """Returns the sequence of lines of the string representation""" 305 | 306 | for subtask in self.subtasks: 307 | task_line = ['*' * (level + 1)] 308 | if subtask.completed: 309 | task_line.append('DONE') 310 | elif subtask.todo: 311 | task_line.append('TODO') 312 | task_line.append(subtask.title) 313 | res.append(' '.join(task_line)) 314 | 315 | time_line = [' ' * (level + 1)] 316 | if subtask.closed_time: 317 | time_line.append("CLOSED: [{0}]".format(subtask.closed_time.to_org_format())) 318 | if subtask.schedule_time: 319 | time_line.append("SCHEDULED: <{0}>".format(subtask.schedule_time.to_org_format())) 320 | if len(time_line) > 1: 321 | res.append(' '.join(time_line)) 322 | subtask._append_links(res, level + 2) 323 | subtask._append_notes(res, level + 2) 324 | subtask._append_tree(res, level + 1) 325 | 326 | 327 | def _append_links(self, res, padding): 328 | for link in self.links: 329 | link_line = ' ' * padding + str(link) 330 | res.append(link_line) 331 | 332 | 333 | def _append_notes(self, res, padding): 334 | for note_line in self.notes: 335 | # add initial space to lines starting w/'*', so that it isn't treated as a task 336 | if note_line.startswith("*"): 337 | note_line = " " + note_line 338 | note_line = ' ' * padding + note_line 339 | res.append(note_line) 340 | 341 | def __str__(self): 342 | """string representation of the tree. 343 | 344 | Only the root-node's children (and their descendents...) are printed, 345 | not the root-node itself. 346 | 347 | """ 348 | # always add a trailing "\n" because text-files normally include a "\n" 349 | # at the end of the last line of the file. 350 | res = [] 351 | self._append_notes(res, 0) 352 | self._append_tree(res, 0) 353 | return '\n'.join(res) + "\n" 354 | 355 | def write_file(self, fname): 356 | f = codecs.open(fname, "w", "utf-8") 357 | f.write(self.__str__()) 358 | f.close() 359 | 360 | def parse_file(path): 361 | """Parses an org-mode file and returns a tree""" 362 | file_lines = codecs.open(path, "r", "utf-8").readlines() 363 | file_text = "".join(file_lines) 364 | return TasksTree.parse_text(file_text) 365 | 366 | def parse_text(text): 367 | """Parses an org-mode formatted block of text and returns a tree""" 368 | # create a (read-only) file object containing *text* 369 | f = io.StringIO(text) 370 | 371 | tasks_tree = TasksTree(None) 372 | last_task = tasks_tree 373 | task_stack = [tasks_tree] 374 | 375 | for line in f: 376 | line = line.strip() 377 | matches = headline_regex.findall(line) 378 | try: 379 | # assign task_depth; root depth starts at 0 380 | indent_level = len(matches[0][0]) 381 | 382 | # add the task to the tree 383 | last_task = task_stack[indent_level - 1].add_subtask(matches[0][2]) 384 | 385 | # expand if it is needed 386 | if (indent_level + 1) > len(task_stack): 387 | task_stack = [task_stack[x] if x < len(task_stack) else None 388 | for x in range(indent_level + 1)] 389 | 390 | task_stack[indent_level] = last_task 391 | 392 | last_task.todo = matches[0][1] == 'DONE' or matches[0][1] == 'TODO' 393 | last_task.completed = matches[0][1] == 'DONE' 394 | 395 | except IndexError: 396 | # this is not a task, but a task-notes line 397 | last_task.notes.append(line) 398 | 399 | f.close() 400 | 401 | tasks_tree.parse_system_notes() 402 | return tasks_tree 403 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /tests/test_merge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Suite of unit-tests for testing Michel 5 | """ 6 | 7 | # This Source Code Form is subject to the terms of the 8 | # Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 9 | # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | import unittest 12 | import textwrap 13 | import os 14 | import sys 15 | import tempfile 16 | import datetime 17 | import locale 18 | 19 | import michel as m 20 | import tests 21 | 22 | class MergeTests(unittest.TestCase): 23 | 24 | def __init__(self, *args, **kargs): 25 | super().__init__(*args, **kargs) 26 | 27 | self.now = m.OrgDate.now() 28 | m.OrgDate.default_locale = tests.getLocaleAlias('us') 29 | 30 | def test_org_date(self): 31 | reference = m.OrgDate(datetime.date(2016, 8, 5), 32 | datetime.time(12, 0)) 33 | same = m.OrgDate(datetime.date(2016, 8, 5), 34 | datetime.time(12, 0)) 35 | earlier = m.OrgDate(datetime.date(2016, 8, 4), 36 | datetime.time(12, 0)) 37 | later = m.OrgDate(datetime.date(2016, 8, 5), 38 | datetime.time(12, 45)) 39 | 40 | self.assertEqual(reference, same) 41 | self.assertEqual(earlier < reference, True) 42 | self.assertEqual(reference < later, True) 43 | self.assertEqual(min(later, earlier, reference), earlier) 44 | 45 | def test_todo_only(self): 46 | 47 | # Arrange 48 | org_tree = m.TasksTree.parse_text("""\ 49 | * NotEntry 50 | * TODO TodoEntry 51 | * DONE CompletedEntry 52 | CLOSED: [{0}] 53 | * NotExtEntry 54 | ** NotExtNotIntEntry 55 | ** TODO NotExtTodoIntEntry 56 | ** DONE NotExtCompletedIntEntry 57 | CLOSED: [{0}] 58 | * TODO TodoExtEntry 59 | ** TodoExtNotIntEntry 60 | ** TODO TodoExtTodoIntEntry 61 | ** DONE TodoExtCompletedIntEntry 62 | CLOSED: [{0}] 63 | """.format(self.now.to_org_format())) 64 | 65 | remote_tree, indexes = tests.createTestTree([ 66 | "TodoEntry", dict(completed=True, 67 | closed_time=self.now), 68 | "NotExtTodoIntEntry", dict(completed=True, 69 | closed_time=self.now), 70 | "TodoExtEntry", dict(completed=True, 71 | closed_time=self.now), 72 | "TodoExtTodoIntEntry", dict(completed=True, 73 | closed_time=self.now), 74 | "NewTodoEntry", dict(todo=True), 75 | ]) 76 | 77 | # Act 78 | m.treemerge(org_tree, remote_tree, None, tests.TestMergeConf([ 79 | [None, "NewTodoEntry", None] 80 | ])) 81 | 82 | # Assert 83 | result_text = textwrap.dedent("""\ 84 | * NotEntry 85 | * DONE TodoEntry 86 | CLOSED: [{0}] 87 | * DONE CompletedEntry 88 | CLOSED: [{0}] 89 | * NotExtEntry 90 | ** NotExtNotIntEntry 91 | ** DONE NotExtTodoIntEntry 92 | CLOSED: [{0}] 93 | ** DONE NotExtCompletedIntEntry 94 | CLOSED: [{0}] 95 | * DONE TodoExtEntry 96 | CLOSED: [{0}] 97 | ** TodoExtNotIntEntry 98 | ** DONE TodoExtTodoIntEntry 99 | CLOSED: [{0}] 100 | ** DONE TodoExtCompletedIntEntry 101 | CLOSED: [{0}] 102 | * TODO NewTodoEntry 103 | """.format(self.now.to_org_format())) 104 | self.assertEqual(str(org_tree), result_text) 105 | 106 | 107 | def test_safemerge(self): 108 | 109 | # Arrange 110 | org_tree = m.TasksTree.parse_text("""\ 111 | * Headline A1 112 | * Headline A2 113 | ** Headline A2.1 114 | * Headline B1 115 | ** Headline B1.1 116 | * Headline B2 117 | """) 118 | 119 | remote_tree, indexes = tests.createTestTree([ 120 | "Headline A1", 121 | " Headline A1.1", 122 | "Headline B1", 123 | " Headline B1.1", dict(notes=["Remote append B1.1 body text."]), 124 | "Headline A2", dict(todo=True), 125 | " Headline A2.1", 126 | "Headline B2 modified", dict(notes=["New B2 body text."]) 127 | ]) 128 | 129 | # Act 130 | m.treemerge(org_tree, remote_tree, None, tests.TestMergeConf([ 131 | [None, "Headline A1.1", None], 132 | ["Headline B2", "Headline B2 modified", "Headline B2 modified"], 133 | ])) 134 | 135 | # Assert 136 | result_text = textwrap.dedent("""\ 137 | * Headline A1 138 | ** Headline A1.1 139 | * Headline A2 140 | ** Headline A2.1 141 | * Headline B1 142 | ** Headline B1.1 143 | Remote append B1.1 body text. 144 | * Headline B2 modified 145 | New B2 body text. 146 | """) 147 | self.assertEqual(str(org_tree), result_text) 148 | 149 | 150 | def test_merge_sync_todo_only(self): 151 | 152 | # Arrange 153 | org_tree = m.TasksTree.parse_text("""\ 154 | * Headline A 155 | * Headline B 156 | ** TODO Headline B1 157 | * TODO Headline C 158 | * TODO Headline D 159 | * Headline E 160 | ** DONE Headline E1 161 | * DONE Headline F 162 | ** Headline F1 163 | """) 164 | 165 | remote_tree, indexes = tests.createTestTree([ 166 | "Headline B1", dict(completed=True, 167 | closed_time=self.now), 168 | "Headline C", dict(completed=True, 169 | closed_time=self.now), 170 | "Headline D", dict(todo=True), 171 | "Headline G", dict(todo=True), 172 | ]) 173 | 174 | # Act 175 | m.treemerge(org_tree, remote_tree, None, tests.TestMergeConf([ 176 | [None, "Headline G", None], 177 | ])) 178 | 179 | # Assert 180 | result_text = textwrap.dedent("""\ 181 | * Headline A 182 | * Headline B 183 | ** DONE Headline B1 184 | CLOSED: [{0}] 185 | * DONE Headline C 186 | CLOSED: [{0}] 187 | * TODO Headline D 188 | * Headline E 189 | ** DONE Headline E1 190 | * DONE Headline F 191 | ** Headline F1 192 | * TODO Headline G 193 | """.format(self.now.to_org_format())) 194 | self.assertEqual(str(org_tree), result_text) 195 | 196 | 197 | def test_fast_merge(self): 198 | 199 | # Arrange 200 | org_tree = m.TasksTree.parse_text("""\ 201 | * Headline A 202 | * Headline B 203 | ** TODO Headline B1 204 | ** DONE Headline B2 205 | CLOSED: [{0}] 206 | ** TODO Headline B3 original 207 | * TODO Headline C 208 | * Headline D 209 | ** DONE Headline D1 210 | CLOSED: [{0}] 211 | * Headline E 212 | ** DONE Headline E1 213 | * TODO Headline F 214 | """.format(self.now.to_org_format())) 215 | 216 | remote_tree, indexes = tests.createTestTree([ 217 | "Headline B1", dict(completed=True, 218 | closed_time=self.now), 219 | "Headline B2 modified", dict(todo=True), 220 | "Headline B3", dict(todo=True), 221 | "Headline C", dict(completed=True, 222 | closed_time=self.now), 223 | "Headline D1", dict(todo=True), 224 | "Headline G", dict(todo=True), 225 | "Headline H", dict(completed=True, 226 | closed_time=self.now), 227 | ]) 228 | 229 | # Act 230 | remote_sync_plan = m.treemerge(org_tree, remote_tree, None, tests.TestMergeConf([ 231 | [None, "Headline G", None], 232 | [None, "Headline H", None], 233 | ["Headline B2", "Headline B2 modified", "Headline B2 modified"], 234 | ["Headline B3 original", "Headline B3", "Headline B3 original"], 235 | ])) 236 | 237 | # Assert 238 | result_text = textwrap.dedent("""\ 239 | * Headline A 240 | * Headline B 241 | ** DONE Headline B1 242 | CLOSED: [{0}] 243 | ** DONE Headline B2 modified 244 | CLOSED: [{0}] 245 | ** TODO Headline B3 original 246 | * DONE Headline C 247 | CLOSED: [{0}] 248 | * Headline D 249 | ** DONE Headline D1 250 | CLOSED: [{0}] 251 | * Headline E 252 | ** DONE Headline E1 253 | * TODO Headline F 254 | * TODO Headline G 255 | * DONE Headline H 256 | CLOSED: [{0}] 257 | """.format(self.now.to_org_format())) 258 | self.assertEqual(str(org_tree), result_text) 259 | 260 | 261 | self.assertEqual(len(remote_sync_plan), 7) 262 | 263 | # Headline B1 264 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[0]) 265 | self.assertEqual(assertObj['action'], 'remove') 266 | 267 | # Headline B2 268 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[1]) 269 | self.assertEqual(assertObj['action'], 'remove') 270 | 271 | # Headline B3 272 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[2]) 273 | self.assertEqual(assertObj['action'], 'update') 274 | self.assertEqual(assertObj['changes'], ['title']) 275 | self.assertEqual(assertObj['item'].title, 'Headline B3 original') 276 | 277 | # Headline C 278 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[3]) 279 | self.assertEqual(assertObj['action'], 'remove') 280 | 281 | # Headline D1 282 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[4]) 283 | self.assertEqual(assertObj['action'], 'remove') 284 | 285 | # Headline F 286 | assertObj = next(x for x in remote_sync_plan if x['item'].title == "Headline F") 287 | self.assertEqual(assertObj['action'], 'append') 288 | self.assertEqual(assertObj['item'].title, 'Headline F') 289 | 290 | # Headline H 291 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[6]) 292 | self.assertEqual(assertObj['action'], 'remove') 293 | 294 | 295 | def test_sync_time(self): 296 | 297 | # Arrange 298 | org_tree = m.TasksTree.parse_text("""\ 299 | * TODO Headline A 300 | * TODO Headline B 301 | * TODO Headline C 302 | SCHEDULED: <2015-12-09 Wed 20:00-21:00> 303 | """) 304 | 305 | remote_tree, indexes = tests.createTestTree([ 306 | "Headline A", dict(todo=True, 307 | schedule_time=m.OrgDate(datetime.date(2015, 12, 9))), 308 | "Headline B", dict(todo=True), 309 | "Headline C", dict(completed=True, 310 | closed_time=self.now, 311 | schedule_time=m.OrgDate(datetime.date(2015, 12, 9), 312 | datetime.time(20, 0), 313 | datetime.timedelta(hours=1))), 314 | "Headline D", dict(todo=True, 315 | schedule_time=m.OrgDate(datetime.date(2015, 12, 9))), 316 | ]) 317 | 318 | # Act 319 | m.treemerge(org_tree, remote_tree, None, tests.TestMergeConf()) 320 | 321 | # Assert 322 | result_text = textwrap.dedent("""\ 323 | * TODO Headline A 324 | SCHEDULED: <2015-12-09 Wed> 325 | * TODO Headline B 326 | * DONE Headline C 327 | CLOSED: [{0}] SCHEDULED: <2015-12-09 Wed 20:00-21:00> 328 | * TODO Headline D 329 | SCHEDULED: <2015-12-09 Wed> 330 | """.format(self.now.to_org_format())) 331 | self.assertEqual(str(org_tree), result_text) 332 | 333 | def test_3way_merge(self): 334 | 335 | # Arrange 336 | base_tree = m.TasksTree.parse_text("""\ 337 | * NotTodoTestTask 338 | * TitleMergeTest 339 | ** TODO TitleMergeTask1 340 | ** TODO TitleMergeTask2 341 | ** TODO TitleMergeTask3 342 | * ScheduleMergeTest 343 | * TODO ScheduleMergeTask1 344 | SCHEDULED: <2015-12-09 Wed> 345 | * TODO ScheduleMergeTask2 346 | SCHEDULED: <2015-12-09 Wed> 347 | * TODO ScheduleMergeTask3 348 | SCHEDULED: <2015-12-09 Wed> 349 | """.format(self.now.to_org_format())) 350 | 351 | org_tree = m.TasksTree.parse_text("""\ 352 | * NotTodoTestTask 353 | * TitleMergeTest 354 | ** TODO TitleMergeTask1 355 | ** TODO TitleMergeTask2 org-edited 356 | ** TODO TitleMergeTask3 357 | * ScheduleMergeTest 358 | * TODO ScheduleMergeTask1 359 | SCHEDULED: <2015-12-09 Wed> 360 | * TODO ScheduleMergeTask2 361 | SCHEDULED: <2015-12-10 Thu> 362 | * TODO ScheduleMergeTask3 363 | SCHEDULED: <2015-12-09 Wed> 364 | """.format(self.now.to_org_format())) 365 | 366 | remote_tree, indexes = tests.createTestTree([ 367 | "TitleMergeTask1", dict(todo=True), 368 | "TitleMergeTask2", dict(todo=True), 369 | "TitleMergeTask3 remote-edited", dict(todo=True), 370 | "ScheduleMergeTask1", dict(todo=True, 371 | schedule_time=m.OrgDate(datetime.date(2015, 12, 9))), 372 | "ScheduleMergeTask2", dict(todo=True, 373 | schedule_time=m.OrgDate(datetime.date(2015, 12, 9))), 374 | "ScheduleMergeTask3", dict(todo=True, 375 | schedule_time=m.OrgDate(datetime.date(2015, 12, 11))) 376 | ]) 377 | 378 | # Act 379 | remote_sync_plan = m.treemerge(org_tree, remote_tree, base_tree, tests.TestMergeConf([ 380 | ["TitleMergeTask2 org-edited", "TitleMergeTask2", None], 381 | ["TitleMergeTask3", "TitleMergeTask3 remote-edited", None], 382 | ])) 383 | 384 | # Assert 385 | result_text = textwrap.dedent("""\ 386 | * NotTodoTestTask 387 | * TitleMergeTest 388 | ** TODO TitleMergeTask1 389 | ** TODO TitleMergeTask2 org-edited 390 | ** TODO TitleMergeTask3 remote-edited 391 | * ScheduleMergeTest 392 | * TODO ScheduleMergeTask1 393 | SCHEDULED: <2015-12-09 Wed> 394 | * TODO ScheduleMergeTask2 395 | SCHEDULED: <2015-12-10 Thu> 396 | * TODO ScheduleMergeTask3 397 | SCHEDULED: <2015-12-11 Fri> 398 | """) 399 | self.assertEqual(str(org_tree), result_text) 400 | 401 | 402 | self.assertEqual(len(remote_sync_plan), 2) 403 | 404 | # TitleMergeTask2 org-edited 405 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[1]) 406 | self.assertEqual(assertObj['action'], 'update') 407 | self.assertEqual(assertObj['changes'], ['title']) 408 | self.assertEqual(assertObj['item'].title, 'TitleMergeTask2 org-edited') 409 | 410 | # ScheduleMergeTask2 411 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[4]) 412 | self.assertEqual(assertObj['action'], 'update') 413 | self.assertEqual(assertObj['changes'], ['schedule_time']) 414 | self.assertEqual(assertObj['item'].schedule_time, 415 | m.OrgDate(datetime.date(2015, 12, 10))) 416 | 417 | 418 | def test_links_merge(self): 419 | 420 | # Arrange 421 | org_tree = m.TasksTree.parse_text("""\ 422 | * TODO Headline A 423 | https://anticode.ninja 424 | * TODO Headline B 425 | [[https://anticode.ninja][#anticode.ninja# blog]] 426 | [[https://github.com/anticodeninja/michel2][michel2 #repo #github]] 427 | """.format(self.now.to_org_format())) 428 | 429 | remote_tree, indexes = tests.createTestTree([ 430 | "Headline A", dict(todo=True, links=[ 431 | m.TaskLink("https://anticode.ninja"), 432 | m.TaskLink('https://github.com/anticodeninja/michel2', 'michel2', ['repo', 'github']) 433 | ]), 434 | "Headline B", dict(todo=True, links=[ 435 | m.TaskLink('https://anticode.ninja', '#anticode.ninja# blog') 436 | ]) 437 | ]) 438 | 439 | # Act 440 | remote_sync_plan = m.treemerge(org_tree, remote_tree, None, tests.TestMergeConf()) 441 | 442 | # Assert 443 | result_text = textwrap.dedent("""\ 444 | * TODO Headline A 445 | https://anticode.ninja 446 | [[https://github.com/anticodeninja/michel2][michel2 #repo #github]] 447 | * TODO Headline B 448 | [[https://anticode.ninja][#anticode.ninja# blog]] 449 | [[https://github.com/anticodeninja/michel2][michel2 #repo #github]] 450 | """.format(self.now.to_org_format())) 451 | self.assertEqual(str(org_tree), result_text) 452 | 453 | self.assertEqual(len(remote_sync_plan), 1) 454 | 455 | # Headline A 456 | assertObj = next(x for x in remote_sync_plan if x['item'] == indexes[1]) 457 | self.assertEqual(assertObj['action'], 'update') 458 | self.assertEqual(assertObj['changes'], ['links']) 459 | self.assertEqual(len(assertObj['item'].links), 2) 460 | self.assertEqual(assertObj['item'].links[0], m.TaskLink('https://anticode.ninja', '#anticode.ninja# blog')) 461 | self.assertEqual(assertObj['item'].links[1], m.TaskLink('https://github.com/anticodeninja/michel2', 'michel2', ['repo', 'github'])) 462 | 463 | 464 | if __name__ == '__main__': 465 | unittest.main() 466 | --------------------------------------------------------------------------------