├── .env ├── .gitignore ├── .pylintrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── alfred-mstodo-workflow.code-workspace ├── build ├── paths.py ├── subtasks.py └── tasks.py ├── changelog.md ├── mstodo.alfredworkflow ├── requirements-dev.txt ├── requirements.txt ├── screenshots ├── .DS_Store ├── alfred-mstodo-taskentry.gif ├── td-about.png ├── td-completed.png ├── td-due.png ├── td-folder new.png ├── td-prefs.png ├── td-search.png ├── td-search_by-list.png ├── td-search_hashtag.png ├── td-search_within-list.png ├── td-search_xfolder.png ├── td-task_complete.png ├── td-task_detail.png ├── td-upcoming.png ├── td-upcoming_duration-custom.png ├── td-upcoming_duration.png ├── td-welcome.png ├── td_due.png ├── td_new-within-list.png ├── td_new1.png ├── td_new2.png ├── td_new3.png ├── td_new4.png ├── td_new5.png ├── td_new6.png ├── td_new7.png ├── td_recurring.png └── tds.png ├── src ├── alfred_mstodo_workflow.py ├── bin │ └── launch_alfred.scpt ├── icons │ ├── .DS_Store │ ├── dark │ │ ├── account.png │ │ ├── back.png │ │ ├── calendar.png │ │ ├── cancel.png │ │ ├── checkmark.png │ │ ├── discuss.png │ │ ├── download.png │ │ ├── hashtag.png │ │ ├── help.png │ │ ├── hidden.png │ │ ├── inbox.png │ │ ├── info.png │ │ ├── link.png │ │ ├── list.png │ │ ├── list_new.png │ │ ├── next_week.png │ │ ├── open.png │ │ ├── paintbrush.png │ │ ├── preferences.png │ │ ├── radio.png │ │ ├── radio_selected.png │ │ ├── recurrence.png │ │ ├── reminder.png │ │ ├── search.png │ │ ├── sort.png │ │ ├── star.png │ │ ├── star_remove.png │ │ ├── sync.png │ │ ├── task.png │ │ ├── task_completed.png │ │ ├── today.png │ │ ├── tomorrow.png │ │ ├── trash.png │ │ ├── upcoming.png │ │ ├── visible.png │ │ └── yesterday.png │ ├── icon.png │ ├── light │ │ ├── account.png │ │ ├── back.png │ │ ├── calendar.png │ │ ├── cancel.png │ │ ├── checkmark.png │ │ ├── discuss.png │ │ ├── download.png │ │ ├── hashtag.png │ │ ├── help.png │ │ ├── hidden.png │ │ ├── inbox.png │ │ ├── info.png │ │ ├── link.png │ │ ├── list.png │ │ ├── list_new.png │ │ ├── next_week.png │ │ ├── open.png │ │ ├── paintbrush.png │ │ ├── preferences.png │ │ ├── radio.png │ │ ├── radio_selected.png │ │ ├── recurrence.png │ │ ├── reminder.png │ │ ├── search.png │ │ ├── sort.png │ │ ├── star.png │ │ ├── star_remove.png │ │ ├── sync.png │ │ ├── task.png │ │ ├── task_completed.png │ │ ├── today.png │ │ ├── tomorrow.png │ │ ├── trash.png │ │ ├── upcoming.png │ │ ├── visible.png │ │ └── yesterday.png │ └── script_filter_icons.psd ├── info.plist ├── logging_config.ini ├── mstodo │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── base.py │ │ ├── taskfolders.py │ │ ├── tasks.py │ │ └── user.py │ ├── auth.py │ ├── config.py │ ├── handlers │ │ ├── __init__.py │ │ ├── about.py │ │ ├── completed.py │ │ ├── due.py │ │ ├── login.py │ │ ├── logout.py │ │ ├── new_task.py │ │ ├── preferences.py │ │ ├── route.py │ │ ├── search.py │ │ ├── task.py │ │ ├── taskfolder.py │ │ ├── upcoming.py │ │ └── welcome.py │ ├── icons.py │ ├── models │ │ ├── __init__.py │ │ ├── base.py │ │ ├── fields.py │ │ ├── hashtag.py │ │ ├── preferences.py │ │ ├── task.py │ │ ├── task_parser.py │ │ ├── taskfolder.py │ │ └── user.py │ ├── sync.py │ └── util.py └── version └── tests ├── .DS_Store └── mstodo └── models └── test_task_parser.py /.env: -------------------------------------------------------------------------------- 1 | PYTHONPATH="./src:./lib" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.orig 4 | **/.DS_Store 5 | **/__pycache__ 6 | 7 | .venv 8 | .tmp 9 | .pytest_cache 10 | lib 11 | dist 12 | mstodo-*.alfredworkflow 13 | workflow.log 14 | workflow.log* 15 | pylint.json 16 | src/workflow 17 | 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug current file", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | }, 14 | { 15 | "name": "Custom command", 16 | "type": "python", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 19 | "console": "integratedTerminal", 20 | "args": [] 21 | }, 22 | { 23 | "name": "Simulate Welcome", 24 | "type": "python", 25 | "request": "launch", 26 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 27 | "console": "integratedTerminal", 28 | "args": [""] 29 | }, 30 | { 31 | "name": "Simulate About", 32 | "type": "python", 33 | "request": "launch", 34 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 35 | "console": "integratedTerminal", 36 | "args": ["about"], 37 | }, 38 | { 39 | "name": "Simulate Preferences", 40 | "type": "python", 41 | "request": "launch", 42 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 43 | "console": "integratedTerminal", 44 | "args": ["pref"], 45 | }, 46 | { 47 | "name": "Simulate Logout", 48 | "type": "python", 49 | "request": "launch", 50 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 51 | "console": "integratedTerminal", 52 | "args": ["logout"], 53 | }, 54 | { 55 | "name": "Simulate Login", 56 | "type": "python", 57 | "request": "launch", 58 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 59 | "console": "integratedTerminal", 60 | "args": ["", "--commit"], 61 | }, 62 | { 63 | "name": "Simulate Sync", 64 | "type": "python", 65 | "request": "launch", 66 | "program": "${workspaceFolder}/src/alfred_mstodo_workflow.py", 67 | "console": "integratedTerminal", 68 | "args": ["pref sync", "--commit"], 69 | } 70 | ] 71 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.extraPaths": [ 3 | "./src/workflow", 4 | "./lib" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 johandebeurs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Microsoft ToDo Workflow for Alfred (Beta) 2 | ========================== 3 | Work-in-progress [Alfred](http://www.alfredapp.com/) workflow for [Microsoft ToDo](http://todo.microsoft.com) (requires Alfred 5 with a Powerpack license, and Python3 installed on your system and available in $PATH). 4 | 5 | Beginner and advanced approaches to adding a monthly repeating task beginning the following week: 6 | 7 | ![Simple and advanced usage](screenshots/alfred-mstodo-taskentry.gif) 8 | 9 | ---------- 10 | 11 | | Jump to: | [Setup](#setup) | [Add Tasks](#add-tasks) | [Search and Browse Tasks](#search-and-browse-tasks) | [Editing Tasks](#editing-tasks) | [Hints](#hints) | 12 | | :------: | :-------------: | :---------------------: | :-------------------------------------------------: | :-----------------------------: | :-------------: | 13 | 14 | Setup 15 | ----- 16 | 17 | ### [Download here](https://raw.github.com/johandebeurs/alfred-mstodo-workflow/master/mstodo.alfredworkflow) 18 | 19 | After downloading, simply double-click to install the workflow in Alfred. Use the `td` command in Alfred to activate the workflow, or assign a hotkey in Alfred preferences. The workflow will guide you through securely logging in to Microsoft ToDo and will even let you know when an important update is available. 20 | 21 | Add tasks 22 | -------- 23 | 24 | The workflow provides an easy guided experience with tips along the way that will help you become a power user. 25 | 26 | The welcome screen appears when you've typed `td` (and nothing else). Special commands are in the form `td-command` with no space; once you type a space after `td ` you're in task entry mode. Partial commands are matched, so rather than typing `td-upcoming` to get to the Upcoming tasks list you can type as little as `td-up` or even `tdu`. 27 | 28 | ![Welcome screen](screenshots/td-welcome.png) 29 | 30 | ### Adding tasks with due dates and recurrence 31 | 32 | Add your first task! As you type, the workflow will pick out due dates and recurrence intervals in just about any format you could think of. Just write naturally, the due date, recurrence, and task text are updated in Alfred as you type. 33 | 34 | ![Task with due date and recurrence](screenshots/td_recurring.png) 35 | 36 | Use the menus to configure your task until you become a power user capable of typing everything manually. It's so worthwhile to be able to drop tasks into Microsoft ToDo in under a second. 37 | 38 | ![Due date menu](screenshots/td-due.png) 39 | 40 | ### Adding tasks to a specific list 41 | 42 | To select a list, type it first followed by a colon or use the Change list menu item. No need to type the full list name, as long as you see the correct list in Alfred a few letters is usually sufficient. You can also set a [default list](#default-list) or default to the most recently used list. 43 | 44 | ![List by substring matching](screenshots/td_new-within-list.png) 45 | 46 | You can also select a list *after* typing your task with the "in" keyword. To avoid false positives you will need to use all-caps in order to match a list by typing fewer than 3 characters. 47 | 48 | #### Examples 49 | 50 | > td h:Fix the broken step saturday morning* 51 | > 52 | > ![td h:Fix the broken step saturday morning*](screenshots/td_new7.png) 53 | > 54 | > **td Buy clicky keyboard in shopping due sat** 55 | > 56 | > ![Buy clicky keyboard in sho due sat](screenshots/td_new6.png) 57 | 58 | > **td Rearrange file cabinet tomorrow in WO** 59 | > 60 | > ![td Rearrange file cabinet tomorrow in WO](screenshots/td_new5.png) 61 | 62 | 63 | ### Reminders 64 | 65 | Microsoft ToDo uses alerts to remind you about tasks that are due, either on the due date or in advance. To set a reminder, either include a time with your due date or use an explicit reminder phrase like *remind me at 3:00pm on June 11*. 66 | 67 | #### Examples 68 | 69 | > **td Pay DoubleCash credit card bill monthly June 26th remind me June 22** 70 | > 71 | > ![td Pay DoubleCash credit card bill monthly June 26th remind me June 22](screenshots/td_new4.png) 72 | > 73 | > **td Make a New Year's resolution reminder: Jan 1 at midnight** 74 | > 75 | > ![td Make a New Year's resolution reminder: Jan 1 at midnight](screenshots/td_new3.png) 76 | > 77 | > **td weekly meeting notes r 8am due 1d** 78 | > 79 | > ![td weekly meeting notes r 8am due 1d](screenshots/td_new2.png) 80 | > 81 | > **td Ask about app icon at dinner tomorrow** 82 | > 83 | > ![td Ask about app icon at dinner tomorrow](screenshots/td_new1.png) 84 | 85 | #### When is the reminder? 86 | 87 | You can set a custom default reminder time from the workflow preferences screen, otherwise when a time is not specified the reminder will be set for 9am. 88 | 89 | | Reminder phrase includes | Task without due date | Task with due date | 90 | | ------------------------- | ------------------------------------------ | ---------------------------------------------- | 91 | | **Time only** | Reminder today at the specified time | Reminder on the due date at the specified time | 92 | | **Neither time nor date** | Today, 1 hour from the current time* | Default time (9am) on the due date** | 93 | | **Date and time** | Exact date and time entered | Exact date and time entered | 94 | | **Date only** | Default time (9am) on the specified date** | Default time (9am) on the specified date** | 95 | 96 | \* By default, reminders for the current day will be set to 1 hour from the current time. You can change this offset in the workflow preferences. 97 | 98 | \*\* The default time can be changed in the workflow preferences. If the specified date is today, your reminder date offset preference will be used instead. 99 | 100 | 101 | Search and browse tasks 102 | ----------------------- 103 | 104 | The `td-search` command allows you to search tasks by keyword or browse by list. To seach within a list, use the same *td-search My List: some query* syntax as when entering a task. 105 | 106 | #### Default search view 107 | ![search](screenshots/td-search.png) 108 | 109 | #### View a list 110 | ![view list](screenshots/td-search_by-list.png) 111 | 112 | #### Search within a list 113 | ![search list](screenshots/td-search_within-list.png) 114 | 115 | #### Search across all lists 116 | 117 | Your search will match against tasks as well as list names. 118 | 119 | ![search](screenshots/td-search_xfolder.png) 120 | 121 | #### Browse tasks by hashtag 122 | 123 | Type the hash symbol # to view and select a tag. 124 | 125 | ![hashtags](screenshots/td-search_hashtag.png) 126 | 127 | ### Upcoming tasks 128 | 129 | View upcoming tasks at `td-upcoming`. It's one half of the Planned list in Microsoft ToDo with the option to choose the duration that you prefer to look ahead (1 week, 2 weeks, 1 month, 3 days, whatever...). Like any other screen you can get there by typing as little as the first letter of the command: `tdu`: 130 | 131 | ![upcoming tasks](screenshots/td-upcoming.png) 132 | 133 | Browse or type to search your upcoming tasks. This screen can show upcoming tasks for any number of days with a few sensible defaults. Maybe there is someone out there who needs to see exactly 11 days ahead. 134 | 135 | ![upcoming duration](screenshots/td-upcoming_duration-custom.png) 136 | 137 | ### Due and overdue tasks 138 | 139 | The `td-due` command shows tasks that are due or overdue, similar the other half of the Planned list in Microsoft ToDo. By default it hoists any recurring tasks that are *multiple times overdue* to the top, but you can change the sort order. Sadly, I have quite a few tasks that are multiple times overdue, so this feature is mostly to keep me motivated but I hope others find it useful as well. 140 | 141 | ![due and overdue tasks](screenshots/td-due.png) 142 | 143 | This view is searchable, just type to filter the results by keyword. 144 | 145 | ### In sync 146 | 147 | The workflow stays in sync with Microsoft ToDo, so your lists and tasks will be up-to-date and searchable. The due and upcoming screens will sync (or wait for the already-running sync) *before showing results* to make sure that everything is up-to-date. A notification is displayed if there is something to sync so that you're not waiting around too long without any feedback. 148 | 149 | Editing tasks 150 | ------------- 151 | 152 | Tasks can be completed or deleted directly from the workflow. Simply find a task through the search, due, or overdue screens. Task editing is currently limited to completing and deleting tasks. 153 | 154 | ![edit a task](screenshots/td-task_complete.png) 155 | 156 | Overdue recurring tasks can be set due today to adjust the next occurrence by holding the alt key while marking a task complete. For example, if you are supposed to water the plants every 3 days but forget and do it 2 days late, you don't need to water them again the following day. 157 | 158 | ### View in Microsoft ToDo 159 | 160 | Any task can be opened in the Microsoft ToDo desktop app for further editing. This is a quick way to view notes, subtasks, assignees, and other features that are not yet supported in the workflow. Microsoft doesn't seem to support linking directly to a task so the closest I have found is to trigger a search on task title, which is good enough for an MVP. 161 | 162 | You can also open a new task in Microsoft ToDo by holding down the alt key when creating the task. 163 | 164 | ![view in Microsoft ToDo](screenshots/td-task_detail.png) 165 | 166 | Hints 167 | ----- 168 | 169 | Read the text below each menu option and you'll be on your way to power user status – most menu items include helpful tips about how to apply a setting without navigating the menu. 170 | 171 | ### Command shorthand 172 | 173 | Commands like `td:list` and `td:pref` have been changed to `td-list` and `td-pref` to allow alt+delete to return you to the welcome screen (any non-word character is fine, I just chose `-` for its word breaking properties). Furthermore, these commands can be triggered with as little as the first letter. `tdd` will get you to the `td-due` screen and `tds` will get you to `td-search`. For this reason, you may noticed that top-level commands are first-letter-distinct to avoid conflicts. 174 | 175 | ### Default list 176 | 177 | There is an option in `td-pref` to set a list other than Tasks as the default when entering tasks. This will save keystrokes when entering a large number of tasks into a list or when a custom list is preferred over Tasks. You can also elect to use the previously-used list to facilitate entry of multiple tasks in the same list. 178 | 179 | ![default list](screenshots/td-prefs.png) 180 | 181 | ### Changelog 182 | 183 | If you notice any problems or want to see what changed in the latest version, jump to the *About* screen from the main menu or type `td-about`. 184 | 185 | ![About screen](screenshots/td-about.png) 186 | 187 | ### Experimental updates 188 | 189 | Those who want to help test the newest features of the workflow can enable experimental updates in the `td-pref` screen. When enabled, the workflow will prompt you to update to alpha and beta releases for the next major version. Note that these may be unstable and feedback is always appreciated if something goes wrong. 190 | 191 | If you are currently using an experimental version the workflow will always prompt you to update to the latest experimental update regardless of this setting. Since fixes are common and often very important during this early stage of development it would not be good to allow old beta versions to continue misbehaving. 192 | 193 | Security 194 | -------- 195 | 196 | Your Microsoft ToDo password is never made available to the workflow or stored in any way. Instead, when you log in through the Microsoft ToDo portal you are asked to authorise the workflow to access your account. 197 | 198 | You can log out at any time through the `td-pref` preferences screen. Upon logging out, all caches, synced data, and workflow preferences are removed. To revert to the default workflow settings simply log out then log back in. 199 | 200 | Limitations 201 | ----------- 202 | 203 | * No offline mode – the workflow must be able to connect the the API for each change you make; currently changes made while offline are not saved. 204 | * Languages and date formats – the workflow only officially supports US English at this time. parsedatetime provides US English, UK English, Dutch, German, Portuguese, Russian, and Spanish with varying coverage of keywords (e.g. tomorrow, Tuesday) in each language; your mileage may vary with these languages. 205 | 206 | Contributing 207 | ------------ 208 | 209 | So you want to help make this workflow better? That's great! After cloning the repository, activate a virtual environment (I recommend using `python -m venv .venv && source .venv/bin/activate`), then run `pip install -r requirements.txt --target=./lib` and `pip install -r requirements-dev.txt` to set up the environment for building. Running `invoke -r build build --initial` will build the workflow for development. Open the _mstodo-symlinked.alfredworkflow_ file to install a copy in Alfred that will update whenever you rebuild the workflow. Run `invoke -r build monitor` to monitor the /src folder and automatically re-build the workflow on any file changes. Using this process, the workflow is kept up-to-date while you work. 210 | 211 | Settings and launch configs for VSCode are included in the git repo to smooth development and testing if you are using this IDE, including altering pythonpath for debugging. You may want to replicate/modify these if you are using Pycharm or other editors. 212 | 213 | Always run through the tests to ensure that your change does not cause issues elsewhere. When possible, add corresponding tests for your contributions. 214 | 215 | Testing 216 | ------- 217 | 218 | Unit tests should be run before committing to reduce the likelihood of introducing a bug. Your feedback is crucial if anything seems to be broken. 219 | 220 | Contributors can use the command `invoke -r build test` to run the test suite and should do so to validate changes in any pull requests. If you add functionality, please back it with unit tests. 221 | 222 | Acknowledgements 223 | ---------------- 224 | 225 | This workflow is a re-write of [Alfred-Wunderlist](http://github.com/idpaterson/alfred-wunderlist-workflow) by [Ian Paterson](https://github.com/idpaterson), updated for Python 3, MacOS 13 (Ventura) and Alfred 5. 226 | 227 | Much of the natural language date processing is powered by [parsedatetime](https://github.com/bear/parsedatetime), a tremendously powerful date parser built by [Mike Taylor](https://github.com/bear) and various contributors. [Peewee](https://github.com/coleifer/peewee) by [Charles Leifer](https://github.com/coleifer) provides a simple interface to store and query synced data retrieved from Microsoft ToDo using [Requests](https://github.com/kennethreitz/requests) by [Kenneth Reitz](https://github.com/kennethreitz). The source code of all three libraries is bundled with the workflow and each is included in the repository as a submodule. -------------------------------------------------------------------------------- /alfred-mstodo-workflow.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /build/paths.py: -------------------------------------------------------------------------------- 1 | # Directory names 2 | # for consistent directories across app, .tmp, www, and dist 3 | app_dirname = "src" 4 | bin_dirname = "bin" 5 | lib_dirname = "lib" 6 | dist_dirname = "dist" 7 | icons_dirname = "icons" 8 | 9 | # Source code 10 | app = app_dirname 11 | app_bin = app + "/" + bin_dirname 12 | app_lib = lib_dirname 13 | app_module = app + "/mstodo" 14 | app_icons = app + "/" + icons_dirname 15 | 16 | tests = "tests" 17 | 18 | # Built distribution 19 | dist = dist_dirname 20 | 21 | dist_app = dist + "/workflow" 22 | dist_bin = dist_app + "/" + bin_dirname 23 | dist_lib = dist_app 24 | dist_icons = dist_app + "/" + icons_dirname 25 | 26 | # Final binaries, make them easy to find in the repo root 27 | dist_workflow = "mstodo.alfredworkflow" 28 | dist_workflow_symlinked = "mstodo-symlinked.alfredworkflow" 29 | 30 | # Temporary paths 31 | tmp = ".tmp" 32 | tmp_img = tmp + "/" + icons_dirname 33 | tmp_workflow_symlinked = tmp + "/workflow-symlinked" -------------------------------------------------------------------------------- /build/subtasks.py: -------------------------------------------------------------------------------- 1 | import paths 2 | import os, sys 3 | import glob 4 | from invoke import task 5 | 6 | sys.path.append(os.path.join(sys.path[0],'../src')) # enables imports from src/__init__.py. @TODO see if this can be improved 7 | 8 | @task 9 | def clean(c): 10 | print(" Removing distribution and temp files...") 11 | c.run(f"rm -rf {paths.dist}") 12 | c.run(f"rm -rf {paths.tmp}") 13 | c.run(f"rm -f {paths.dist_workflow}") 14 | c.run(f"rm -f {paths.dist_workflow_symlinked}") 15 | return 16 | 17 | @task 18 | def minify(c, changed_files=None): 19 | # copies and minifies /src/icons/icon.png into /dist/workflow/icon.png 20 | # copies and minifies /src/icons/*/**/*.png into /dist/workflow/icons/**/.png 21 | c.run("mkdir -p {}/{{dark,light}}".format(paths.tmp_img)) 22 | images = glob.glob(f"{paths.app_icons}/**/*.png", recursive=True) 23 | if changed_files: images = list(set(images) & set(changed_files)) 24 | print(" Compressing {}images...".format(str(len(images)) + " ")) 25 | for img in images: 26 | dest = paths.tmp_img + img.removeprefix(paths.app_icons) 27 | c.run(f"sips -o {dest} -Z 100 {img} >/dev/null") 28 | return 29 | 30 | @task() 31 | def copy(c, changed_files=None): 32 | print(" Copying app files for distribution...") 33 | icons = glob.glob(f"{paths.tmp_img}/**/*.png", recursive=True) 34 | if changed_files: icons = list(set(icons) & set(changed_files)) 35 | for icon in icons: 36 | if icon == f"{paths.tmp_img}/icon.png": 37 | dest = f"{paths.dist_app}/icon.png" 38 | else: 39 | dest = paths.dist_icons + icon.removeprefix(paths.tmp_img) 40 | c.run(f"ditto {icon} {dest}") 41 | 42 | # Copy *.scpt in paths.app_bin to paths.dist_bin 43 | bin_files = glob.glob(f"{paths.app_bin}/*.scpt") 44 | if changed_files: bin_files = list(set(bin_files) & set(changed_files)) 45 | for bin_file in bin_files: 46 | dest = paths.dist_bin + bin_file.removeprefix(paths.app_bin) 47 | c.run(f"ditto {bin_file} {dest}") 48 | 49 | app_files = glob.glob(f"{paths.app_module}/**/*.py", recursive=True) 50 | app_files.extend(glob.glob(f"{paths.app}/*.py")) 51 | app_files.extend(glob.glob(f"{paths.app}/*.ini")) 52 | app_files.extend(glob.glob(f"{paths.app}/version")) 53 | if changed_files: app_files = list(set(app_files) & set(changed_files)) 54 | for app_file in app_files: 55 | dest = paths.dist_app + app_file.removeprefix(paths.app_dirname) 56 | c.run(f"ditto {app_file} {dest}") 57 | 58 | print(" Copied {}files...".format(str(len(icons) + len(bin_files) + len(app_files)) + " ")) 59 | return 60 | 61 | @task() 62 | def copy_libs(c, initial=False): 63 | print(" Copying module dependencies...") 64 | lib_files = [] 65 | lib_files.extend(glob.glob(f"{paths.app_lib}/**/*.py", recursive=True)) 66 | lib_files.extend(glob.glob(f"{paths.app_lib}/**/*.pem", recursive=True)) 67 | lib_files.extend(glob.glob(f"{paths.app_lib}/**/version")) 68 | lib_files.sort() 69 | for lib_file in lib_files: 70 | c.run(f"ditto {lib_file} {paths.dist_lib}{str(lib_file).removeprefix(paths.app_lib)}") 71 | if initial: 72 | print(" Cloning alfred workflow into /src") 73 | for workflow_file in glob.glob(f"{paths.app_lib}/workflow/*", recursive=True): 74 | c.run(f"ditto {workflow_file} {paths.app}{str(workflow_file).removeprefix(paths.app_lib)}") 75 | return 76 | 77 | @task() 78 | def symlink(c): 79 | print(" Creating symbolic links for dev workflow...") 80 | # take relevant files in cwd/paths.dist_app and symlink to paths.tmp_workflow_symlinked 81 | c.run(f"mkdir -p {paths.tmp_workflow_symlinked}") 82 | targets = [ 83 | 'alfred_mstodo_workflow.py', 84 | 'bin', 85 | 'icon.png', 86 | 'icons', 87 | 'info.plist', 88 | 'logging_config.ini', 89 | 'mstodo', 90 | 'workflow', 91 | 'version' 92 | ] 93 | symlink_items = [] 94 | for target in targets: 95 | symlink_items.extend(glob.glob(f"{paths.dist_app}/{target}")) 96 | for item in symlink_items: 97 | target = os.path.abspath(item) 98 | dest = paths.tmp_workflow_symlinked + item.removeprefix(paths.dist_app) 99 | c.run(f"ln -sfhF {target} {dest}") 100 | return 101 | 102 | @task() 103 | def replace(c): 104 | import re 105 | from mstodo import get_version, get_github_slug 106 | print(" Copying Alfred .plist file and replacing placeholders...") 107 | changelog = re.escape(open('./changelog.md').read()) .replace("'","\'") # replace ' given challenges with passing string via c.run 108 | c.run(f"ditto {paths.app}/info.plist {paths.dist_app}") 109 | with c.cd(paths.dist_app): 110 | c.run("sed -i '' 's|__changelog__|{}|g' info.plist".format(changelog)) 111 | c.run("""sed -i "" "s|\\'|'|g" info.plist""") 112 | c.run(f"sed -i '' 's#__version__#{get_version()}#g' info.plist") 113 | c.run(f"sed -i '' 's#__githubslug__#{get_github_slug()}#g' info.plist") 114 | return 115 | 116 | @task(pre=[symlink, replace]) 117 | def package_workflow(c, rebuild=False, env=''): 118 | print(f" Creating Alfred{' ' + env if env else ''} workflow...") 119 | flag = '-r -FSq' if rebuild else '-rq' 120 | if env == '': 121 | # build the workflow by zipping dist/workflow/**/* into /mstodo.alfredworkflow 122 | with c.cd(paths.dist_app): 123 | c.run(f"zip -9 {flag} {paths.dist_workflow} .") 124 | c.run(f"mv {paths.dist_workflow} ../..") 125 | elif env == "dev": 126 | # Build dev workflow 127 | with c.cd(paths.tmp_workflow_symlinked): 128 | c.run(f"zip --symlinks {flag} {paths.dist_workflow_symlinked} .") 129 | c.run(f"mv {paths.dist_workflow_symlinked} ../..") 130 | return 131 | 132 | @task 133 | def pylint(c): 134 | c.run(f"pylint {paths.app} --output-format=json:pylint.json,colorized") 135 | return 136 | 137 | @task 138 | def test(c): 139 | print(" Running tests...") 140 | with c.cd(f"{paths.dist_app}"): 141 | c.run(f"PYTHONPATH=. py.test ../../{paths.tests} --cov-report term-missing --cov mstodo") 142 | return 143 | 144 | @task 145 | def release(c): 146 | import re 147 | from mstodo import get_version, get_github_slug 148 | print(" Creating release") 149 | version = get_version() 150 | title = re.escape(open('./changelog.md').read().splitlines()[0].removeprefix('# ')) 151 | c.run(f"cp ./changelog.md {paths.tmp}/changelog.md") 152 | with c.cd(paths.tmp): 153 | c.run(f"sed -i '' '1,2d' changelog.md") 154 | c.run(f"sed -i '' 's#__version__#{version}#g' changelog.md") 155 | c.run(f"sed -i '' 's#__githubslug__#{get_github_slug()}#g' changelog.md") 156 | 157 | release_cmd = f"gh release create {version} {paths.dist_workflow} --title {title} --notes-file {paths.tmp}/changelog.md" 158 | if '-' in version: 159 | release_cmd = release_cmd + ' --prerelease' 160 | c.run(release_cmd) -------------------------------------------------------------------------------- /build/tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | from invoke import Collection, task 3 | import subtasks 4 | 5 | @task 6 | def clean(c): 7 | subtasks.clean(c) 8 | 9 | @task(pre=[clean]) 10 | def build(c, initial=False): 11 | print("Running build sequence:") 12 | subtasks.minify(c) 13 | subtasks.copy_libs(c, initial=initial) 14 | subtasks.copy(c) 15 | subtasks.replace(c) 16 | subtasks.symlink(c) 17 | subtasks.package_workflow(c) 18 | subtasks.package_workflow(c, env="dev") 19 | 20 | @task 21 | def monitor(c, changed_files=None): 22 | # if there are changes in the /src directory, then re-run the build-dev tasks 23 | # this means re-copy relevant files to destination and recreate the workflow 24 | print("Watching files for changes:") 25 | if changed_files is None: 26 | from watchfiles import watch 27 | for changes in watch('./src', './screenshots','./changelog.md', './README.md'): 28 | changed_files = [change[1].removeprefix(os.getcwd() + '/') for change in changes] # unpacks the set of FileChanges into a list of absolute paths 29 | monitor(c, changed_files=changed_files) 30 | 31 | subtasks.minify(c,changed_files=changed_files) 32 | subtasks.copy(c,changed_files=changed_files) 33 | subtasks.replace(c) 34 | subtasks.package_workflow(c, rebuild=True) 35 | return 36 | 37 | @task(pre=[build]) 38 | def release(c): 39 | print("Releasing version onto github:") 40 | subtasks.release(c) 41 | 42 | @task 43 | def test(c): 44 | print("Running Pytest") 45 | subtasks.test(c) 46 | 47 | namespace = Collection(clean, build, monitor, release, test, subtasks) 48 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Python3 refactor 2 | 3 | New in version __version__: 4 | * Bumped Requests version for security 5 | 6 | For more details: 7 | https://github.com/__githubslug__/releases/tag/__version__ -------------------------------------------------------------------------------- /mstodo.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/mstodo.alfredworkflow -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest==7.3.1 2 | invoke==2.2.0 3 | pylint==2.17.3 4 | watchfiles==0.19.0 5 | pytest-cov==4.0.0 6 | pytest-mock==3.10.0 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alfred-pyworkflow==2.0.0b2 2 | msal==1.22.0 3 | parsedatetime==2.6 4 | peewee==3.16.2 5 | requests==2.31.0 6 | python-dateutil==2.8.2 -------------------------------------------------------------------------------- /screenshots/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/.DS_Store -------------------------------------------------------------------------------- /screenshots/alfred-mstodo-taskentry.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/alfred-mstodo-taskentry.gif -------------------------------------------------------------------------------- /screenshots/td-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-about.png -------------------------------------------------------------------------------- /screenshots/td-completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-completed.png -------------------------------------------------------------------------------- /screenshots/td-due.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-due.png -------------------------------------------------------------------------------- /screenshots/td-folder new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-folder new.png -------------------------------------------------------------------------------- /screenshots/td-prefs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-prefs.png -------------------------------------------------------------------------------- /screenshots/td-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-search.png -------------------------------------------------------------------------------- /screenshots/td-search_by-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-search_by-list.png -------------------------------------------------------------------------------- /screenshots/td-search_hashtag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-search_hashtag.png -------------------------------------------------------------------------------- /screenshots/td-search_within-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-search_within-list.png -------------------------------------------------------------------------------- /screenshots/td-search_xfolder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-search_xfolder.png -------------------------------------------------------------------------------- /screenshots/td-task_complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-task_complete.png -------------------------------------------------------------------------------- /screenshots/td-task_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-task_detail.png -------------------------------------------------------------------------------- /screenshots/td-upcoming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-upcoming.png -------------------------------------------------------------------------------- /screenshots/td-upcoming_duration-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-upcoming_duration-custom.png -------------------------------------------------------------------------------- /screenshots/td-upcoming_duration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-upcoming_duration.png -------------------------------------------------------------------------------- /screenshots/td-welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td-welcome.png -------------------------------------------------------------------------------- /screenshots/td_due.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_due.png -------------------------------------------------------------------------------- /screenshots/td_new-within-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new-within-list.png -------------------------------------------------------------------------------- /screenshots/td_new1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new1.png -------------------------------------------------------------------------------- /screenshots/td_new2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new2.png -------------------------------------------------------------------------------- /screenshots/td_new3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new3.png -------------------------------------------------------------------------------- /screenshots/td_new4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new4.png -------------------------------------------------------------------------------- /screenshots/td_new5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new5.png -------------------------------------------------------------------------------- /screenshots/td_new6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new6.png -------------------------------------------------------------------------------- /screenshots/td_new7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_new7.png -------------------------------------------------------------------------------- /screenshots/td_recurring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/td_recurring.png -------------------------------------------------------------------------------- /screenshots/tds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/screenshots/tds.png -------------------------------------------------------------------------------- /src/alfred_mstodo_workflow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # encoding: utf-8 3 | 4 | import sys 5 | import logging 6 | from os import path 7 | from logging.config import fileConfig 8 | 9 | ROOT_DIR = path.dirname(path.abspath(__file__)) 10 | fileConfig(path.join(ROOT_DIR,"logging_config.ini")) 11 | log = logging.getLogger('mstodo') 12 | 13 | def main(workflow): 14 | route(workflow.args) 15 | log.info(f"Workflow response complete with args {workflow.args}") 16 | 17 | if __name__ == '__main__': 18 | from mstodo.util import wf_wrapper 19 | from mstodo.handlers.route import route 20 | 21 | wf = wf_wrapper() 22 | sys.exit(wf.run(main, text_errors='--commit' in wf.args)) 23 | -------------------------------------------------------------------------------- /src/bin/launch_alfred.scpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/bin/launch_alfred.scpt -------------------------------------------------------------------------------- /src/icons/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/.DS_Store -------------------------------------------------------------------------------- /src/icons/dark/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/account.png -------------------------------------------------------------------------------- /src/icons/dark/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/back.png -------------------------------------------------------------------------------- /src/icons/dark/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/calendar.png -------------------------------------------------------------------------------- /src/icons/dark/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/cancel.png -------------------------------------------------------------------------------- /src/icons/dark/checkmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/checkmark.png -------------------------------------------------------------------------------- /src/icons/dark/discuss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/discuss.png -------------------------------------------------------------------------------- /src/icons/dark/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/download.png -------------------------------------------------------------------------------- /src/icons/dark/hashtag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/hashtag.png -------------------------------------------------------------------------------- /src/icons/dark/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/help.png -------------------------------------------------------------------------------- /src/icons/dark/hidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/hidden.png -------------------------------------------------------------------------------- /src/icons/dark/inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/inbox.png -------------------------------------------------------------------------------- /src/icons/dark/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/info.png -------------------------------------------------------------------------------- /src/icons/dark/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/link.png -------------------------------------------------------------------------------- /src/icons/dark/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/list.png -------------------------------------------------------------------------------- /src/icons/dark/list_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/list_new.png -------------------------------------------------------------------------------- /src/icons/dark/next_week.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/next_week.png -------------------------------------------------------------------------------- /src/icons/dark/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/open.png -------------------------------------------------------------------------------- /src/icons/dark/paintbrush.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/paintbrush.png -------------------------------------------------------------------------------- /src/icons/dark/preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/preferences.png -------------------------------------------------------------------------------- /src/icons/dark/radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/radio.png -------------------------------------------------------------------------------- /src/icons/dark/radio_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/radio_selected.png -------------------------------------------------------------------------------- /src/icons/dark/recurrence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/recurrence.png -------------------------------------------------------------------------------- /src/icons/dark/reminder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/reminder.png -------------------------------------------------------------------------------- /src/icons/dark/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/search.png -------------------------------------------------------------------------------- /src/icons/dark/sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/sort.png -------------------------------------------------------------------------------- /src/icons/dark/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/star.png -------------------------------------------------------------------------------- /src/icons/dark/star_remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/star_remove.png -------------------------------------------------------------------------------- /src/icons/dark/sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/sync.png -------------------------------------------------------------------------------- /src/icons/dark/task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/task.png -------------------------------------------------------------------------------- /src/icons/dark/task_completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/task_completed.png -------------------------------------------------------------------------------- /src/icons/dark/today.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/today.png -------------------------------------------------------------------------------- /src/icons/dark/tomorrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/tomorrow.png -------------------------------------------------------------------------------- /src/icons/dark/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/trash.png -------------------------------------------------------------------------------- /src/icons/dark/upcoming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/upcoming.png -------------------------------------------------------------------------------- /src/icons/dark/visible.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/visible.png -------------------------------------------------------------------------------- /src/icons/dark/yesterday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/dark/yesterday.png -------------------------------------------------------------------------------- /src/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/icon.png -------------------------------------------------------------------------------- /src/icons/light/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/account.png -------------------------------------------------------------------------------- /src/icons/light/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/back.png -------------------------------------------------------------------------------- /src/icons/light/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/calendar.png -------------------------------------------------------------------------------- /src/icons/light/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/cancel.png -------------------------------------------------------------------------------- /src/icons/light/checkmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/checkmark.png -------------------------------------------------------------------------------- /src/icons/light/discuss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/discuss.png -------------------------------------------------------------------------------- /src/icons/light/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/download.png -------------------------------------------------------------------------------- /src/icons/light/hashtag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/hashtag.png -------------------------------------------------------------------------------- /src/icons/light/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/help.png -------------------------------------------------------------------------------- /src/icons/light/hidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/hidden.png -------------------------------------------------------------------------------- /src/icons/light/inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/inbox.png -------------------------------------------------------------------------------- /src/icons/light/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/info.png -------------------------------------------------------------------------------- /src/icons/light/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/link.png -------------------------------------------------------------------------------- /src/icons/light/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/list.png -------------------------------------------------------------------------------- /src/icons/light/list_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/list_new.png -------------------------------------------------------------------------------- /src/icons/light/next_week.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/next_week.png -------------------------------------------------------------------------------- /src/icons/light/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/open.png -------------------------------------------------------------------------------- /src/icons/light/paintbrush.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/paintbrush.png -------------------------------------------------------------------------------- /src/icons/light/preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/preferences.png -------------------------------------------------------------------------------- /src/icons/light/radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/radio.png -------------------------------------------------------------------------------- /src/icons/light/radio_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/radio_selected.png -------------------------------------------------------------------------------- /src/icons/light/recurrence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/recurrence.png -------------------------------------------------------------------------------- /src/icons/light/reminder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/reminder.png -------------------------------------------------------------------------------- /src/icons/light/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/search.png -------------------------------------------------------------------------------- /src/icons/light/sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/sort.png -------------------------------------------------------------------------------- /src/icons/light/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/star.png -------------------------------------------------------------------------------- /src/icons/light/star_remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/star_remove.png -------------------------------------------------------------------------------- /src/icons/light/sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/sync.png -------------------------------------------------------------------------------- /src/icons/light/task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/task.png -------------------------------------------------------------------------------- /src/icons/light/task_completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/task_completed.png -------------------------------------------------------------------------------- /src/icons/light/today.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/today.png -------------------------------------------------------------------------------- /src/icons/light/tomorrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/tomorrow.png -------------------------------------------------------------------------------- /src/icons/light/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/trash.png -------------------------------------------------------------------------------- /src/icons/light/upcoming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/upcoming.png -------------------------------------------------------------------------------- /src/icons/light/visible.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/visible.png -------------------------------------------------------------------------------- /src/icons/light/yesterday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/light/yesterday.png -------------------------------------------------------------------------------- /src/icons/script_filter_icons.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/icons/script_filter_icons.psd -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.johandebeurs.alfred.mstodo 7 | category 8 | Productivity 9 | connections 10 | 11 | 06482847-7F21-4C57-88BB-B642E0182781 12 | 13 | 14 | destinationuid 15 | 0BD5A678-C27A-4DEC-8170-B71A675AF94E 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | destinationuid 25 | 0F5DDFFB-8D34-43DE-8652-8FC47EEDEBFD 26 | modifiers 27 | 0 28 | modifiersubtext 29 | 30 | vitoclose 31 | 32 | 33 | 34 | 0BD5A678-C27A-4DEC-8170-B71A675AF94E 35 | 36 | 37 | destinationuid 38 | 942CDC8D-C24D-4DB7-B085-A1F18CF934F7 39 | modifiers 40 | 0 41 | modifiersubtext 42 | 43 | vitoclose 44 | 45 | 46 | 47 | 0F2D229C-18BA-4E40-A81D-D796A6E159BC 48 | 49 | 50 | destinationuid 51 | 39540B6D-5AA4-4427-BA63-42256009EE5F 52 | modifiers 53 | 524288 54 | modifiersubtext 55 | No special behavior for the Alt key 56 | vitoclose 57 | 58 | 59 | 60 | destinationuid 61 | E0AE4F8B-253B-4EFD-B568-E9A2A46AA0C7 62 | modifiers 63 | 0 64 | modifiersubtext 65 | 66 | vitoclose 67 | 68 | 69 | 70 | destinationuid 71 | B17D3A8B-97D1-4CB1-AA34-48754A1CC6F3 72 | modifiers 73 | 1048576 74 | modifiersubtext 75 | No special behavior for the Cmd key 76 | vitoclose 77 | 78 | 79 | 80 | destinationuid 81 | 6B21F0AC-FBEB-4269-A007-F3FB0A8D11DE 82 | modifiers 83 | 262144 84 | modifiersubtext 85 | No special behavior for the Ctrl key 86 | vitoclose 87 | 88 | 89 | 90 | 39540B6D-5AA4-4427-BA63-42256009EE5F 91 | 92 | 93 | destinationuid 94 | A28E28AD-30A4-4578-87C2-E258E3948B63 95 | modifiers 96 | 0 97 | modifiersubtext 98 | 99 | vitoclose 100 | 101 | 102 | 103 | 40D1F3AB-A7A4-487C-A0DD-EEB4DCB4A527 104 | 105 | 106 | destinationuid 107 | E05D8886-A5A2-4CD7-9C9D-6D8F3CDDEE3F 108 | modifiers 109 | 0 110 | modifiersubtext 111 | 112 | vitoclose 113 | 114 | 115 | 116 | destinationuid 117 | 0BD5A678-C27A-4DEC-8170-B71A675AF94E 118 | modifiers 119 | 0 120 | modifiersubtext 121 | 122 | vitoclose 123 | 124 | 125 | 126 | 6B21F0AC-FBEB-4269-A007-F3FB0A8D11DE 127 | 128 | 129 | destinationuid 130 | A28E28AD-30A4-4578-87C2-E258E3948B63 131 | modifiers 132 | 0 133 | modifiersubtext 134 | 135 | vitoclose 136 | 137 | 138 | 139 | B17D3A8B-97D1-4CB1-AA34-48754A1CC6F3 140 | 141 | 142 | destinationuid 143 | A28E28AD-30A4-4578-87C2-E258E3948B63 144 | modifiers 145 | 0 146 | modifiersubtext 147 | 148 | vitoclose 149 | 150 | 151 | 152 | CB67A7A4-2A1F-4905-990C-5BD07FD51C1C 153 | 154 | 155 | destinationuid 156 | 0F2D229C-18BA-4E40-A81D-D796A6E159BC 157 | modifiers 158 | 0 159 | modifiersubtext 160 | 161 | vitoclose 162 | 163 | 164 | 165 | E0AE4F8B-253B-4EFD-B568-E9A2A46AA0C7 166 | 167 | 168 | destinationuid 169 | A28E28AD-30A4-4578-87C2-E258E3948B63 170 | modifiers 171 | 0 172 | modifiersubtext 173 | 174 | vitoclose 175 | 176 | 177 | 178 | 179 | createdby 180 | Johan de Beurs 181 | description 182 | Create tasks and lists in Microsoft ToDo 183 | disabled 184 | 185 | name 186 | Microsoft ToDo 187 | objects 188 | 189 | 190 | config 191 | 192 | concurrently 193 | 194 | escaping 195 | 102 196 | script 197 | export LC_CTYPE="$(defaults read -g AppleLocale).UTF-8" 198 | /usr/bin/env python3 -O alfred_mstodo_workflow.py "{query}" --commit --alt 199 | scriptargtype 200 | 0 201 | scriptfile 202 | 203 | type 204 | 0 205 | 206 | type 207 | alfred.workflow.action.script 208 | uid 209 | 39540B6D-5AA4-4427-BA63-42256009EE5F 210 | version 211 | 2 212 | 213 | 214 | config 215 | 216 | concurrently 217 | 218 | escaping 219 | 102 220 | script 221 | export LC_CTYPE="$(defaults read -g AppleLocale).UTF-8" 222 | /usr/bin/env python3 -O alfred_mstodo_workflow.py "{query}" --commit 223 | scriptargtype 224 | 0 225 | scriptfile 226 | 227 | type 228 | 0 229 | 230 | type 231 | alfred.workflow.action.script 232 | uid 233 | E0AE4F8B-253B-4EFD-B568-E9A2A46AA0C7 234 | version 235 | 2 236 | 237 | 238 | config 239 | 240 | action 241 | 0 242 | argument 243 | 0 244 | focusedappvariable 245 | 246 | focusedappvariablename 247 | 248 | hotkey 249 | 0 250 | hotmod 251 | 0 252 | leftcursor 253 | 254 | modsmode 255 | 0 256 | relatedAppsMode 257 | 0 258 | 259 | type 260 | alfred.workflow.trigger.hotkey 261 | uid 262 | CB67A7A4-2A1F-4905-990C-5BD07FD51C1C 263 | version 264 | 2 265 | 266 | 267 | config 268 | 269 | lastpathcomponent 270 | 271 | onlyshowifquerypopulated 272 | 273 | removeextension 274 | 275 | text 276 | {query} 277 | title 278 | Microsoft ToDo 279 | 280 | type 281 | alfred.workflow.output.notification 282 | uid 283 | A28E28AD-30A4-4578-87C2-E258E3948B63 284 | version 285 | 1 286 | 287 | 288 | config 289 | 290 | alfredfiltersresults 291 | 292 | alfredfiltersresultsmatchmode 293 | 0 294 | argumenttrimmode 295 | 0 296 | argumenttype 297 | 1 298 | escaping 299 | 102 300 | keyword 301 | td 302 | queuedelaycustom 303 | 3 304 | queuedelayimmediatelyinitially 305 | 306 | queuedelaymode 307 | 0 308 | queuemode 309 | 2 310 | runningsubtext 311 | Preparing workflow 312 | script 313 | export LC_CTYPE="$(defaults read -g AppleLocale).UTF-8" 314 | echo "{query}" > .query 315 | /usr/bin/env python3 -O alfred_mstodo_workflow.py "{query}" 316 | scriptargtype 317 | 0 318 | scriptfile 319 | 320 | subtext 321 | Type to to control ToDo 322 | title 323 | Microsoft ToDo 324 | type 325 | 0 326 | withspace 327 | 328 | 329 | type 330 | alfred.workflow.input.scriptfilter 331 | uid 332 | 0F2D229C-18BA-4E40-A81D-D796A6E159BC 333 | version 334 | 2 335 | 336 | 337 | config 338 | 339 | concurrently 340 | 341 | escaping 342 | 102 343 | script 344 | export LC_CTYPE="$(defaults read -g AppleLocale).UTF-8" 345 | /usr/bin/env python3 -O alfred_mstodo_workflow.py "{query}" --commit --cmd 346 | scriptargtype 347 | 0 348 | scriptfile 349 | 350 | type 351 | 0 352 | 353 | type 354 | alfred.workflow.action.script 355 | uid 356 | B17D3A8B-97D1-4CB1-AA34-48754A1CC6F3 357 | version 358 | 2 359 | 360 | 361 | config 362 | 363 | concurrently 364 | 365 | escaping 366 | 102 367 | script 368 | export LC_CTYPE="$(defaults read -g AppleLocale).UTF-8" 369 | /usr/bin/env python3 -O alfred_mstodo_workflow.py "{query}" --commit --ctrl 370 | scriptargtype 371 | 0 372 | scriptfile 373 | 374 | type 375 | 0 376 | 377 | type 378 | alfred.workflow.action.script 379 | uid 380 | 6B21F0AC-FBEB-4269-A007-F3FB0A8D11DE 381 | version 382 | 2 383 | 384 | 385 | config 386 | 387 | concurrently 388 | 389 | escaping 390 | 102 391 | script 392 | version=${alfred_version:0:1} 393 | cd ~/Library/Application\ Support/Alfred\ $version/Workflow\ Data/com.johandebeurs.alfred.mstodo/ && rm -f lists.* .lists.* *.lock mstodo.* 394 | rm -rf ~/Library/Caches/com.runningwithcrayons.Alfred-$version/Workflow\ Data/com.johandebeurs.alfred.mstodo/ 395 | cd ~/Library/Application\ Support/Alfred/Workflow\ Data/com.johandebeurs.alfred.mstodo/ && rm -f lists.* .lists.* *.lock mstodo.* 396 | rm -rf ~/Library/Caches/com.runningwithcrayons.Alfred/Workflow\ Data/com.johandebeurs.alfred.mstodo/ 397 | scriptargtype 398 | 0 399 | scriptfile 400 | 401 | type 402 | 0 403 | 404 | type 405 | alfred.workflow.action.script 406 | uid 407 | E05D8886-A5A2-4CD7-9C9D-6D8F3CDDEE3F 408 | version 409 | 2 410 | 411 | 412 | config 413 | 414 | argumenttype 415 | 2 416 | keyword 417 | _td-reset 418 | subtext 419 | Clears stored data and caches while maintaining your preferences 420 | text 421 | Partial Reset of Alfred ToDo Workflow 422 | withspace 423 | 424 | 425 | type 426 | alfred.workflow.input.keyword 427 | uid 428 | 40D1F3AB-A7A4-487C-A0DD-EEB4DCB4A527 429 | version 430 | 1 431 | 432 | 433 | config 434 | 435 | browser 436 | 437 | spaces 438 | 439 | url 440 | https://github.com/__githubslug__/issues 441 | utf8 442 | 443 | 444 | type 445 | alfred.workflow.action.openurl 446 | uid 447 | 0BD5A678-C27A-4DEC-8170-B71A675AF94E 448 | version 449 | 1 450 | 451 | 452 | config 453 | 454 | lastpathcomponent 455 | 456 | onlyshowifquerypopulated 457 | 458 | removeextension 459 | 460 | text 461 | Please post a support request on GitHub for any recurring problems. 462 | title 463 | Workflow has been reset 464 | 465 | type 466 | alfred.workflow.output.notification 467 | uid 468 | 942CDC8D-C24D-4DB7-B085-A1F18CF934F7 469 | version 470 | 1 471 | 472 | 473 | config 474 | 475 | concurrently 476 | 477 | escaping 478 | 102 479 | script 480 | version=${alfred_version:0:1} 481 | rm -rf ~/Library/Application\ Support/Alfred\ $version/Workflow\ Data/com.johandebeurs.alfred.mstodo/ 482 | rm -rf ~/Library/Caches/com.runningwithcrayons.Alfred-$version/Workflow\ Data/com.johandebeurs.alfred.mstodo/ 483 | rm -rf ~/Library/Application\ Support/Alfred/Workflow\ Data/com.johandebeurs.alfred.mstodo/ 484 | rm -rf ~/Library/Caches/com.runningwithcrayons.Alfred/Workflow\ Data/com.johandebeurs.alfred.mstodo/ 485 | security delete-generic-password -s com.johandebeurs.alfred.mstodo -a msal 486 | scriptargtype 487 | 0 488 | scriptfile 489 | 490 | type 491 | 0 492 | 493 | type 494 | alfred.workflow.action.script 495 | uid 496 | 0F5DDFFB-8D34-43DE-8652-8FC47EEDEBFD 497 | version 498 | 2 499 | 500 | 501 | config 502 | 503 | argumenttype 504 | 2 505 | keyword 506 | _td-full-reset 507 | subtext 508 | Clears all stored data, preferences, and authentication token 509 | text 510 | Full Reset of Alfred ToDo Workflow 511 | withspace 512 | 513 | 514 | type 515 | alfred.workflow.input.keyword 516 | uid 517 | 06482847-7F21-4C57-88BB-B642E0182781 518 | version 519 | 1 520 | 521 | 522 | readme 523 | __changelog__ 524 | uidata 525 | 526 | 06482847-7F21-4C57-88BB-B642E0182781 527 | 528 | xpos 529 | 300 530 | ypos 531 | 780 532 | 533 | 0BD5A678-C27A-4DEC-8170-B71A675AF94E 534 | 535 | xpos 536 | 500 537 | ypos 538 | 670 539 | 540 | 0F2D229C-18BA-4E40-A81D-D796A6E159BC 541 | 542 | xpos 543 | 300 544 | ypos 545 | 230 546 | 547 | 0F5DDFFB-8D34-43DE-8652-8FC47EEDEBFD 548 | 549 | xpos 550 | 500 551 | ypos 552 | 780 553 | 554 | 39540B6D-5AA4-4427-BA63-42256009EE5F 555 | 556 | xpos 557 | 500 558 | ypos 559 | 120 560 | 561 | 40D1F3AB-A7A4-487C-A0DD-EEB4DCB4A527 562 | 563 | xpos 564 | 300 565 | ypos 566 | 560 567 | 568 | 6B21F0AC-FBEB-4269-A007-F3FB0A8D11DE 569 | 570 | xpos 571 | 500 572 | ypos 573 | 450 574 | 575 | 942CDC8D-C24D-4DB7-B085-A1F18CF934F7 576 | 577 | xpos 578 | 700 579 | ypos 580 | 670 581 | 582 | A28E28AD-30A4-4578-87C2-E258E3948B63 583 | 584 | xpos 585 | 700 586 | ypos 587 | 230 588 | 589 | B17D3A8B-97D1-4CB1-AA34-48754A1CC6F3 590 | 591 | xpos 592 | 500 593 | ypos 594 | 340 595 | 596 | CB67A7A4-2A1F-4905-990C-5BD07FD51C1C 597 | 598 | xpos 599 | 100 600 | ypos 601 | 230 602 | 603 | E05D8886-A5A2-4CD7-9C9D-6D8F3CDDEE3F 604 | 605 | xpos 606 | 500 607 | ypos 608 | 560 609 | 610 | E0AE4F8B-253B-4EFD-B568-E9A2A46AA0C7 611 | 612 | xpos 613 | 500 614 | ypos 615 | 230 616 | 617 | 618 | version 619 | __version__ 620 | webaddress 621 | https://github.com/__githubslug__ 622 | 623 | 624 | -------------------------------------------------------------------------------- /src/logging_config.ini: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,mstodo,workflow 3 | 4 | [handlers] 5 | keys=stream_handler,file_handler 6 | 7 | [formatters] 8 | keys=formatter,complex_formatter 9 | 10 | [logger_root] 11 | handlers=stream_handler 12 | level=NOTSET 13 | 14 | [logger_mstodo] 15 | handlers=stream_handler,file_handler 16 | qualname=mstodo 17 | level=DEBUG 18 | propagate=0 19 | 20 | [logger_workflow] 21 | handlers=file_handler 22 | qualname=workflow 23 | level=DEBUG 24 | propagate=0 25 | 26 | [handler_stream_handler] 27 | class=StreamHandler 28 | level=INFO 29 | formatter=formatter 30 | args=(sys.stderr,) 31 | 32 | [handler_file_handler] 33 | class=handlers.TimedRotatingFileHandler 34 | when=D 35 | interval=7 36 | backupCount=10 37 | formatter=complex_formatter 38 | level=DEBUG 39 | args=('workflow.log',) 40 | 41 | [formatter_formatter] 42 | format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s 43 | 44 | [formatter_complex_formatter] 45 | format=%(asctime)s - %(name)s - %(levelname)s - %(module)s : %(lineno)d - %(message)s 46 | -------------------------------------------------------------------------------- /src/mstodo/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | __title__ = 'Alfred-MSToDo' 4 | with open(os.path.join(os.path.dirname(__file__), '../version'), encoding='ASCII') as fp: 5 | __version__ = fp.read() 6 | __author__ = 'Johan de Beurs, Ian Paterson' 7 | __licence__ = 'MIT' 8 | __copyright__ = 'Copyright 2023 Johan de Beurs, 2013-2017 Ian Paterson' 9 | __githubslug__ = 'johandebeurs/alfred-mstodo-workflow' 10 | 11 | def get_version(): 12 | return __version__ 13 | 14 | def get_github_slug(): 15 | return __githubslug__ -------------------------------------------------------------------------------- /src/mstodo/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/mstodo/api/__init__.py -------------------------------------------------------------------------------- /src/mstodo/api/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | from mstodo import config 5 | from mstodo.auth import oauth_token 6 | 7 | def _request_headers(): 8 | _oauth_token = oauth_token() 9 | 10 | if _oauth_token: 11 | return { 12 | 'Authorization': f"Bearer {_oauth_token}" 13 | } 14 | return None 15 | 16 | def _report_errors(fn): 17 | def report_errors(*args, **kwargs): 18 | response = fn(*args, **kwargs) 19 | if response.status_code > 500: 20 | response.raise_for_status() 21 | return response 22 | return report_errors 23 | 24 | def get(path, params=None): 25 | headers = _request_headers() 26 | return requests.get( 27 | config.MS_TODO_API_BASE_URL + '/' + path, 28 | headers=headers, 29 | params=params, 30 | timeout=config.REQUEST_TIMEOUT 31 | ) 32 | 33 | @_report_errors 34 | def post(path, data=None): 35 | headers = _request_headers() 36 | headers['Content-Type'] = 'application/json' 37 | return requests.post( 38 | config.MS_TODO_API_BASE_URL + '/' + path, 39 | headers=headers, 40 | data=json.dumps(data), 41 | timeout=config.REQUEST_TIMEOUT 42 | ) 43 | 44 | @_report_errors 45 | def put(path, data=None): 46 | headers = _request_headers() 47 | headers['Content-Type'] = 'application/json' 48 | return requests.put( 49 | config.MS_TODO_API_BASE_URL + '/' + path, 50 | headers=headers, 51 | data=json.dumps(data), 52 | timeout=config.REQUEST_TIMEOUT 53 | ) 54 | 55 | @_report_errors 56 | def patch(path, data=None): 57 | headers = _request_headers() 58 | headers['Content-Type'] = 'application/json' 59 | return requests.patch( 60 | config.MS_TODO_API_BASE_URL + '/' + path, 61 | headers=headers, 62 | data=json.dumps(data), 63 | timeout=config.REQUEST_TIMEOUT 64 | ) 65 | 66 | @_report_errors 67 | def delete(path, data=None): 68 | headers = _request_headers() 69 | return requests.delete( 70 | config.MS_TODO_API_BASE_URL + '/' + path, 71 | headers=headers, 72 | params=data, 73 | timeout=config.REQUEST_TIMEOUT 74 | ) 75 | -------------------------------------------------------------------------------- /src/mstodo/api/taskfolders.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from requests import codes 5 | 6 | from mstodo import config 7 | import mstodo.api.base as api 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | def taskfolders(order='display', task_counts=False): 12 | start = time.time() 13 | query = f"?$top={config.MS_TODO_PAGE_SIZE}&count=true" 14 | next_link = f"me/outlook/taskFolders{query}" 15 | taskfolders = [] 16 | while True: 17 | req = api.get(next_link) 18 | taskfolders.extend(req.json()['value']) 19 | if '@odata.nextLink' in req.json(): 20 | next_link= req.json()['@odata.nextLink'].replace(config.MS_TODO_API_BASE_URL + '/','') 21 | else: 22 | log.debug(f"Retrieved taskFolders in {round(time.time() - start, 3)} seconds") 23 | break 24 | 25 | if task_counts: 26 | for taskfolder in taskfolders: 27 | update_taskfolder_with_tasks_count(taskfolder) 28 | 29 | return taskfolders 30 | 31 | def taskfolder(_id, task_counts=False): 32 | req = api.get(f"me/outlook/taskFolders/{_id}") 33 | info = req.json() 34 | 35 | #@TODO: run this request in parallel 36 | if task_counts: 37 | update_taskfolder_with_tasks_count(info) 38 | 39 | return info 40 | 41 | def taskfolder_tasks_count(_id): 42 | info = {} 43 | req = api.get(f"taskFolders/{_id}/tasks?$count=true&$top=1&$filter=status+ne+'completed'") 44 | info['uncompleted_count'] = req.json()['@odata.count'] 45 | req = api.get(f"taskFolders/{_id}/tasks?$count=true&$top=1&$filter=status+eq+'completed'") 46 | info['completed_count'] = req.json()['@odata.count'] 47 | 48 | return info 49 | 50 | def update_taskfolder_with_tasks_count(info): 51 | counts = taskfolder_tasks_count(info['id']) 52 | 53 | info['completed_count'] = counts['completed_count'] if 'completed_count' in counts else 0 54 | info['uncompleted_count'] = counts['uncompleted_count'] if 'uncompleted_count' in counts else 0 55 | 56 | return info 57 | 58 | def create_taskfolder(title): 59 | req = api.post('me/outlook/taskFolders', {'name': title}) 60 | 61 | return req 62 | 63 | def delete_taskfolder(_id): 64 | req = api.delete('me/outlook/taskFolders/' + _id) 65 | 66 | return req.status_code == codes.no_content 67 | -------------------------------------------------------------------------------- /src/mstodo/api/tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import datetime 4 | from dateutil import tz 5 | 6 | from mstodo import config 7 | import mstodo.api.base as api 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | def _build_querystring(completed=None, dt=None, afterdt=True, fields=None): 12 | if fields is None: 13 | fields = [] 14 | query = f"?$top={config.MS_TODO_PAGE_SIZE}&count=true&$select= \ 15 | {''.join([field + ',' for field in fields])[:-1]}" 16 | if (completed is not None or dt is not None): 17 | query += '&$filter=' 18 | if completed is True: 19 | query += "status+eq+'completed'" 20 | elif completed is False: 21 | query += "status+ne+'completed'" 22 | if completed is not None: 23 | query += "&" 24 | if dt is not None: 25 | query += f"lastModifiedDateTime+{'ge+' if afterdt else 'lt+'}{dt.isoformat()[:-4]}Z" 26 | else: 27 | query += '' 28 | return query 29 | 30 | def tasks(taskfolder_id=None, completed=None, dt=None, afterdt=None, fields=None): 31 | if fields is None: 32 | fields = [] 33 | if taskfolder_id is not None: 34 | root_uri = f"me/outlook/taskFolders/{taskfolder_id}/tasks" 35 | else: 36 | root_uri = "me/outlook/tasks" 37 | next_link = root_uri + _build_querystring( 38 | completed=completed, 39 | dt=dt, 40 | afterdt=afterdt, 41 | fields=fields 42 | ) 43 | task_data = [] 44 | while True: 45 | start_page = time.time() 46 | req = api.get(next_link) 47 | task_data.extend(req.json()['value']) 48 | log.debug(f"Retrieved {len(req.json()['value'])} {'modified ' if afterdt else ''}\ 49 | {'completed ' if completed else ''}tasks in {round(time.time() - start_page, 3)} seconds") 50 | if '@odata.nextLink' in req.json(): 51 | next_link= req.json()['@odata.nextLink'].replace(f"{config.MS_TODO_API_BASE_URL}/",'') 52 | else: 53 | break 54 | 55 | return task_data 56 | 57 | def task(_id): 58 | req = api.get('me/outlook/tasks/' + _id) 59 | info = req.json() 60 | 61 | return info 62 | 63 | def set_due_date(due_date): 64 | due_date = datetime.datetime.combine(due_date, datetime.time(0, 0, 0, 1)) 65 | # Microsoft ignores the time component of the API response so we don't do TZ conversion here 66 | return { 67 | 'dueDateTime': { 68 | "dateTime": due_date.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] + 'Z', 69 | "timeZone": "UTC" 70 | } 71 | } 72 | 73 | def set_reminder_date(reminder_date): 74 | reminder_date = reminder_date.replace(tzinfo=tz.gettz()) 75 | return { 76 | 'isReminderOn': True, 77 | 'reminderDateTime': { 78 | "dateTime": reminder_date.astimezone(tz.tzutc()) \ 79 | .strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] + 'Z', 80 | "timeZone": "UTC" 81 | } 82 | } 83 | 84 | def set_recurrence(recurrence_count, recurrence_type, due_date): 85 | recurrence = {'pattern':{},'range':{}} 86 | if recurrence_type == 'day': 87 | recurrence_type = 'daily' 88 | elif recurrence_type == 'week': 89 | recurrence_type = 'weekly' 90 | recurrence['pattern']['firstDayOfWeek'] = 'sunday' 91 | recurrence['pattern']['daysOfWeek'] = [due_date.strftime('%A')] 92 | elif recurrence_type == 'month': 93 | recurrence_type = 'absoluteMonthly' 94 | recurrence['pattern']['dayOfMonth'] = due_date.strftime('%d') 95 | elif recurrence_type == 'year': 96 | recurrence_type = 'absoluteYearly' 97 | recurrence['pattern']['dayOfMonth'] = due_date.strftime('%d') 98 | recurrence['pattern']['month'] = due_date.strftime('%m') 99 | recurrence['pattern']['interval'] = recurrence_count 100 | recurrence['pattern']['type'] = recurrence_type 101 | recurrence['range'] = { 102 | # "endDate": "String (timestamp)", only for endDate types 103 | # "numberOfOccurrences": 1024, 104 | # "recurrenceTimeZone": "string", 105 | 'startDate': due_date.strftime('%Y-%m-%d'), 106 | 'type': 'noEnd' # "endDate / noEnd / numbered" 107 | } 108 | return recurrence 109 | 110 | def create_task(taskfolder_id, title, assignee_id=None, recurrence_type=None, 111 | recurrence_count=None, due_date=None, reminder_date=None, 112 | starred=False, completed=False, note=None): 113 | params = { 114 | 'subject': title, 115 | 'importance': 'high' if starred else 'normal', 116 | 'status': 'completed' if completed else 'notStarted', 117 | 'sensitivity': 'normal', 118 | 'isReminderOn': False, 119 | 'body': { 120 | 'contentType':'text', 121 | 'content': note if note else '' 122 | } 123 | } 124 | if due_date: 125 | due_date = datetime.datetime.combine(due_date,datetime.time(0,0,0,1)) 126 | # Microsoft ignores the time component of the API response so we don't do TZ conversion here 127 | params['dueDateTime'] = { 128 | "dateTime": due_date.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] + 'Z', 129 | "timeZone": "UTC" 130 | } 131 | if reminder_date: 132 | reminder_date = reminder_date.replace(tzinfo=tz.gettz()) 133 | params['isReminderOn'] = True 134 | params['reminderDateTime'] = { 135 | "dateTime": reminder_date.astimezone(tz.tzutc()) \ 136 | .strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] + 'Z', 137 | "timeZone": "UTC" 138 | } 139 | if (recurrence_count is not None and recurrence_type is not None): 140 | params['recurrence'] = {'pattern':{},'range':{}} 141 | if recurrence_type == 'day': 142 | recurrence_type = 'daily' 143 | elif recurrence_type == 'week': 144 | recurrence_type = 'weekly' 145 | params['recurrence']['pattern']['firstDayOfWeek'] = 'sunday' 146 | params['recurrence']['pattern']['daysOfWeek'] = [due_date.strftime('%A')] 147 | elif recurrence_type == 'month': 148 | recurrence_type = 'absoluteMonthly' 149 | params['recurrence']['pattern']['dayOfMonth'] = due_date.strftime('%d') 150 | elif recurrence_type == 'year': 151 | recurrence_type = 'absoluteYearly' 152 | params['recurrence']['pattern']['dayOfMonth'] = due_date.strftime('%d') 153 | params['recurrence']['pattern']['month'] = due_date.strftime('%m') 154 | params['recurrence']['pattern']['interval'] = recurrence_count 155 | params['recurrence']['pattern']['type'] = recurrence_type 156 | params['recurrence']['range'] = { 157 | # "endDate": "String (timestamp)", only for endDate types 158 | # "numberOfOccurrences": 1024, 159 | # "recurrenceTimeZone": "string", 160 | 'startDate': due_date.strftime('%Y-%m-%d'), 161 | 'type': 'noEnd' # "endDate / noEnd / numbered" 162 | } 163 | 164 | #@TODO maybe add these if required 165 | # params_new = { 166 | # "categories": ["String"], 167 | # "startDateTime": {"@odata.type": "microsoft.graph.dateTimeTimeZone"}, 168 | # } 169 | 170 | #@TODO check these and add back if needed 171 | # if assignee_id: 172 | # params['assignedTo'] = int(assignee_id) 173 | 174 | req = api.post(f"me/outlook/taskFolders/{taskfolder_id}/tasks", params) 175 | log.debug(req.status_code) 176 | 177 | return req 178 | 179 | def update_task(_id, revision, title=None, assignee_id=None, recurrence_type=None, 180 | recurrence_count=None, due_date=None, reminder_date=None, starred=None, 181 | completed=None): 182 | params = {} 183 | 184 | if not completed is None: 185 | if completed: 186 | res = api.post(f"me/outlook/tasks/{_id}/complete") 187 | return res 188 | else: 189 | params['status'] = 'notStarted' 190 | params['completedDateTime'] = {} 191 | 192 | if title is not None: 193 | params['subject'] = title 194 | 195 | if starred is not None: 196 | if starred is True: 197 | params['importance'] = 'high' 198 | elif starred is False: 199 | params['importance'] = 'normal' 200 | 201 | if due_date is not None: 202 | params.update(set_due_date(due_date)) 203 | 204 | if reminder_date is not None: 205 | params.update(set_reminder_date(reminder_date)) 206 | 207 | #@TODO this requires all three to be set. Need to ensure due_date is pulled from task on calling this function 208 | if (recurrence_count is not None and recurrence_type is not None and due_date is not None): 209 | params.update(set_recurrence(recurrence_count, recurrence_type, due_date)) 210 | #@TODO maybe add these if required 211 | # params_new = { 212 | # "categories": ["String"], 213 | # "startDateTime": {"@odata.type": "microsoft.graph.dateTimeTimeZone"}, 214 | # } 215 | 216 | #@TODO check these and add back if needed 217 | # if assignee_id: 218 | # params['assignedTo'] = int(assignee_id) 219 | # remove = [] 220 | 221 | if params: 222 | res = api.patch(f"me/outlook/tasks/{_id}", params) 223 | 224 | return res 225 | 226 | return None 227 | 228 | def delete_task(_id, revision): 229 | res = api.delete(f"me/outlook/tasks/{_id}") 230 | 231 | return res 232 | -------------------------------------------------------------------------------- /src/mstodo/api/user.py: -------------------------------------------------------------------------------- 1 | import mstodo.api.base as api 2 | 3 | 4 | def user(): 5 | req = api.get('me') 6 | return req.json() 7 | -------------------------------------------------------------------------------- /src/mstodo/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import atexit 3 | import msal 4 | from workflow import PasswordNotFound 5 | from mstodo import config, __version__, __title__ 6 | from mstodo.util import wf_wrapper 7 | 8 | log = logging.getLogger(__name__) 9 | wf = wf_wrapper() 10 | 11 | # Set up MSAL cache and config to write any changed data on program exit 12 | cache = msal.SerializableTokenCache() 13 | atexit.register( 14 | lambda: wf.save_password('msal', cache.serialize()) \ 15 | if cache.has_state_changed else None 16 | ) 17 | 18 | # Set up msal application for this session 19 | app = msal.PublicClientApplication( 20 | client_id=config.MS_AZURE_CLIENT_ID, 21 | token_cache=cache, 22 | timeout=config.OAUTH_TIMEOUT, 23 | app_name=__title__, 24 | app_version=__version__ 25 | ) 26 | 27 | def is_authorised(): 28 | try: 29 | # Attempt to load cached credentials from keychain 30 | cache.deserialize(wf.get_password('msal')) 31 | return True 32 | except PasswordNotFound: 33 | # This is the first run or the workflow has been deauthorized 34 | return False 35 | 36 | def authorise(): 37 | result = None 38 | accounts = app.get_accounts() 39 | 40 | if accounts: 41 | #@TODO if logged out but previously was logged in, display these 42 | # to the end user in the workflow UI and allow selection? 43 | print("You have used the following accounts. We'll choose the first one:") 44 | for acct in accounts: 45 | print(acct["username"]) 46 | chosen = accounts[0] 47 | # Try to find cached token, or if it has expired, use refresh token to silently re-obtain 48 | result = app.acquire_token_silent(scopes=config.MS_TODO_SCOPE, account=chosen) 49 | 50 | if not result: 51 | # So no suitable token exists in cache. Let's get a new one from AAD. 52 | result = app.acquire_token_interactive(scopes=config.MS_TODO_SCOPE) 53 | if "access_token" in result: 54 | log.debug("Obtained access token") 55 | cache.add(result) 56 | wf.cache_data('query_event', True) 57 | return True 58 | 59 | wf.store_data('auth', 'Error: ') 60 | log.debug(result.get("error")) 61 | log.debug(result.get("error_description")) 62 | log.debug(result.get("correlation_id")) # You may need this when reporting a bug 63 | return False 64 | 65 | def deauthorise(): 66 | try: 67 | log.debug('Deauthorising') 68 | wf.delete_password('msal') 69 | except PasswordNotFound: 70 | pass 71 | 72 | def oauth_token(): 73 | result = None 74 | accounts = app.get_accounts() 75 | if accounts: 76 | result = app.acquire_token_silent(scopes=config.MS_TODO_SCOPE, account=accounts[0]) 77 | return result.get('access_token') 78 | else: 79 | return None 80 | -------------------------------------------------------------------------------- /src/mstodo/config.py: -------------------------------------------------------------------------------- 1 | MS_AZURE_CLIENT_ID = "27f3e5af-2d84-4073-91fa-9390208d1527" 2 | MS_TODO_SCOPE = [ 3 | "User.Read", 4 | "Tasks.ReadWrite", 5 | "Tasks.ReadWrite.Shared", 6 | "MailboxSettings.ReadWrite" 7 | ] 8 | MS_TODO_API_BASE_URL = "https://graph.microsoft.com/beta" 9 | MS_TODO_PAGE_SIZE = '1000' 10 | KC_REFRESH_TOKEN = 'refresh_token' 11 | KC_OAUTH_TOKEN = 'oauth_token' 12 | KC_OAUTH_STATE = 'oauth_state' 13 | 14 | OAUTH_TIMEOUT = 60 * 10 15 | REQUEST_TIMEOUT = 60 16 | -------------------------------------------------------------------------------- /src/mstodo/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/mstodo/handlers/__init__.py -------------------------------------------------------------------------------- /src/mstodo/handlers/about.py: -------------------------------------------------------------------------------- 1 | from workflow.notify import notify 2 | from mstodo import icons, __version__, __githubslug__ 3 | from mstodo.util import wf_wrapper 4 | 5 | wf = wf_wrapper() 6 | 7 | def display(args): 8 | wf.add_item( 9 | 'New in this version', 10 | 'Installed: ' + __version__ + '. See the changes from the previous version', 11 | arg='-about changelog', valid=True, icon=icons.INFO 12 | ) 13 | 14 | wf.add_item( 15 | 'Questions or concerns?', 16 | 'See outstanding issues and report your own bugs or feedback', 17 | arg='-about issues', valid=True, icon=icons.HELP 18 | ) 19 | 20 | wf.add_item( 21 | 'Update workflow', 22 | 'Check for updates to the workflow (automatically checked periodically)', 23 | arg='-about update', valid=True, icon=icons.DOWNLOAD 24 | ) 25 | 26 | wf.add_item( 27 | 'Main menu', 28 | autocomplete='', icon=icons.BACK 29 | ) 30 | 31 | def commit(args, modifier=None): 32 | if 'update' in args: 33 | if wf.start_update(): 34 | notify( 35 | title='Workflow update', 36 | message='The workflow is being updated' 37 | ) 38 | else: 39 | notify( 40 | title='Workflow update', 41 | message='You already have the latest workflow version' 42 | ) 43 | else: 44 | import webbrowser 45 | 46 | if 'changelog' in args: 47 | webbrowser.open(f"https://github.com/{__githubslug__}/releases/tag/{__version__}") 48 | elif 'mstodo' in args: 49 | webbrowser.open('https://todo.microsoft.com/') 50 | elif 'issues' in args: 51 | webbrowser.open(f"https://github.com/{__githubslug__}/issues") 52 | -------------------------------------------------------------------------------- /src/mstodo/handlers/completed.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from datetime import date, timedelta 4 | 5 | from peewee import OperationalError 6 | 7 | from mstodo import icons 8 | from mstodo.models.preferences import Preferences 9 | from mstodo.models.task import Task 10 | from mstodo.models.taskfolder import TaskFolder 11 | from mstodo.sync import background_sync, background_sync_if_necessary 12 | from mstodo.util import relaunch_alfred, wf_wrapper 13 | 14 | _durations = [ 15 | { 16 | 'days': 1, 17 | 'label': 'In the past 1 day', 18 | 'subtitle': 'Show tasks that were completed in the past day' 19 | }, 20 | { 21 | 'days': 3, 22 | 'label': 'In the past 3 days', 23 | 'subtitle': 'Show tasks that were completed in the past 3 days' 24 | }, 25 | { 26 | 'days': 7, 27 | 'label': 'In the past week', 28 | 'subtitle': 'Show tasks that were completed in the past 7 days' 29 | }, 30 | { 31 | 'days': 14, 32 | 'label': 'In the past 2 weeks', 33 | 'subtitle': 'Show tasks that were completed in the past 14 days' 34 | }, 35 | { 36 | 'days': 30, 37 | 'label': 'In the past month', 38 | 'subtitle': 'Show tasks that were completed in the past 30 days' 39 | } 40 | ] 41 | 42 | 43 | def _default_label(days): 44 | return f"In the past {days} day{'' if days == 1 else 's'}" 45 | 46 | 47 | def _duration_info(days): 48 | duration_info = [d for d in _durations if d['days'] == days] 49 | 50 | if len(duration_info) > 0: 51 | return duration_info[0] 52 | 53 | return { 54 | 'days': days, 55 | 'label': _default_label(days), 56 | 'subtitle': 'Your custom duration', 57 | 'custom': True 58 | } 59 | 60 | 61 | def display(args): 62 | wf = wf_wrapper() 63 | prefs = Preferences.current_prefs() 64 | command = args[1] if len(args) > 1 else None 65 | duration_info = _duration_info(prefs.completed_duration) 66 | 67 | if command == 'duration': 68 | selected_duration = prefs.completed_duration 69 | 70 | # Apply selected duration option 71 | if len(args) > 2: 72 | try: 73 | selected_duration = int(args[2]) 74 | except: 75 | pass 76 | 77 | duration_info = _duration_info(selected_duration) 78 | 79 | if 'custom' in duration_info: 80 | wf.add_item(duration_info['label'], duration_info['subtitle'], 81 | arg=f"-completed duration {duration_info['days']}", valid=True, 82 | icon=icons.RADIO_SELECTED if duration_info['days'] == selected_duration else icons.RADIO) 83 | 84 | for duration_info in _durations: 85 | wf.add_item(duration_info['label'], duration_info['subtitle'], 86 | arg=f"-completed duration {duration_info['days']}", valid=True, 87 | icon=icons.RADIO_SELECTED if duration_info['days'] == selected_duration else icons.RADIO) 88 | 89 | wf.add_item('Back', autocomplete='-completed ', icon=icons.BACK) 90 | 91 | return 92 | 93 | # Force a sync if not done recently or join if already running 94 | background_sync_if_necessary() 95 | 96 | wf.add_item(duration_info['label'], subtitle='Change the duration for completed tasks', 97 | autocomplete='-completed duration ', icon=icons.YESTERDAY) 98 | 99 | conditions = True 100 | 101 | # Build task title query based on the args 102 | for arg in args[1:]: 103 | if len(arg) > 1: 104 | conditions = conditions & (Task.title.contains(arg) | TaskFolder.title.contains(arg)) 105 | 106 | if conditions is None: 107 | conditions = True 108 | 109 | tasks = Task.select().join(TaskFolder).where( 110 | (Task.completedDateTime > date.today() - timedelta(days=duration_info['days'])) & 111 | Task.list.is_null(False) & 112 | conditions 113 | )\ 114 | .order_by(Task.completedDateTime.desc(), Task.reminderDateTime.asc(), Task.changeKey.asc()) 115 | 116 | try: 117 | for task in tasks: 118 | wf.add_item(f"{task.list_title} – {task.title}", task.subtitle(), autocomplete=f"-task {task.id}", 119 | icon=icons.TASK_COMPLETED if task.status == 'completed' else icons.TASK) 120 | except OperationalError: 121 | background_sync() 122 | 123 | wf.add_item('Main menu', autocomplete='', icon=icons.BACK) 124 | 125 | def commit(args, modifier=None): 126 | relaunch_command = None 127 | prefs = Preferences.current_prefs() 128 | action = args[1] 129 | 130 | if action == 'duration': 131 | relaunch_command = 'td-completed ' 132 | prefs.completed_duration = int(args[2]) 133 | 134 | if relaunch_command: 135 | relaunch_alfred(relaunch_command) 136 | -------------------------------------------------------------------------------- /src/mstodo/handlers/due.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from datetime import datetime, timedelta 4 | import logging 5 | 6 | from peewee import OperationalError 7 | 8 | from mstodo import icons 9 | from mstodo.models.taskfolder import TaskFolder 10 | from mstodo.models.preferences import Preferences 11 | from mstodo.models.task import Task 12 | from mstodo.sync import background_sync, background_sync_if_necessary 13 | from mstodo.util import relaunch_alfred, wf_wrapper 14 | 15 | log = logging.getLogger('mstodo') 16 | 17 | _due_orders = ( 18 | { 19 | 'due_order': ['order', 'due_date', 'TaskFolder.id'], 20 | 'title': 'Most overdue within each folder', 21 | 'subtitle': 'Sort tasks by increasing due date within folders (Default)' 22 | }, 23 | { 24 | 'due_order': ['order', '-due_date', 'TaskFolder.id'], 25 | 'title': 'Most recently due within each folder', 26 | 'subtitle': 'Sort tasks by decreasing due date within folders' 27 | }, 28 | { 29 | 'due_order': ['order', 'due_date'], 30 | 'title': 'Most overdue at the top', 31 | 'subtitle': 'All tasks sorted by increasing due date' 32 | }, 33 | { 34 | 'due_order': ['order', '-due_date'], 35 | 'title': 'Most recently due at the top', 36 | 'subtitle': 'All tasks sorted by decreasing due date' 37 | } 38 | ) 39 | 40 | 41 | def display(args): 42 | wf = wf_wrapper() 43 | prefs = Preferences.current_prefs() 44 | command = args[1] if len(args) > 1 else None 45 | 46 | # Show sort options 47 | if command == 'sort': 48 | for i, order_info in enumerate(_due_orders): 49 | wf.add_item(order_info['title'], order_info['subtitle'], arg=f"-due sort {(i + 1)}", valid=True, 50 | icon=icons.RADIO_SELECTED if order_info['due_order'] == prefs.due_order else icons.RADIO) 51 | 52 | wf.add_item('Highlight skipped recurring tasks', 53 | 'Hoists recurring tasks that have been missed multiple times over to the top', 54 | arg='-due sort toggle-skipped', valid=True, 55 | icon=icons.CHECKBOX_SELECTED if prefs.hoist_skipped_tasks else icons.CHECKBOX) 56 | 57 | wf.add_item('Back', autocomplete='-due ', icon=icons.BACK) 58 | 59 | return 60 | 61 | background_sync_if_necessary() 62 | conditions = True 63 | 64 | # Build task title query based on the args 65 | for arg in args[1:]: 66 | if len(arg) > 1: 67 | conditions = conditions & (Task.title.contains(arg) | TaskFolder.title.contains(arg)) 68 | 69 | if conditions is None: 70 | conditions = True 71 | 72 | tasks = Task.select().join(TaskFolder).where( 73 | (Task.status != 'completed') & 74 | (Task.dueDateTime < datetime.now() + timedelta(days=1)) & 75 | Task.list.is_null(False) & 76 | conditions 77 | ) 78 | 79 | # Sort the tasks according to user preference 80 | for key in prefs.due_order: 81 | order = 'asc' 82 | field = None 83 | if key[0] == '-': 84 | order = 'desc' 85 | key = key[1:] 86 | 87 | if key == 'due_date': 88 | field = Task.dueDateTime 89 | elif key == 'taskfolder.id': 90 | field = TaskFolder.id 91 | elif key == 'order': 92 | field = Task.lastModifiedDateTime 93 | 94 | if field: 95 | if order == 'asc': 96 | tasks = tasks.order_by(field.asc()) 97 | else: 98 | tasks = tasks.order_by(field.desc()) 99 | 100 | try: 101 | if prefs.hoist_skipped_tasks: 102 | log.debug('hoisting skipped tasks') 103 | tasks = sorted(tasks, key=lambda t: -t.overdue_times) 104 | 105 | for task in tasks: 106 | wf.add_item( 107 | f"{task.list_title} – {task.title}", task.subtitle(), autocomplete=f"-task {task.id} ", 108 | icon=icons.TASK_COMPLETED if task.status == 'completed' else icons.TASK 109 | ) 110 | except OperationalError: 111 | background_sync() 112 | 113 | wf.add_item( 114 | 'Sort order', 'Change the display order of due tasks', 115 | autocomplete='-due sort', icon=icons.SORT 116 | ) 117 | 118 | wf.add_item('Main menu', autocomplete='', icon=icons.BACK) 119 | 120 | def commit(args, modifier=None): 121 | action = args[1] 122 | prefs = Preferences.current_prefs() 123 | relaunch_command = None 124 | 125 | if action == 'sort' and len(args) > 2: 126 | command = args[2] 127 | 128 | if command == 'toggle-skipped': 129 | prefs.hoist_skipped_tasks = not prefs.hoist_skipped_tasks 130 | relaunch_command = 'td-due sort' 131 | else: 132 | try: 133 | index = int(command) 134 | order_info = _due_orders[index - 1] 135 | prefs.due_order = order_info['due_order'] 136 | relaunch_command = 'td-due ' 137 | except IndexError: 138 | pass 139 | except ValueError: 140 | pass 141 | 142 | if relaunch_command: 143 | relaunch_alfred(relaunch_command) 144 | -------------------------------------------------------------------------------- /src/mstodo/handlers/login.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import re 4 | 5 | from mstodo import auth, icons 6 | from mstodo.util import wf_wrapper 7 | 8 | ACTION_PATTERN = re.compile(r'^\W+', re.UNICODE) 9 | wf = wf_wrapper() 10 | 11 | def display(args): 12 | getting_help = False 13 | 14 | if len(args) > 0: 15 | action = re.sub(ACTION_PATTERN, '', args[0]) 16 | getting_help = action and 'help'.find(action) == 0 17 | 18 | if not getting_help: 19 | wf.add_item( 20 | 'Please log in', 21 | 'Authorise Alfred ToDo Workflow to use your Microsoft account', 22 | valid=True, icon=icons.ACCOUNT 23 | ) 24 | 25 | if getting_help: 26 | wf.add_item( 27 | 'I need to log in to a different account', 28 | 'Go to microsoft.com in your browser and sign out of your account first', 29 | arg='-about mstodo', valid=True, icon=icons.ACCOUNT 30 | ) 31 | wf.add_item( 32 | 'Other issues?', 33 | 'See outstanding issues and report your own bugs or feedback', 34 | arg='-about issues', valid=True, icon=icons.HELP 35 | ) 36 | else: 37 | wf.add_item( 38 | 'Having trouble?', 39 | autocomplete='-help ', valid=False, icon=icons.HELP 40 | ) 41 | 42 | if not getting_help: 43 | wf.add_item( 44 | 'About', 45 | 'Learn about the workflow and get support', 46 | autocomplete='-about ', 47 | icon=icons.INFO 48 | ) 49 | 50 | def commit(args, modifier=None): 51 | command = ' '.join(args).strip() 52 | 53 | if not command: 54 | auth.authorise() 55 | -------------------------------------------------------------------------------- /src/mstodo/handlers/logout.py: -------------------------------------------------------------------------------- 1 | from workflow.notify import notify 2 | from mstodo import auth, icons 3 | from mstodo.util import wf_wrapper 4 | 5 | wf = wf_wrapper() 6 | 7 | def display(args): 8 | wf.add_item( 9 | 'Are you sure?', 10 | 'You will need to log in to a Microsoft account to continue using the workflow', 11 | arg=' '.join(args), 12 | valid=True, 13 | icon=icons.CHECKMARK 14 | ) 15 | 16 | wf.add_item( 17 | 'Nevermind', 18 | autocomplete='', 19 | icon=icons.CANCEL 20 | ) 21 | 22 | def commit(args, modifier=None): 23 | auth.deauthorise() 24 | wf.clear_data() 25 | wf.clear_cache() 26 | 27 | notify(title='Authentication', message='You are now logged out') 28 | -------------------------------------------------------------------------------- /src/mstodo/handlers/new_task.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from random import random 4 | from requests import codes 5 | from peewee import fn 6 | from workflow.background import is_running 7 | from workflow.notify import notify 8 | 9 | from mstodo import icons 10 | from mstodo.models.preferences import Preferences 11 | from mstodo.models.task_parser import TaskParser 12 | from mstodo.util import format_time, short_relative_formatted_date, wf_wrapper, SYMBOLS 13 | 14 | def _task(args): 15 | return TaskParser(' '.join(args)) 16 | 17 | def task_subtitle(task): 18 | subtitle = [] 19 | 20 | if task.starred: 21 | subtitle.append(SYMBOLS['star']) 22 | 23 | if task.due_date: 24 | subtitle.append(f"Due {short_relative_formatted_date(task.due_date)}") 25 | 26 | if task.recurrence_type: 27 | if task.recurrence_count > 1: 28 | subtitle.append(f"{SYMBOLS['recurrence']} Every {task.recurrence_count} {task.recurrence_type}s") 29 | # Cannot simply add -ly suffix 30 | elif task.recurrence_type == 'day': 31 | subtitle.append(f"{SYMBOLS['recurrence']} Daily") 32 | else: 33 | subtitle.append(f"{SYMBOLS['recurrence']} {task.recurrence_type.title()}ly") 34 | 35 | if task.reminder_date: 36 | reminder_date_phrase = None 37 | if task.reminder_date.date() == task.due_date: 38 | reminder_date_phrase = 'On due date' 39 | else: 40 | reminder_date_phrase = short_relative_formatted_date(task.reminder_date) 41 | 42 | subtitle.append(f"{SYMBOLS['reminder']} {reminder_date_phrase} \ 43 | at {format_time(task.reminder_date.time(), 'short')}") 44 | 45 | subtitle.append(task.title) 46 | 47 | if task.note: 48 | subtitle.append(f"{SYMBOLS['note']} {task.note}") 49 | 50 | return ' '.join(subtitle) 51 | 52 | def display(args): 53 | task = _task(args) 54 | subtitle = task_subtitle(task) 55 | wf = wf_wrapper() 56 | matching_hashtags = [] 57 | 58 | if not task.title: 59 | subtitle = 'Begin typing to add a new task' 60 | 61 | # Preload matching hashtags into a list so that we can get the length 62 | if task.has_hashtag_prompt: 63 | from mstodo.models.hashtag import Hashtag 64 | 65 | hashtags = Hashtag.select().where(Hashtag.id.contains(task.hashtag_prompt.lower())) \ 66 | .order_by(fn.Lower(Hashtag.tag).asc()) 67 | 68 | for hashtag in hashtags: 69 | matching_hashtags.append(hashtag) 70 | 71 | # Show hashtag prompt if there is more than one matching hashtag or the 72 | # hashtag being typed does not exactly match the single matching hashtag 73 | if task.has_hashtag_prompt and len(matching_hashtags) > 0 and \ 74 | (len(matching_hashtags) > 1 or task.hashtag_prompt != matching_hashtags[0].tag): 75 | for hashtag in matching_hashtags: 76 | wf.add_item(hashtag.tag[1:], '', autocomplete=' ' + task.phrase_with(hashtag=hashtag.tag) + ' ', 77 | icon=icons.HASHTAG) 78 | 79 | elif task.has_list_prompt: 80 | taskfolders = wf.stored_data('taskfolders') 81 | if taskfolders: 82 | for taskfolder in taskfolders: 83 | # Show some full list names and some concatenated in command 84 | # suggestions 85 | sample_command = taskfolder['title'] 86 | if random() > 0.5: 87 | sample_command = sample_command[:int(len(sample_command) * .75)] 88 | icon = icons.INBOX if taskfolder['isDefaultFolder'] else icons.LIST 89 | wf.add_item(taskfolder['title'], f"Assign task to this folder, e.g. {sample_command.lower()}: {task.title}", 90 | autocomplete=' ' + task.phrase_with(list_title=taskfolder['title']), icon=icon) 91 | wf.add_item('Remove folder', 'Tasks without a folder are added to the Inbox', 92 | autocomplete=f" {task.phrase_with(list_title=False)}", icon=icons.CANCEL) 93 | elif is_running('sync'): 94 | wf.add_item('Your folders are being synchronized', 'Please try again in a few moments', 95 | autocomplete=f" {task.phrase_with(list_title=False)}", icon=icons.BACK) 96 | 97 | # Task has an unfinished recurrence phrase 98 | elif task.has_recurrence_prompt: 99 | wf.add_item('Every month', 'Same day every month, e.g. every mo', uid="recurrence_1m", 100 | autocomplete=f" {task.phrase_with(recurrence='every month')} ", icon=icons.RECURRENCE) 101 | wf.add_item('Every week', 'Same day every week, e.g. every week, every Tuesday', uid="recurrence_1w", 102 | autocomplete=f" {task.phrase_with(recurrence='every week')} ", icon=icons.RECURRENCE) 103 | wf.add_item('Every year', 'Same date every year, e.g. every 1 y, every April 15', uid="recurrence_1y", 104 | autocomplete=f" {task.phrase_with(recurrence='every year')} ", icon=icons.RECURRENCE) 105 | wf.add_item('Every 3 months', 'Same day every 3 months, e.g. every 3 months', uid="recurrence_3m", 106 | autocomplete=f" {task.phrase_with(recurrence='every 3 months')} ", icon=icons.RECURRENCE) 107 | wf.add_item('Remove recurrence', autocomplete=' ' + task.phrase_with(recurrence=False), icon=icons.CANCEL) 108 | 109 | # Task has an unfinished due date phrase 110 | elif task.has_due_date_prompt: 111 | wf.add_item('Today', 'e.g. due today', 112 | autocomplete=f" {task.phrase_with(due_date='due today')} ", icon=icons.TODAY) 113 | wf.add_item('Tomorrow', 'e.g. due tomorrow', 114 | autocomplete=f" {task.phrase_with(due_date='due tomorrow')} ", icon=icons.TOMORROW) 115 | wf.add_item('Next Week', 'e.g. due next week', 116 | autocomplete=f" {task.phrase_with(due_date='due next week')} ", icon=icons.NEXT_WEEK) 117 | wf.add_item('Next Month', 'e.g. due next month', 118 | autocomplete=f" {task.phrase_with(due_date='due next month')} ", icon=icons.CALENDAR) 119 | wf.add_item('Next Year', 'e.g. due next year, due April 15', 120 | autocomplete=f" {task.phrase_with(due_date='due next year')} ", icon=icons.CALENDAR) 121 | wf.add_item('Remove due date', 'Add "not due" to fix accidental dates, or see td-pref', 122 | autocomplete=f" {task.phrase_with(due_date=False)}", icon=icons.CANCEL) 123 | 124 | # Task has an unfinished reminder phrase 125 | elif task.has_reminder_prompt: 126 | prefs = Preferences.current_prefs() 127 | default_reminder_time = format_time(prefs.reminder_time, 'short') 128 | due_date_hint = ' on the due date' if task.due_date else '' 129 | wf.add_item(f"Reminder at {default_reminder_time}{due_date_hint}", f"e.g. r {default_reminder_time}", 130 | autocomplete=f" {task.phrase_with(reminder_date='remind me at %s' % format_time(prefs.reminder_time, 'short'))} ", 131 | icon=icons.REMINDER) 132 | wf.add_item(f"At noon{due_date_hint}", 'e.g. reminder noon', 133 | autocomplete=f" {task.phrase_with(reminder_date='remind me at noon')} ", 134 | icon=icons.REMINDER) 135 | wf.add_item(f"At 8:00 PM{due_date_hint}", 'e.g. remind at 8:00 PM', 136 | autocomplete=f" {task.phrase_with(reminder_date='remind me at 8:00pm')} ", 137 | icon=icons.REMINDER) 138 | wf.add_item(f"At dinner{due_date_hint}", 'e.g. alarm at dinner', 139 | autocomplete=f" {task.phrase_with(reminder_date='remind me at dinner')} ", 140 | icon=icons.REMINDER) 141 | wf.add_item('Today at 6:00 PM', 'e.g. remind me today at 6pm', 142 | autocomplete=f" {task.phrase_with(reminder_date='remind me today at 6:00pm')} ", 143 | icon=icons.REMINDER) 144 | wf.add_item('Remove reminder', autocomplete=f" {task.phrase_with(reminder_date=False)}", icon=icons.CANCEL) 145 | 146 | # Main menu for tasks 147 | else: 148 | wf.add_item(f"{task.list_title} – create a new task...", subtitle, arg='--stored-query', 149 | valid=task.title != '', icon=icons.TASK) \ 150 | .add_modifier(key='alt', subtitle=f"…then edit it in the ToDo app {subtitle}") 151 | 152 | title = 'Change folder' if task.list_title else 'Select a folder' 153 | wf.add_item(title, f"Prefix the task, e.g. Automotive: {task.title}", 154 | autocomplete=f" {task.phrase_with(list_title=True)}", icon=icons.LIST) 155 | 156 | title = 'Change the due date' if task.due_date else 'Set a due date' 157 | wf.add_item(title, '"due" followed by any date-related phrase, e.g. due next Tuesday; due May 4', 158 | autocomplete=f" {task.phrase_with(due_date=True)}", icon=icons.CALENDAR) 159 | 160 | title = 'Change the recurrence' if task.recurrence_type else 'Make it a recurring task' 161 | wf.add_item(title, '"every" followed by a unit of time, e.g. every 2 months; every year; every 4w', 162 | autocomplete=f" {task.phrase_with(recurrence=True)}", icon=icons.RECURRENCE) 163 | 164 | title = 'Change the reminder' if task.reminder_date else 'Set a reminder' 165 | wf.add_item(title, '"remind me" followed by a time and/or date, e.g. remind me at noon; r 10am; alarm 8:45p', 166 | autocomplete=f" {task.phrase_with(reminder_date=True)}", icon=icons.REMINDER) 167 | 168 | if task.starred: 169 | wf.add_item('Remove star', 'Remove * from the task', 170 | autocomplete=f" {task.phrase_with(starred=False)}", icon=icons.STAR_REMOVE) 171 | else: 172 | wf.add_item('Star', 'End the task with * (asterisk)', 173 | autocomplete=f" {task.phrase_with(starred=True)}", icon=icons.STAR) 174 | 175 | wf.add_item('Main menu', autocomplete='', icon=icons.BACK) 176 | 177 | def commit(args, modifier=None): 178 | from mstodo.api import tasks 179 | from mstodo.sync import background_sync 180 | 181 | task = _task(args) 182 | prefs = Preferences.current_prefs() 183 | 184 | prefs.last_taskfolder_id = task.list_id 185 | 186 | req = tasks.create_task(task.list_id, task.title, 187 | assignee_id=task.assignee_id, 188 | recurrence_type=task.recurrence_type, 189 | recurrence_count=task.recurrence_count, 190 | due_date=task.due_date, 191 | reminder_date=task.reminder_date, 192 | starred=task.starred, 193 | completed=task.completed, 194 | note=task.note) 195 | 196 | if req.status_code == codes.created: 197 | notify(title="Task creation success", message=f"The task was added to {task.list_title}") 198 | background_sync() 199 | if modifier == 'alt': 200 | import webbrowser 201 | webbrowser.open(f"ms-to-do://search/{task.title}") 202 | elif req.status_code > 400: 203 | notify(title="Task creation error", message=req.json()['error']['message']) 204 | else: 205 | notify(title="Task creation error", message="Unknown error. Try again or raise an issue on github") 206 | -------------------------------------------------------------------------------- /src/mstodo/handlers/preferences.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from peewee import OperationalError 3 | 4 | from workflow import MATCH_ALL, MATCH_ALLCHARS 5 | from workflow.notify import notify 6 | 7 | from mstodo import icons 8 | from mstodo.models.preferences import Preferences, DEFAULT_TASKFOLDER_MOST_RECENT 9 | from mstodo.models.user import User 10 | from mstodo.util import format_time, parsedatetime_calendar, relaunch_alfred, user_locale, wf_wrapper, SYMBOLS 11 | 12 | wf = wf_wrapper() 13 | 14 | def _parse_time(phrase): 15 | from datetime import date, time 16 | 17 | cal = parsedatetime_calendar() 18 | 19 | # Use a sourceTime so that time expressions are relative to 00:00:00 20 | # rather than the current time 21 | datetime_info = cal.parse(phrase, sourceTime=date.today().timetuple()) 22 | 23 | # Ensure that only a time was provided and not a date 24 | if datetime_info[1].hasTime: 25 | return time(*datetime_info[0][3:5]) 26 | return None 27 | 28 | def _format_time_offset(dt): 29 | if dt is None: 30 | return 'disabled' 31 | 32 | offset = [] 33 | 34 | if dt.hour > 0: 35 | offset.append(f"{dt.hour}h") 36 | if dt.minute > 0: 37 | offset.append(f"{dt.minute}m") 38 | 39 | return ' '.join(offset) 40 | 41 | def display(args): 42 | prefs = Preferences.current_prefs() 43 | 44 | if 'reminder' in args: 45 | reminder_time = _parse_time(' '.join(args)) 46 | 47 | if reminder_time is not None: 48 | wf.add_item( 49 | 'Change default reminder time', 50 | f"{SYMBOLS['reminder']} {format_time(reminder_time, 'short')}", 51 | arg=' '.join(args), valid=True, icon=icons.REMINDER 52 | ) 53 | else: 54 | wf.add_item( 55 | 'Type a new reminder time', 56 | 'Date offsets like the morning before the due date are not supported yet', 57 | valid=False, icon=icons.REMINDER 58 | ) 59 | 60 | wf.add_item( 61 | 'Cancel', 62 | autocomplete='-pref', icon=icons.BACK 63 | ) 64 | elif 'reminder_today' in args: 65 | reminder_today_offset = _parse_time(' '.join(args)) 66 | 67 | if reminder_today_offset is not None: 68 | wf.add_item( 69 | 'Set a custom reminder offset', 70 | f"{SYMBOLS['reminder']} now + {_format_time_offset(reminder_today_offset)}", 71 | arg=' '.join(args), valid=True, icon=icons.REMINDER 72 | ) 73 | else: 74 | wf.add_item( 75 | 'Type a custom reminder offset', 76 | 'Use the formats hh:mm or 2h 5m', 77 | valid=False, icon=icons.REMINDER 78 | ) 79 | 80 | wf.add_item( 81 | '30 minutes', 82 | arg='-pref reminder_today 30m', valid=True, icon=icons.REMINDER 83 | ) 84 | 85 | wf.add_item( 86 | '1 hour', 87 | '(default)', 88 | arg='-pref reminder_today 1h', valid=True, icon=icons.REMINDER 89 | ) 90 | 91 | wf.add_item( 92 | '90 minutes', 93 | arg='-pref reminder_today 90m', valid=True, icon=icons.REMINDER 94 | ) 95 | 96 | wf.add_item( 97 | 'Always use the default reminder time', 98 | 'Avoids adjusting the reminder based on the current date', 99 | arg='-pref reminder_today disabled', valid=True, icon=icons.CANCEL 100 | ) 101 | 102 | wf.add_item( 103 | 'Cancel', 104 | autocomplete='-pref', icon=icons.BACK 105 | ) 106 | elif 'default_folder' in args: 107 | taskfolders = wf.stored_data('taskfolders') 108 | matching_taskfolders = taskfolders 109 | 110 | if len(args) > 2: 111 | taskfolder_query = ' '.join(args[2:]) 112 | if taskfolder_query: 113 | matching_taskfolders = wf.filter( 114 | taskfolder_query, 115 | taskfolders, 116 | lambda f: f['title'], 117 | # Ignore MATCH_ALLCHARS which is expensive and inaccurate 118 | match_on=MATCH_ALL ^ MATCH_ALLCHARS 119 | ) 120 | 121 | for i, f in enumerate(matching_taskfolders): 122 | if i == 1: 123 | wf.add_item( 124 | 'Most recently used folder', 125 | 'Default to the last folder to which a task was added', 126 | arg=f"-pref default_folder {DEFAULT_TASKFOLDER_MOST_RECENT}", 127 | valid=True, icon=icons.RECURRENCE 128 | ) 129 | icon = icons.INBOX if f['isDefaultFolder'] else icons.LIST 130 | wf.add_item( 131 | f['title'], 132 | arg=f"-pref default_folder {f['id']}", 133 | valid=True, icon=icon 134 | ) 135 | 136 | wf.add_item( 137 | 'Cancel', 138 | autocomplete='-pref', icon=icons.BACK 139 | ) 140 | else: 141 | current_user = None 142 | taskfolders = wf.stored_data('taskfolders') 143 | loc = user_locale() 144 | default_folder_name = 'Tasks' 145 | 146 | try: 147 | current_user = User.get() 148 | except User.DoesNotExist: 149 | pass 150 | except OperationalError: 151 | from mstodo.sync import background_sync 152 | background_sync() 153 | 154 | if prefs.default_taskfolder_id == DEFAULT_TASKFOLDER_MOST_RECENT: 155 | default_folder_name = 'Most recent folder' 156 | else: 157 | default_taskfolder_id = prefs.default_taskfolder_id 158 | default_folder_name = next( 159 | (f['title'] for f in taskfolders if f['id'] == default_taskfolder_id), 160 | 'Tasks' 161 | ) 162 | 163 | if current_user and current_user.userPrincipalName: 164 | #@TODO double check this handling if the user schema changes on move to new APIs 165 | wf.add_item( 166 | 'Sign out', 167 | f"You are logged in as {current_user.userPrincipalName}", 168 | autocomplete='-logout', icon=icons.CANCEL 169 | ) 170 | 171 | wf.add_item( 172 | 'Show completed tasks', 173 | 'Includes completed tasks in search results', 174 | arg='-pref show_completed_tasks', valid=True, 175 | icon=icons.TASK_COMPLETED if prefs.show_completed_tasks else icons.TASK 176 | ) 177 | 178 | wf.add_item( 179 | 'Default reminder time', 180 | f"{SYMBOLS['reminder']} {format_time(prefs.reminder_time, 'short')} Reminders without a specific time \ 181 | will be set to this time", 182 | autocomplete='-pref reminder ', icon=icons.REMINDER 183 | ) 184 | 185 | wf.add_item( 186 | 'Default reminder when due today', 187 | f"""{SYMBOLS['reminder']} {_format_time_offset(prefs.reminder_today_offset)} Default reminder time \ 188 | for tasks due today is {'relative to the current time' if prefs.reminder_today_offset else f"always {format_time(prefs.reminder_time, 'short')}"}""", 189 | autocomplete='-pref reminder_today ', icon=icons.REMINDER 190 | ) 191 | 192 | wf.add_item( 193 | 'Default folder', 194 | f"{default_folder_name} Change the default folder when creating new tasks", 195 | autocomplete='-pref default_folder ', icon=icons.LIST 196 | ) 197 | 198 | wf.add_item( 199 | 'Automatically set a reminder on the due date', 200 | 'Sets a default reminder for tasks with a due date.', 201 | arg='-pref automatic_reminders', valid=True, 202 | icon=icons.TASK_COMPLETED if prefs.automatic_reminders else icons.TASK 203 | ) 204 | 205 | if loc != 'en_US' or prefs.date_locale: 206 | wf.add_item( 207 | 'Force US English for dates', 208 | f"Rather than the current locale ({loc})", 209 | arg='-pref force_en_US', valid=True, 210 | icon=icons.TASK_COMPLETED if prefs.date_locale == 'en_US' else icons.TASK 211 | ) 212 | 213 | wf.add_item( 214 | 'Require explicit due keyword', 215 | 'Requires the due keyword to avoid accidental due date extraction', 216 | arg='-pref explicit_keywords', valid=True, 217 | icon=icons.TASK_COMPLETED if prefs.explicit_keywords else icons.TASK 218 | ) 219 | 220 | wf.add_item( 221 | 'Check for experimental updates to this workflow', 222 | 'The workflow automatically checks for updates; enable this to include pre-releases', 223 | arg=':pref prerelease_channel', valid=True, 224 | icon=icons.TASK_COMPLETED if prefs.prerelease_channel else icons.TASK 225 | ) 226 | 227 | wf.add_item( 228 | 'Force sync', 229 | 'The workflow syncs automatically, but feel free to be forcible.', 230 | arg='-pref sync', valid=True, icon=icons.SYNC 231 | ) 232 | 233 | wf.add_item( 234 | 'Switch theme', 235 | 'Toggle between light and dark icons', 236 | arg='-pref retheme', 237 | valid=True, 238 | icon=icons.PAINTBRUSH 239 | ) 240 | 241 | wf.add_item( 242 | 'Main menu', 243 | autocomplete='', icon=icons.BACK 244 | ) 245 | 246 | def commit(args, modifier=None): 247 | prefs = Preferences.current_prefs() 248 | relaunch_command = '-pref' 249 | if '--alfred' in args: 250 | relaunch_command = ' '.join(args[args.index('--alfred') + 1:]) 251 | if 'sync' in args: 252 | from mstodo.sync import sync 253 | sync(background='background' in args) 254 | relaunch_command = None 255 | elif 'show_completed_tasks' in args: 256 | prefs.show_completed_tasks = not prefs.show_completed_tasks 257 | 258 | if prefs.show_completed_tasks: 259 | notify( 260 | title='Preferences changed', 261 | message='Completed tasks are now visible in the workflow' 262 | ) 263 | else: 264 | notify( 265 | title='Preferences changed', 266 | message='Completed tasks will not be visible in the workflow' 267 | ) 268 | elif 'default_folder' in args: 269 | default_taskfolder_id = None 270 | taskfolders = wf.stored_data('taskfolders') 271 | if len(args) > 2: 272 | default_taskfolder_id = args[2] 273 | prefs.default_taskfolder_id = default_taskfolder_id 274 | if default_taskfolder_id: 275 | default_folder_name = next( 276 | (f['title'] for f in taskfolders if f['id'] == default_taskfolder_id), 277 | 'most recent' 278 | ) 279 | notify( 280 | title='Preferences changed', 281 | message=f"Tasks will be added to your {default_folder_name} folder by default" 282 | ) 283 | else: 284 | notify( 285 | title='Preferences changed', 286 | message='Tasks will be added to the Tasks folder by default' 287 | ) 288 | elif 'explicit_keywords' in args: 289 | prefs.explicit_keywords = not prefs.explicit_keywords 290 | if prefs.explicit_keywords: 291 | notify( 292 | title='Preferences changed', 293 | message='Remember to use the "due" keyword' 294 | ) 295 | else: 296 | notify( 297 | title='Preferences changed', 298 | message='Implicit due dates enabled (e.g. "Recycling tomorrow")' 299 | ) 300 | elif 'reminder' in args: 301 | reminder_time = _parse_time(' '.join(args)) 302 | if reminder_time is not None: 303 | prefs.reminder_time = reminder_time 304 | notify( 305 | title='Preferences changed', 306 | message=f"Reminders will now default to {format_time(reminder_time, 'short')}" 307 | ) 308 | elif 'reminder_today' in args: 309 | reminder_today_offset = None 310 | if not 'disabled' in args: 311 | reminder_today_offset = _parse_time(' '.join(args)) 312 | prefs.reminder_today_offset = reminder_today_offset 313 | notify( 314 | title='Preferences changed', 315 | message=f"The offset for current-day reminders is now {_format_time_offset(reminder_today_offset)}" 316 | ) 317 | elif 'automatic_reminders' in args: 318 | prefs.automatic_reminders = not prefs.automatic_reminders 319 | if prefs.automatic_reminders: 320 | notify( 321 | title='Preferences changed', 322 | message='A reminder will automatically be set for due tasks' 323 | ) 324 | else: 325 | notify( 326 | title='Preferences changed', 327 | message='A reminder will not be added automatically' 328 | ) 329 | elif 'retheme' in args: 330 | prefs.icon_theme = 'light' if icons.icon_theme() == 'dark' else 'dark' 331 | notify( 332 | title='Preferences changed', 333 | message=f"The workflow is now using the {prefs.icon_theme} icon theme" 334 | ) 335 | elif 'prerelease_channel' in args: 336 | prefs.prerelease_channel = not prefs.prerelease_channel 337 | # Update the workflow settings and reverify the update data 338 | wf.check_update(True) 339 | if prefs.prerelease_channel: 340 | notify( 341 | title='Preferences changed', 342 | message='The workflow will prompt you to update to experimental pre-releases' 343 | ) 344 | else: 345 | notify( 346 | title='Preferences changed', 347 | message='The workflow will only prompt you to update to final releases' 348 | ) 349 | elif 'force_en_US' in args: 350 | if prefs.date_locale: 351 | prefs.date_locale = None 352 | notify( 353 | title='Preferences changed', 354 | message='The workflow will expect your local language and date format' 355 | ) 356 | else: 357 | prefs.date_locale = 'en_US' 358 | notify( 359 | title='Preferences changed', 360 | message='The workflow will expect dates in US English' 361 | ) 362 | 363 | if relaunch_command: 364 | relaunch_alfred(f"td{relaunch_command}") 365 | -------------------------------------------------------------------------------- /src/mstodo/handlers/route.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from mstodo import icons 5 | from mstodo.auth import is_authorised 6 | from mstodo.sync import background_sync_if_necessary 7 | from mstodo.util import wf_wrapper 8 | 9 | COMMAND_PATTERN = re.compile(r'^[^\w\s]+', re.UNICODE) 10 | ACTION_PATTERN = re.compile(r'^\W+', re.UNICODE) 11 | 12 | def route(args): 13 | handler = None 14 | command = [] 15 | command_string = '' 16 | action = 'none' 17 | logged_in = is_authorised() 18 | wf = wf_wrapper() 19 | 20 | # Read the stored query, which will correspond to the user's alfred query 21 | # as of the very latest keystroke. This may be different than the query 22 | # when this script was launched due to the startup latency. 23 | if args[0] == '--stored-query': 24 | query_file = wf.workflowfile('.query') 25 | with open(query_file, 'r') as fp: 26 | command_string = wf.decode(fp.read()) 27 | os.remove(query_file) 28 | # Otherwise take the command from the first command line argument 29 | elif args: 30 | command_string = args[0] 31 | 32 | command_string = re.sub(COMMAND_PATTERN, '', command_string) 33 | command = re.split(r' +', command_string) 34 | 35 | if command: 36 | action = re.sub(ACTION_PATTERN, '', command[0]) or 'none' 37 | 38 | if 'about'.find(action) == 0: 39 | from mstodo.handlers import about 40 | handler = about 41 | elif not logged_in: 42 | from mstodo.handlers import login 43 | handler = login 44 | elif 'folder'.find(action) == 0: 45 | from mstodo.handlers import taskfolder 46 | handler = taskfolder 47 | elif 'task'.find(action) == 0: 48 | from mstodo.handlers import task 49 | handler = task 50 | elif 'search'.find(action) == 0: 51 | from mstodo.handlers import search 52 | handler = search 53 | elif 'due'.find(action) == 0: 54 | from mstodo.handlers import due 55 | handler = due 56 | elif 'upcoming'.find(action) == 0: 57 | from mstodo.handlers import upcoming 58 | handler = upcoming 59 | elif 'completed'.find(action) == 0: 60 | from mstodo.handlers import completed 61 | handler = completed 62 | elif 'logout'.find(action) == 0: 63 | from mstodo.handlers import logout 64 | handler = logout 65 | elif 'pref'.find(action) == 0: 66 | from mstodo.handlers import preferences 67 | handler = preferences 68 | # If the command starts with a space (no special keywords), the workflow 69 | # creates a new task 70 | elif not command_string: 71 | from mstodo.handlers import welcome 72 | handler = welcome 73 | else: 74 | from mstodo.handlers import new_task 75 | handler = new_task 76 | 77 | if handler: 78 | if '--commit' in args: 79 | modifier = re.search(r'--(alt|cmd|ctrl|fn)\b', ' '.join(args)) 80 | 81 | if modifier: 82 | modifier = modifier.group(1) 83 | 84 | handler.commit(command, modifier) 85 | else: 86 | if wf.update_available: 87 | wf.add_item( 88 | 'An update is available!', 89 | f"Update the ToDo workflow from version {wf.settings['__workflow_last_version']} \ 90 | to {wf.cached_data('__workflow_latest_version').get('version')}", 91 | arg='-about update', valid=True, icon=icons.DOWNLOAD 92 | ) 93 | handler.display(command) 94 | wf.send_feedback() 95 | 96 | if logged_in: 97 | background_sync_if_necessary() 98 | -------------------------------------------------------------------------------- /src/mstodo/handlers/search.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import re 4 | import logging 5 | 6 | from peewee import fn, OperationalError 7 | from workflow import MATCH_ALL, MATCH_ALLCHARS 8 | 9 | from mstodo import icons 10 | from mstodo.models.taskfolder import TaskFolder 11 | from mstodo.models.preferences import Preferences 12 | from mstodo.models.task import Task 13 | from mstodo.sync import background_sync 14 | from mstodo.util import wf_wrapper 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | _hashtag_prompt_pattern = re.compile(r'#\S*$', re.UNICODE) 19 | 20 | def display(args): 21 | query = ' '.join(args[1:]) 22 | wf = wf_wrapper() 23 | prefs = Preferences.current_prefs() 24 | matching_hashtags = [] 25 | 26 | if not query: 27 | wf.add_item('Begin typing to search tasks', '', icon=icons.SEARCH) 28 | 29 | hashtag_match = re.search(_hashtag_prompt_pattern, query) 30 | if hashtag_match: 31 | from mstodo.models.hashtag import Hashtag 32 | 33 | hashtag_prompt = hashtag_match.group().lower() 34 | hashtags = Hashtag.select().where(Hashtag.id.contains(hashtag_prompt)).order_by(fn.Lower(Hashtag.tag).asc()) 35 | 36 | for hashtag in hashtags: 37 | # If there is an exact match, do not show hashtags 38 | if hashtag.id == hashtag_prompt: 39 | matching_hashtags = [] 40 | break 41 | 42 | matching_hashtags.append(hashtag) 43 | 44 | # Show hashtag prompt if there is more than one matching hashtag or the 45 | # hashtag being typed does not exactly match the single matching hashtag 46 | if len(matching_hashtags) > 0: 47 | for hashtag in matching_hashtags: 48 | wf.add_item(hashtag.tag[1:], '', 49 | autocomplete=f"-search {query[:hashtag_match.start()]}{hashtag.tag} ", 50 | icon=icons.HASHTAG) 51 | 52 | else: 53 | conditions = True 54 | taskfolders = wf.stored_data('taskfolders') 55 | matching_taskfolders = None 56 | query = ' '.join(args[1:]).strip() 57 | taskfolder_query = None 58 | 59 | # Show all task folders on the main search screen 60 | if not query: 61 | matching_taskfolders = taskfolders 62 | # Filter task folders when colon is used 63 | if ':' in query: 64 | matching_taskfolders = taskfolders 65 | components = re.split(r':\s*', query, 1) 66 | taskfolder_query = components[0] 67 | if taskfolder_query: 68 | matching_taskfolders = wf.filter( 69 | taskfolder_query, 70 | taskfolders if taskfolders else [], 71 | lambda folder: folder['title'], 72 | # Ignore MATCH_ALLCHARS which is expensive and inaccurate 73 | match_on=MATCH_ALL ^ MATCH_ALLCHARS 74 | ) 75 | 76 | # If no matching task folder search against all tasks 77 | if matching_taskfolders: 78 | query = components[1] if len(components) > 1 else '' 79 | 80 | # If there is a task folder exactly matching the query ignore 81 | # anything else. This takes care of taskfolders that are substrings 82 | # of other taskfolders 83 | if len(matching_taskfolders) > 1: 84 | for folder in matching_taskfolders: 85 | if folder['title'].lower() == taskfolder_query.lower(): 86 | matching_taskfolders = [folder] 87 | break 88 | 89 | if matching_taskfolders: 90 | if not taskfolder_query: 91 | wf.add_item('Browse by hashtag', autocomplete='-search #', icon=icons.HASHTAG) 92 | 93 | if len(matching_taskfolders) > 1: 94 | for folder in matching_taskfolders: 95 | icon = icons.INBOX if folder['isDefaultFolder'] else icons.LIST 96 | wf.add_item(folder['title'], autocomplete=f"-search {folder['title']}: ", icon=icon) 97 | else: 98 | conditions = conditions & (Task.list == matching_taskfolders[0]['id']) 99 | 100 | if not matching_taskfolders or len(matching_taskfolders) <= 1: 101 | for arg in query.split(' '): 102 | if len(arg) > 1: 103 | conditions = conditions & (Task.title.contains(arg) | TaskFolder.title.contains(arg)) 104 | 105 | if conditions: 106 | if not prefs.show_completed_tasks: 107 | conditions = (Task.status != 'completed') & conditions 108 | 109 | tasks = Task.select().where(Task.list.is_null(False) & conditions) 110 | 111 | tasks = tasks.join(TaskFolder).order_by(Task.lastModifiedDateTime.desc(), TaskFolder.changeKey.asc()) 112 | 113 | # Avoid excessive results 114 | tasks = tasks.limit(50) 115 | 116 | try: 117 | for task in tasks: 118 | wf.add_item(f"{task.list_title} – {task.title}", task.subtitle(), 119 | autocomplete=f"-task {task.id} ", 120 | icon=icons.TASK_COMPLETED if task.status == 'completed' else icons.TASK) 121 | except OperationalError: 122 | background_sync() 123 | 124 | 125 | if prefs.show_completed_tasks: 126 | wf.add_item('Hide completed tasks', arg=f"-pref show_completed_tasks --alfred {' '.join(args)}", 127 | valid=True, icon=icons.HIDDEN) 128 | else: 129 | wf.add_item('Show completed tasks', arg=f"-pref show_completed_tasks --alfred {' '.join(args)}", 130 | valid=True, icon=icons.VISIBLE) 131 | 132 | wf.add_item('New search', autocomplete='-search ', icon=icons.CANCEL) 133 | wf.add_item('Main menu', autocomplete='', icon=icons.BACK) 134 | 135 | # Make sure tasks are up-to-date while searching 136 | background_sync() 137 | 138 | def commit(args, modifier=None): 139 | action = args[1] 140 | -------------------------------------------------------------------------------- /src/mstodo/handlers/task.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import logging 4 | from datetime import date 5 | 6 | from workflow.notify import notify 7 | from requests import codes 8 | from mstodo import icons 9 | from mstodo.models.task import Task 10 | from mstodo.models.task_parser import TaskParser 11 | from mstodo.util import wf_wrapper 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | def _task(args): 16 | return TaskParser(' '.join(args)) 17 | 18 | def display(args): 19 | task_id = args[1] 20 | wf = wf_wrapper() 21 | task = None 22 | 23 | try: 24 | task = Task.get(Task.id == task_id) 25 | except Task.DoesNotExist: 26 | pass 27 | 28 | if not task: 29 | wf.add_item('Unknown task', 'The ID does not match a task', autocomplete='', icon=icons.BACK) 30 | else: 31 | subtitle = task.subtitle() 32 | 33 | if task.status == 'completed': 34 | wf.add_item('Mark task not completed', subtitle, arg=' '.join(args + ['toggle-completion']), 35 | valid=True, icon=icons.TASK_COMPLETED) 36 | else: 37 | wf.add_item('Complete this task', subtitle, arg=' '.join(args + ['toggle-completion']), 38 | valid=True, icon=icons.TASK) \ 39 | .add_modifier(key='alt', subtitle=f"…and set due today {subtitle}") 40 | 41 | wf.add_item('View in ToDo', 'View and edit this task in the ToDo app', 42 | arg=' '.join(args + ['view']), valid=True, icon=icons.OPEN) 43 | 44 | if task.recurrence_type and not task.status == 'completed': 45 | wf.add_item('Delete', 'Delete this task and cancel recurrence', 46 | arg=' '.join(args + ['delete']), valid=True, icon=icons.TRASH) 47 | else: 48 | wf.add_item('Delete', 'Delete this task', arg=' '.join(args + ['delete']), 49 | valid=True, icon=icons.TRASH) 50 | 51 | wf.add_item('Main menu', autocomplete='', icon=icons.BACK) 52 | 53 | def commit(args, modifier=None): 54 | from mstodo.api import tasks 55 | from mstodo.sync import background_sync 56 | 57 | task_id = args[1] 58 | action = args[2] 59 | task = Task.get(Task.id == task_id) 60 | 61 | if action == 'toggle-completion': 62 | due_date = task.dueDateTime 63 | 64 | if modifier == 'alt': 65 | due_date = date.today() 66 | 67 | if task.status == 'completed': 68 | res = tasks.update_task(task.id, task.changeKey, completed=False, due_date=due_date) 69 | if res.status_code == codes.ok: 70 | notify( 71 | title='Task updated', 72 | message='The task was marked incomplete' 73 | ) 74 | else: 75 | log.debug(f"An unhandled error occurred when attempting to complete task {task.id}") 76 | #@TODO raise these as errors properly 77 | else: 78 | res = tasks.update_task(task.id, task.changeKey, completed=True, due_date=due_date) 79 | if res.status_code == codes.ok: 80 | notify( 81 | title='Task updated', 82 | message='The task was marked complete' 83 | ) 84 | else: 85 | log.debug(f"An unhandled error occurred when attempting to update task {task.id}") 86 | 87 | elif action == 'delete': 88 | res = tasks.delete_task(task.id, task.changeKey) 89 | if res.status_code == codes.no_content: 90 | notify( 91 | title='Task updated', 92 | message='The task was marked deleted' 93 | ) 94 | else: 95 | log.debug(f"An unhandled error occurred when attempting to update task {task.id}") 96 | 97 | elif action == 'view': 98 | import webbrowser 99 | webbrowser.open(f"ms-to-do://search/{task.title}") 100 | 101 | background_sync() 102 | -------------------------------------------------------------------------------- /src/mstodo/handlers/taskfolder.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from workflow.notify import notify 3 | from requests import codes 4 | from mstodo import icons 5 | from mstodo.util import wf_wrapper 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | def _taskfolder_name(args): 10 | return ' '.join(args[1:]).strip() 11 | 12 | def display(args): 13 | wf = wf_wrapper() 14 | taskfolder_name = _taskfolder_name(args) 15 | subtitle = taskfolder_name if taskfolder_name else 'Type the name of the task folder' 16 | 17 | wf.add_item('New folder...', subtitle, arg='--stored-query', 18 | valid=taskfolder_name != '', icon=icons.LIST_NEW) 19 | 20 | wf.add_item( 21 | 'Main menu', 22 | autocomplete='', icon=icons.BACK 23 | ) 24 | 25 | def commit(args, modifier=None): 26 | from mstodo.api import taskfolders 27 | from mstodo.sync import background_sync 28 | 29 | taskfolder_name = _taskfolder_name(args) 30 | 31 | req = taskfolders.create_taskfolder(taskfolder_name) 32 | if req.status_code == codes.created: 33 | notify( 34 | title='Taskfolder updated', 35 | message=f"The folder {taskfolder_name} was created" 36 | ) 37 | background_sync() 38 | elif req.status_code > 400: 39 | log.debug(str(req.json()['error']['message'])) 40 | else: 41 | log.debug("Unknown API error. Please try again") 42 | -------------------------------------------------------------------------------- /src/mstodo/handlers/upcoming.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from datetime import datetime, timedelta 4 | import logging 5 | 6 | from peewee import OperationalError 7 | 8 | from mstodo import icons 9 | from mstodo.models.preferences import Preferences 10 | from mstodo.models.task import Task 11 | from mstodo.models.taskfolder import TaskFolder 12 | from mstodo.sync import background_sync, background_sync_if_necessary 13 | from mstodo.util import relaunch_alfred, wf_wrapper 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | _durations = [ 18 | { 19 | 'days': 7, 20 | 'label': 'In the next week', 21 | 'subtitle': 'Show tasks that are due in the next 7 days' 22 | }, 23 | { 24 | 'days': 14, 25 | 'label': 'In the next 2 weeks', 26 | 'subtitle': 'Show tasks that are due in the next 14 days' 27 | }, 28 | { 29 | 'days': 30, 30 | 'label': 'In the next month', 31 | 'subtitle': 'Show tasks that are due in the next 30 days' 32 | }, 33 | { 34 | 'days': 90, 35 | 'label': 'In the next 3 months', 36 | 'subtitle': 'Show tasks that are due in the next 90 days' 37 | } 38 | ] 39 | 40 | 41 | def _default_label(days): 42 | return f"In the next {days} day{'' if days == 1 else 's'}" 43 | 44 | 45 | def _duration_info(days): 46 | duration_info = [d for d in _durations if d['days'] == days] 47 | 48 | if len(duration_info) > 0: 49 | return duration_info[0] 50 | return { 51 | 'days': days, 52 | 'label': _default_label(days), 53 | 'subtitle': 'Your custom duration', 54 | 'custom': True 55 | } 56 | 57 | 58 | def display(args): 59 | wf = wf_wrapper() 60 | prefs = Preferences.current_prefs() 61 | command = args[1] if len(args) > 1 else None 62 | duration_info = _duration_info(prefs.upcoming_duration) 63 | 64 | if command == 'duration': 65 | selected_duration = prefs.upcoming_duration 66 | 67 | # Apply selected duration option 68 | if len(args) > 2: 69 | try: 70 | selected_duration = int(args[2]) 71 | except: 72 | pass 73 | 74 | duration_info = _duration_info(selected_duration) 75 | 76 | if 'custom' in duration_info: 77 | wf.add_item(duration_info['label'], duration_info['subtitle'], 78 | arg=f"-upcoming duration {duration_info['days']}", valid=True, 79 | icon=icons.RADIO_SELECTED if duration_info['days'] == selected_duration else icons.RADIO 80 | ) 81 | 82 | for duration_info in _durations: 83 | wf.add_item(duration_info['label'], duration_info['subtitle'], 84 | arg=f"-upcoming duration {duration_info['days']}", valid=True, 85 | icon=icons.RADIO_SELECTED if duration_info['days'] == selected_duration else icons.RADIO) 86 | 87 | wf.add_item('Back', autocomplete='-upcoming ', icon=icons.BACK) 88 | 89 | return 90 | 91 | # Force a sync if not done recently or join if already running 92 | background_sync_if_necessary() 93 | 94 | wf.add_item(duration_info['label'], subtitle='Change the duration for upcoming tasks', 95 | autocomplete='-upcoming duration ', icon=icons.UPCOMING) 96 | 97 | conditions = True 98 | 99 | # Build task title query based on the args 100 | for arg in args[1:]: 101 | if len(arg) > 1: 102 | conditions = conditions & (Task.title.contains(arg) | TaskFolder.title.contains(arg)) 103 | 104 | if conditions is None: 105 | conditions = True 106 | 107 | tasks = Task.select().join(TaskFolder).where( 108 | (Task.status != 'completed') & 109 | (Task.dueDateTime < datetime.now() + timedelta(days=duration_info['days'] + 1)) & 110 | (Task.dueDateTime > datetime.now() + timedelta(days=1)) & 111 | Task.list.is_null(False) & 112 | conditions 113 | )\ 114 | .order_by(Task.dueDateTime.asc(), Task.reminderDateTime.asc(), Task.lastModifiedDateTime.asc()) 115 | 116 | try: 117 | for task in tasks: 118 | wf.add_item(f"{task.list_title} – {task.title}", task.subtitle(), autocomplete=f"-task {task.id} ", 119 | icon=icons.TASK_COMPLETED if task.status == 'completed' else icons.TASK) 120 | except OperationalError: 121 | background_sync() 122 | 123 | wf.add_item('Main menu', autocomplete='', icon=icons.BACK) 124 | 125 | def commit(args, modifier=None): 126 | relaunch_command = None 127 | prefs = Preferences.current_prefs() 128 | action = args[1] 129 | 130 | if action == 'duration': 131 | relaunch_command = 'td-upcoming ' 132 | prefs.upcoming_duration = int(args[2]) 133 | 134 | if relaunch_command: 135 | relaunch_alfred(relaunch_command) 136 | -------------------------------------------------------------------------------- /src/mstodo/handlers/welcome.py: -------------------------------------------------------------------------------- 1 | from mstodo import icons 2 | from mstodo.util import wf_wrapper 3 | 4 | def display(args): 5 | wf = wf_wrapper() 6 | wf.add_item( 7 | 'New task...', 8 | 'Begin typing to add a new task', 9 | autocomplete=' ', 10 | icon=icons.TASK_COMPLETED 11 | ) 12 | 13 | wf.add_item( 14 | 'Due today', 15 | 'Due and overdue tasks', 16 | autocomplete='-due ', 17 | icon=icons.TODAY 18 | ) 19 | 20 | wf.add_item( 21 | 'Upcoming', 22 | 'Tasks due soon', 23 | autocomplete='-upcoming ', 24 | icon=icons.UPCOMING 25 | ) 26 | 27 | wf.add_item( 28 | 'Completed', 29 | 'Tasks recently completed', 30 | autocomplete='-completed ', 31 | icon=icons.YESTERDAY 32 | ) 33 | 34 | wf.add_item( 35 | 'Find and update tasks', 36 | 'Search or browse by folder', 37 | autocomplete='-search ', 38 | icon=icons.SEARCH 39 | ) 40 | 41 | wf.add_item( 42 | 'New folder', 43 | autocomplete='-folder ', 44 | icon=icons.LIST_NEW 45 | ) 46 | 47 | wf.add_item( 48 | 'Preferences', 49 | autocomplete='-pref ', 50 | icon=icons.PREFERENCES 51 | ) 52 | 53 | wf.add_item( 54 | 'About', 55 | 'Learn about the workflow and get support', 56 | autocomplete='-about ', 57 | icon=icons.INFO 58 | ) 59 | -------------------------------------------------------------------------------- /src/mstodo/icons.py: -------------------------------------------------------------------------------- 1 | from mstodo.models.preferences import Preferences 2 | from mstodo.util import wf_wrapper 3 | 4 | _ICON_THEME = None 5 | 6 | def alfred_is_dark(): 7 | # Formatted rgba(255,255,255,0.90) 8 | background_rgba = wf_wrapper().alfred_env['theme_background'] 9 | if background_rgba: 10 | rgb = [int(x) for x in background_rgba[5:-6].split(',')] 11 | return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255 < 0.5 12 | return False 13 | 14 | 15 | def icon_theme(): 16 | global _ICON_THEME 17 | if not _ICON_THEME: 18 | prefs = Preferences.current_prefs() 19 | 20 | if prefs.icon_theme: 21 | _ICON_THEME = prefs.icon_theme 22 | else: 23 | _ICON_THEME = 'light' if alfred_is_dark() else 'dark' 24 | 25 | return _ICON_THEME 26 | 27 | _icon_path = f"icons/{icon_theme()}/" 28 | 29 | ACCOUNT = _icon_path + 'account.png' 30 | BACK = _icon_path + 'back.png' 31 | CALENDAR = _icon_path + 'calendar.png' 32 | CANCEL = _icon_path + 'cancel.png' 33 | CHECKBOX = _icon_path + 'task.png' 34 | CHECKBOX_SELECTED = _icon_path + 'task_completed.png' 35 | CHECKMARK = _icon_path + 'checkmark.png' 36 | DISCUSS = _icon_path + 'discuss.png' 37 | DOWNLOAD = _icon_path + 'download.png' 38 | HASHTAG = _icon_path + 'hashtag.png' 39 | HELP = _icon_path + 'help.png' 40 | HIDDEN = _icon_path + 'hidden.png' 41 | INBOX = _icon_path + 'inbox.png' 42 | INFO = _icon_path + 'info.png' 43 | LINK = _icon_path + 'link.png' 44 | LIST = _icon_path + 'list.png' 45 | LIST_NEW = _icon_path + 'list_new.png' 46 | NEXT_WEEK = _icon_path + 'next_week.png' 47 | OPEN = _icon_path + 'open.png' 48 | PAINTBRUSH = _icon_path + 'paintbrush.png' 49 | PREFERENCES = _icon_path + 'preferences.png' 50 | RADIO = _icon_path + 'radio.png' 51 | RADIO_SELECTED = _icon_path + 'radio_selected.png' 52 | RECURRENCE = _icon_path + 'recurrence.png' 53 | REMINDER = _icon_path + 'reminder.png' 54 | SEARCH = _icon_path + 'search.png' 55 | SORT = _icon_path + 'sort.png' 56 | STAR = _icon_path + 'star.png' 57 | STAR_REMOVE = _icon_path + 'star_remove.png' 58 | SYNC = _icon_path + 'sync.png' 59 | TASK = _icon_path + 'task.png' 60 | TASK_COMPLETED = _icon_path + 'task_completed.png' 61 | TODAY = _icon_path + 'today.png' 62 | TOMORROW = _icon_path + 'tomorrow.png' 63 | TRASH = _icon_path + 'trash.png' 64 | UPCOMING = _icon_path + 'upcoming.png' 65 | VISIBLE = _icon_path + 'visible.png' 66 | YESTERDAY = _icon_path + 'yesterday.png' 67 | -------------------------------------------------------------------------------- /src/mstodo/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/src/mstodo/models/__init__.py -------------------------------------------------------------------------------- /src/mstodo/models/base.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | import logging 3 | import time 4 | 5 | from dateutil import parser 6 | from peewee import (DateField, DateTimeField, ForeignKeyField, Model, 7 | SqliteDatabase, TimeField) 8 | 9 | from mstodo.util import wf_wrapper 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | db = SqliteDatabase(wf_wrapper().datadir + '/mstodo.db') 14 | # This writes a SQLiteDB to ~/Library/Application Support/Alfred/Workflow Data/ 15 | 16 | def _balance_keys_for_insert(values): 17 | all_keys = set() 18 | for v in values: 19 | all_keys.update(v) 20 | 21 | balanced_values = [] 22 | for v in values: 23 | balanced = {} 24 | for k in all_keys: 25 | balanced[k] = v.get(k) 26 | balanced_values.append(balanced) 27 | 28 | return balanced_values 29 | 30 | class BaseModel(Model): 31 | """ 32 | Extends the Peewee model class and refines it for MS ToDo API structures. 33 | Holds methods to unpack APIs to database models, update data and perform 34 | actions on child items for an entity 35 | """ 36 | @classmethod 37 | def _api2model(cls, data): 38 | fields = copy(cls._meta.fields) 39 | model_data = {} 40 | 41 | # Map relationships, e.g. from user_id to user's 42 | for (field_name, field) in cls._meta.fields.items(): 43 | if field_name.endswith('_id'): 44 | fields[field_name[:-3]] = field 45 | elif isinstance(field, ForeignKeyField): 46 | fields[field_name + '_id'] = field 47 | 48 | # The Microsoft ToDo API does not include some falsy values. For 49 | # example, if a task is completed then marked incomplete the 50 | # updated data will not include a completed key, so we have to set 51 | # the defaults for everything that is not specified 52 | if field.default: 53 | model_data[field_name] = field.default 54 | elif field.null: 55 | model_data[field_name] = None 56 | 57 | # Map each data property to the correct field 58 | for (k, v) in data.items(): 59 | if k in fields: 60 | if isinstance(fields[k], (DateTimeField, DateField, TimeField)) and v is not None: 61 | model_data[fields[k].name] = parser.parse(v) 62 | else: 63 | model_data[fields[k].name] = v 64 | 65 | return model_data 66 | 67 | @classmethod 68 | def sync(cls): 69 | pass 70 | 71 | @classmethod 72 | def _perform_updates(cls, model_instances, update_items): 73 | start = time.time() 74 | # This creates a dict of all records within the database, indexable by id 75 | instances_by_id = dict((instance.id, instance) for instance in model_instances if instance) 76 | 77 | # Remove all update metadata and instances that have the same revision 78 | # before any additional processing on the metadata 79 | def revised(item): 80 | id = item['id'] 81 | # if api task is in database, has a changeKey and is unchanged 82 | if id in instances_by_id and 'changeKey' in item and instances_by_id[id].changeKey == item['changeKey']: 83 | del instances_by_id[id] # remove the item from our database list 84 | return False 85 | 86 | return True 87 | 88 | # changed items is the list of API data if it is updated based on the logic above 89 | changed_items = [item for item in update_items if revised(item)] 90 | 91 | # Map of id to the normalized item 92 | changed_items = dict((item['id'], cls._api2model(item)) for item in changed_items) 93 | all_instances = [] 94 | log.debug(f"Prepared {len(changed_items)} of {len(update_items)} {cls.__name__}s \ 95 | in {round(time.time() - start, 3)} seconds") 96 | 97 | # Update all the changed metadata and remove instances that no longer exist 98 | with db.atomic(): 99 | # For each item in the database that is either deleted or changed 100 | for id, instance in instances_by_id.items(): 101 | if not instance: 102 | continue 103 | if id in changed_items: 104 | changed_item = changed_items[id] 105 | # Create temp list with all changed items 106 | all_instances.append(instance) 107 | 108 | if cls._meta.has_children: 109 | log.debug(f"Syncing children of {instance}") 110 | instance._sync_children() 111 | cls.update(**changed_item).where(cls.id == id).execute() 112 | if changed_item.get('changeKey'): 113 | log.debug(f"Updated {instance} in db to revision {changed_item.get('changeKey')}") 114 | log.debug(f"with data {changed_item}") 115 | # remove changed items from list to leave only new items 116 | del changed_items[id] 117 | # The model does not exist anymore 118 | else: 119 | instance.delete_instance() 120 | log.debug(f"Deleted {instance} from db") 121 | 122 | # Bulk insert and retrieve 123 | new_values = list(changed_items.values()) 124 | 125 | # Insert new items in batches 126 | for i in range(0, len(new_values), 500): 127 | inserted_chunk = _balance_keys_for_insert(new_values[i:i + 500]) 128 | 129 | with db.atomic(): 130 | cls.insert_many(inserted_chunk).execute() 131 | 132 | log.debug(f"Created {len(inserted_chunk)} of model {cls.__name__} in db") 133 | 134 | inserted_ids = [i['id'] for i in inserted_chunk] 135 | inserted_instances = cls.select().where(cls.id.in_(inserted_ids)) # read from db again 136 | 137 | for instance in inserted_instances: 138 | if type(instance)._meta.has_children: 139 | log.debug(f"Syncing children of {instance}") 140 | instance._sync_children() 141 | 142 | all_instances += inserted_instances 143 | 144 | return all_instances 145 | 146 | @classmethod 147 | def _populate_api_extras(cls, info): 148 | return info 149 | 150 | def __str__(self): 151 | return f"<{type(self).__name__} {self.id}>" 152 | 153 | def _sync_children(self): 154 | pass 155 | 156 | class Meta(): 157 | """ 158 | Default metadata for the base model object 159 | """ 160 | database = db 161 | expect_revisions = False 162 | has_children = False 163 | -------------------------------------------------------------------------------- /src/mstodo/models/fields.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from dateutil.tz import tzutc 4 | from peewee import DateTimeField 5 | from mstodo.util import utc_to_local 6 | 7 | class DateTimeUTCField(DateTimeField): 8 | """ 9 | Extends Peewee's datetime field with timezone awareness 10 | """ 11 | def python_value(self, value): 12 | value = super().python_value(value) 13 | 14 | if isinstance(value, datetime): 15 | value = value.replace(tzinfo=tzutc()) 16 | return value 17 | 18 | def db_value(self, value): 19 | if isinstance(value, datetime): 20 | value = value.replace(tzinfo=None) 21 | 22 | return super().db_value(value) 23 | 24 | def _get_local_datetime_descriptor(self): 25 | return LocalDateTimeDescriptor(self) 26 | 27 | def add_to_class(self, model_class, name): 28 | """Add a corresponding property with the local datetime""" 29 | super().add_to_class(model_class, name) 30 | 31 | setattr(model_class, name + '_local', self._get_local_datetime_descriptor()) 32 | 33 | class LocalDateTimeDescriptor(): 34 | """Gives direct access to the localized datetime""" 35 | def __init__(self, field): 36 | self.attr_name = field.name 37 | 38 | def __get__(self, instance, instance_type=None): 39 | if instance is not None: 40 | dt = instance._data.get(self.attr_name) 41 | 42 | return utc_to_local(dt) 43 | -------------------------------------------------------------------------------- /src/mstodo/models/hashtag.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from peewee import CharField, IntegerField 4 | 5 | from mstodo.models.base import BaseModel 6 | 7 | _hashtag_pattern = re.compile(r'(?<=\s)#\S+', re.UNICODE) 8 | 9 | # Remove any non-word characters at the end of the hashtag 10 | _hashtag_trim_pattern = re.compile(r'\W+$', re.UNICODE) 11 | 12 | class Hashtag(BaseModel): 13 | """ 14 | Extends the Base class and refines it for the Hashtag data structure 15 | """ 16 | id = CharField(primary_key=True) 17 | tag = CharField() 18 | revision = IntegerField(default=0) 19 | 20 | @classmethod 21 | def sync(cls): 22 | from mstodo.models.task import Task 23 | 24 | tasks_with_hashtags = Task.select().where(Task.title.contains('#')) 25 | hashtags = {} 26 | 27 | for task in tasks_with_hashtags: 28 | for hashtag in cls.hashtags_in_task(task): 29 | tag = re.sub(_hashtag_trim_pattern, r'', hashtag) 30 | hashtags[tag.lower()] = tag 31 | 32 | if len(hashtags) > 0: 33 | hashtag_data = [{'id': id, 'tag': tag, 'revision': 0} for (id, tag) in hashtags.items()] 34 | instances = cls.select() 35 | 36 | return cls._perform_updates(instances, hashtag_data) 37 | 38 | return False 39 | 40 | @classmethod 41 | def hashtags_in_task(cls, task): 42 | return set(re.findall(_hashtag_pattern, ' ' + task.title)) 43 | -------------------------------------------------------------------------------- /src/mstodo/models/preferences.py: -------------------------------------------------------------------------------- 1 | from datetime import time, timedelta 2 | 3 | from workflow import Workflow 4 | 5 | DEFAULT_TASKFOLDER_MOST_RECENT = 'most_recent' 6 | 7 | AUTOMATIC_REMINDERS_KEY = 'automatic_reminders' 8 | DEFAULT_TASKFOLDER_ID_KEY = 'default_taskfolder_id' 9 | DUE_ORDER_KEY = 'due_order' 10 | EXPLICIT_KEYWORDS_KEY = 'explicit_keywords' 11 | HOIST_SKIPPED_TASKS_KEY = 'hoist_skipped_tasks' 12 | ICON_THEME_KEY = 'icon_theme' 13 | LAST_TASKFOLDER_ID_KEY = 'last_taskfolder_id' 14 | PRERELEASES_KEY = '__workflow_prereleases' 15 | REMINDER_TIME_KEY = 'reminder_time' 16 | REMINDER_TODAY_OFFSET_KEY = 'reminder_today_offset' 17 | SHOW_COMPLETED_TASKS_KEY = 'show_completed_tasks' 18 | UPCOMING_DURATION_KEY = 'upcoming_duration' 19 | COMPLETED_DURATION_KEY = 'completed_duration' 20 | DATE_LOCALE_KEY = 'date_locale' 21 | 22 | # Using a new object to avoid cyclic imports between mstodo.util and this file 23 | wf = Workflow() 24 | 25 | class Preferences(): 26 | """ 27 | Holds and modifies preferences for the MS ToDo alfred workflow 28 | """ 29 | 30 | _current_prefs = None 31 | 32 | @classmethod 33 | def current_prefs(cls): 34 | if not cls._current_prefs: 35 | cls._current_prefs = Preferences(wf.stored_data('prefs')) 36 | if not cls._current_prefs: 37 | cls._current_prefs = Preferences({}) 38 | return cls._current_prefs 39 | 40 | def __init__(self, data): 41 | self._data = data or {} 42 | 43 | # Clean up old prerelease preference 44 | if 'prerelease_channel' in self._data: 45 | # Migrate to the alfred-workflow preference 46 | self.prerelease_channel = self._data['prerelease_channel'] 47 | del self._data['prerelease_channel'] 48 | 49 | wf.store_data('prefs', self._data) 50 | 51 | def _set(self, key, value): 52 | if value is None and key in self._data: 53 | del self._data[key] 54 | elif self._data.get(key) != value: 55 | self._data[key] = value 56 | else: 57 | return 58 | 59 | wf.store_data('prefs', self._data) 60 | 61 | def _get(self, key, default=None): 62 | value = self._data.get(key) 63 | 64 | if value is None and default is not None: 65 | value = default 66 | 67 | return value 68 | 69 | @property 70 | def reminder_time(self): 71 | return self._get(REMINDER_TIME_KEY) or time(9, 0, 0) 72 | 73 | @reminder_time.setter 74 | def reminder_time(self, reminder_time): 75 | self._set(REMINDER_TIME_KEY, reminder_time) 76 | 77 | @property 78 | def reminder_today_offset(self): 79 | return self._get(REMINDER_TODAY_OFFSET_KEY, None) 80 | 81 | @reminder_today_offset.setter 82 | def reminder_today_offset(self, reminder_today_offset): 83 | self._set(REMINDER_TODAY_OFFSET_KEY, reminder_today_offset) 84 | 85 | @property 86 | def reminder_today_offset_timedelta(self): 87 | reminder_today_offset = self.reminder_today_offset 88 | 89 | return timedelta(hours=reminder_today_offset.hour, minutes=reminder_today_offset.minute) 90 | 91 | @property 92 | def icon_theme(self): 93 | return self._get(ICON_THEME_KEY) 94 | 95 | @icon_theme.setter 96 | def icon_theme(self, reminder_time): 97 | self._set(ICON_THEME_KEY, reminder_time) 98 | 99 | @property 100 | def explicit_keywords(self): 101 | return self._get(EXPLICIT_KEYWORDS_KEY, False) 102 | 103 | @explicit_keywords.setter 104 | def explicit_keywords(self, explicit_keywords): 105 | self._set(EXPLICIT_KEYWORDS_KEY, explicit_keywords) 106 | 107 | @property 108 | def automatic_reminders(self): 109 | return self._get(AUTOMATIC_REMINDERS_KEY, False) 110 | 111 | @automatic_reminders.setter 112 | def automatic_reminders(self, automatic_reminders): 113 | self._set(AUTOMATIC_REMINDERS_KEY, automatic_reminders) 114 | 115 | @property 116 | def prerelease_channel(self): 117 | return wf.settings.get(PRERELEASES_KEY, False) 118 | 119 | @prerelease_channel.setter 120 | def prerelease_channel(self, prerelease_channel): 121 | wf.settings[PRERELEASES_KEY] = prerelease_channel 122 | 123 | @property 124 | def last_taskfolder_id(self): 125 | return self._get(LAST_TASKFOLDER_ID_KEY, None) 126 | 127 | @last_taskfolder_id.setter 128 | def last_taskfolder_id(self, last_taskfolder_id): 129 | self._set(LAST_TASKFOLDER_ID_KEY, last_taskfolder_id) 130 | 131 | @property 132 | def due_order(self): 133 | return self._get(DUE_ORDER_KEY, ['order', 'due_date', 'taskfolder.order']) 134 | 135 | @due_order.setter 136 | def due_order(self, due_order): 137 | self._set(DUE_ORDER_KEY, due_order) 138 | 139 | @property 140 | def hoist_skipped_tasks(self): 141 | return self._get(HOIST_SKIPPED_TASKS_KEY, True) 142 | 143 | @hoist_skipped_tasks.setter 144 | def hoist_skipped_tasks(self, hoist_skipped_tasks): 145 | self._set(HOIST_SKIPPED_TASKS_KEY, hoist_skipped_tasks) 146 | 147 | @property 148 | def show_completed_tasks(self): 149 | return self._get(SHOW_COMPLETED_TASKS_KEY, False) 150 | 151 | @show_completed_tasks.setter 152 | def show_completed_tasks(self, show_completed_tasks): 153 | self._set(SHOW_COMPLETED_TASKS_KEY, show_completed_tasks) 154 | 155 | @property 156 | def upcoming_duration(self): 157 | return self._get(UPCOMING_DURATION_KEY, 7) 158 | 159 | @upcoming_duration.setter 160 | def upcoming_duration(self, upcoming_duration): 161 | self._set(UPCOMING_DURATION_KEY, upcoming_duration) 162 | 163 | @property 164 | def completed_duration(self): 165 | return self._get(COMPLETED_DURATION_KEY, 1) 166 | 167 | @completed_duration.setter 168 | def completed_duration(self, completed_duration): 169 | self._set(COMPLETED_DURATION_KEY, completed_duration) 170 | 171 | @property 172 | def default_taskfolder_id(self): 173 | return self._get(DEFAULT_TASKFOLDER_ID_KEY, None) 174 | 175 | @default_taskfolder_id.setter 176 | def default_taskfolder_id(self, default_taskfolder_id): 177 | self._set(DEFAULT_TASKFOLDER_ID_KEY, default_taskfolder_id) 178 | 179 | @property 180 | def date_locale(self): 181 | return self._get(DATE_LOCALE_KEY, None) 182 | 183 | @date_locale.setter 184 | def date_locale(self, date_locale): 185 | self._set(DATE_LOCALE_KEY, date_locale) 186 | -------------------------------------------------------------------------------- /src/mstodo/models/task.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from datetime import date, timedelta, datetime 4 | import logging 5 | import time 6 | 7 | from peewee import (BooleanField, CharField, ForeignKeyField, IntegerField, 8 | PeeweeException, TextField) 9 | 10 | from mstodo.models.fields import DateTimeUTCField 11 | from mstodo.models.base import BaseModel 12 | from mstodo.models.taskfolder import TaskFolder 13 | from mstodo.models.user import User 14 | from mstodo.util import short_relative_formatted_date, SYMBOLS 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | _days_by_recurrence_type = { 19 | 'day': 1, 20 | 'week': 7, 21 | 'month': 30.43, 22 | 'year': 365 23 | } 24 | 25 | _primary_api_fields = [ 26 | 'id', 27 | 'parentFolderId', 28 | 'lastModifiedDateTime', 29 | 'changeKey', 30 | 'status' 31 | ] 32 | _secondary_api_fields = [ 33 | 'createdDateTime', 34 | 'startDateTime', 35 | 'dueDateTime', 36 | 'isReminderOn', 37 | 'reminderDateTime', 38 | 'completedDateTime', 39 | 'recurrence', 40 | 'subject', 41 | 'body', 42 | 'importance', 43 | 'sensitivity', 44 | 'hasAttachments', 45 | 'owner', 46 | 'assignedTo' 47 | ] 48 | 49 | class Task(BaseModel): 50 | """ 51 | Extends the Base class and refines it for the Task data structure 52 | """ 53 | id = CharField(primary_key=True) 54 | list = ForeignKeyField(TaskFolder,index=True, related_name='tasks') #@TODO check related name syntax 55 | createdDateTime = DateTimeUTCField() 56 | lastModifiedDateTime = DateTimeUTCField() 57 | changeKey = CharField() 58 | hasAttachments = BooleanField(null=True) 59 | importance = CharField(index=True, null=True) 60 | isReminderOn = BooleanField(null=True) 61 | owner = ForeignKeyField(User, related_name='created_tasks', null=True) 62 | assignedTo = ForeignKeyField(User, related_name='assigned_tasks', null=True) 63 | sensitivity = CharField(index=True,null=True) 64 | status = CharField(index=True) 65 | title = TextField(index=True) 66 | completedDateTime = DateTimeUTCField(index=True, null=True) 67 | dueDateTime = DateTimeUTCField(index=True, null=True) 68 | reminderDateTime = DateTimeUTCField(index=True, null=True) 69 | startDateTime = DateTimeUTCField(index=True, null=True) 70 | body_contentType = TextField(null=True) 71 | body_content = TextField(null=True) 72 | recurrence_type = CharField(null=True) 73 | recurrence_count = IntegerField(null=True) 74 | # "categories": [], 75 | 76 | @staticmethod 77 | def transform_datamodel(tasks_data): 78 | for task in tasks_data: 79 | for (k, v) in task.copy().items(): 80 | if k == "subject": 81 | task['title'] = v 82 | elif k == "parentFolderId": 83 | task['list'] = v 84 | if isinstance(v, dict): 85 | if k.find("DateTime") > -1: 86 | # Datetimes are shown as a dicts with naive datetime + separate timezone field 87 | task[k] = v['dateTime'] 88 | elif k == "body": 89 | task['body_contentType'] = v['contentType'] 90 | task['body_content'] = v['content'] 91 | elif k == 'recurrence': 92 | # WL uses day, week month year, MSTODO uses 93 | # daily weekly absoluteMonthly relativeMonthly a..Yearly r...Yearly 94 | if 'week' in v['pattern']['type'].lower(): 95 | window = 'week' 96 | elif 'month' in v['pattern']['type'].lower(): 97 | window = 'month' 98 | elif 'year' in v['pattern']['type'].lower(): 99 | window = 'year' 100 | elif 'da' in v['pattern']['type'].lower(): 101 | window = 'day' 102 | else: 103 | window = '' 104 | task['recurrence_type'] = window 105 | task['recurrence_count'] = v['pattern']['interval'] 106 | return tasks_data 107 | 108 | @classmethod 109 | def sync_all_tasks(cls): 110 | from mstodo.api import tasks 111 | from concurrent import futures 112 | start = time.time() 113 | instances = [] 114 | tasks_data = [] 115 | 116 | with futures.ThreadPoolExecutor(max_workers=4) as executor: 117 | fields = list(_primary_api_fields) 118 | fields.extend(_secondary_api_fields) 119 | kwargs = {'fields': fields} 120 | job = executor.submit(lambda p: tasks.tasks(**p),kwargs) 121 | tasks_data = job.result() 122 | 123 | log.debug(f"Retrieved all {len(tasks_data)} task ids in {round(time.time() - start, 3)} seconds") 124 | start = time.time() 125 | 126 | try: 127 | # Pull instances from DB if they exist 128 | instances = cls.select(cls.id, cls.title, cls.changeKey) 129 | except PeeweeException: 130 | pass 131 | 132 | log.debug(f"Retrieved all {len(instances)} tasks from database in {round(time.time() - start, 3)} seconds") 133 | start = time.time() 134 | 135 | tasks_data = cls.transform_datamodel(tasks_data) 136 | cls._perform_updates(instances, tasks_data) 137 | cls._sync_children() 138 | 139 | log.debug(f"Completed updates to tasks in {round(time.time() - start, 3)} seconds") 140 | 141 | return None 142 | 143 | @classmethod 144 | def sync_modified_tasks(cls): 145 | from mstodo.api import tasks 146 | from concurrent import futures 147 | from workflow import Workflow 148 | wf = Workflow() 149 | start = time.time() 150 | instances = [] 151 | all_tasks = [] 152 | 153 | # Remove 60 seconds to make sure all recent tasks are included 154 | # dt = Preferences.current_prefs().last_sync - timedelta(seconds=60) 155 | dt = wf.cached_data('last_sync', max_age=0) - timedelta(seconds=60) 156 | 157 | # run a single future for all tasks modified since last run 158 | with futures.ThreadPoolExecutor() as executor: 159 | job = executor.submit(lambda p: tasks.tasks(**p), {'dt':dt, 'afterdt':True}) 160 | modified_tasks = job.result() 161 | 162 | # run a separate futures map over all taskfolders @TODO change this to be per taskfolder 163 | with futures.ThreadPoolExecutor(max_workers=4) as executor: 164 | jobs = ( 165 | executor.submit(lambda p: tasks.tasks(**p), 166 | {'fields': _primary_api_fields, 'completed':True}), 167 | executor.submit(lambda p: tasks.tasks(**p), 168 | {'fields': _primary_api_fields, 'completed':False}) 169 | ) 170 | for job in futures.as_completed(jobs): 171 | all_tasks += job.result() 172 | 173 | # if task in modified_tasks then remove from all taskfolder data 174 | modified_tasks_ids = [task['id'] for task in modified_tasks] 175 | for task in all_tasks: 176 | if task['id'] in modified_tasks_ids: 177 | all_tasks.remove(task) 178 | all_tasks.extend(modified_tasks) 179 | 180 | log.debug(f"Retrieved all {len(all_tasks)} including {len(modified_tasks)}\ 181 | modifications since {dt} in {round(time.time() - start, 3)} seconds") 182 | start = time.time() 183 | 184 | try: 185 | # Pull instances from DB 186 | instances = cls.select(cls.id, cls.title, cls.changeKey) 187 | except PeeweeException: 188 | pass 189 | 190 | log.debug(f"Loaded all {len(instances)} from database in {round(time.time() - start, 3)} seconds") 191 | start = time.time() 192 | 193 | all_tasks = cls.transform_datamodel(all_tasks) 194 | cls._perform_updates(instances, all_tasks) 195 | # cls._sync_children() 196 | #FIXME this causes errors, need to refactor 197 | 198 | log.debug(f"Completed updates to tasks in {round(time.time() - start, 3)} seconds") 199 | 200 | return None 201 | 202 | @classmethod 203 | def due_today(cls): 204 | return ( 205 | cls.select(cls, TaskFolder) 206 | .join(TaskFolder) 207 | .where(cls.completedDateTime >> None) 208 | .where(cls.dueDateTime <= date.today()) 209 | .order_by(cls.dueDateTime.asc()) 210 | ) 211 | 212 | @classmethod 213 | def search(cls, query): 214 | return ( 215 | cls.select(cls, TaskFolder) 216 | .join(TaskFolder) 217 | .where(cls.completedDateTime >> None) 218 | .where(cls.title.contains(query)) 219 | .order_by(cls.dueDateTime.asc()) 220 | ) 221 | 222 | @property 223 | def completed(self): 224 | return bool(self.completedDateTime) 225 | 226 | @property 227 | def overdue_times(self): 228 | if self.recurrence_type is None or self.completed: 229 | return 0 230 | recurrence_days = _days_by_recurrence_type[self.recurrence_type] * self.recurrence_count 231 | overdue_time = datetime.now() - self.dueDateTime.replace(tzinfo=None) 232 | return int(overdue_time.days / recurrence_days) 233 | 234 | @property 235 | def list_title(self): 236 | if self.list: 237 | return self.list.title 238 | return None 239 | 240 | def subtitle(self): 241 | from mstodo.util import format_time 242 | 243 | subtitle = [] 244 | 245 | if self.importance == 'high': 246 | subtitle.append(SYMBOLS['star']) 247 | 248 | # Task is completed 249 | if self.status == 'completed': 250 | subtitle.append(f"Completed {short_relative_formatted_date(self.completedDateTime)}") 251 | # Task is not yet completed 252 | elif self.dueDateTime: 253 | subtitle.append(f"Due {short_relative_formatted_date(self.dueDateTime)}") 254 | 255 | if self.recurrence_type: 256 | if self.recurrence_count > 1: 257 | subtitle.append(f"{SYMBOLS['recurrence']} Every {self.recurrence_count} {self.recurrence_type}s") 258 | # Cannot simply add -ly suffix 259 | elif self.recurrence_type == 'day': 260 | subtitle.append(f"{SYMBOLS['recurrence']} Daily") 261 | else: 262 | subtitle.append(f"{SYMBOLS['recurrence']} {self.recurrence_type.title()}ly") 263 | 264 | if self.status != 'completed': 265 | overdue_times = self.overdue_times 266 | if overdue_times > 1: 267 | subtitle.insert(0, f"{SYMBOLS['overdue_2x']} {overdue_times}X OVERDUE!") 268 | elif overdue_times == 1: 269 | subtitle.insert(0, f"{SYMBOLS['overdue_1x']} OVERDUE!") 270 | 271 | if self.reminderDateTime: 272 | reminder_date_phrase = None 273 | 274 | if self.reminderDateTime.date() == self.dueDateTime: 275 | reminder_date_phrase = 'On due date' 276 | else: 277 | reminder_date_phrase = short_relative_formatted_date(self.reminderDateTime) 278 | 279 | subtitle.append(f"{SYMBOLS['reminder']} {reminder_date_phrase} at \ 280 | {format_time(self.reminderDateTime, 'short')}") 281 | 282 | subtitle.append(self.title) 283 | 284 | return ' '.join(subtitle) 285 | 286 | def _sync_children(self): 287 | pass 288 | #@TODO sort out child syncing 289 | # from mstodo.models.hashtag import Hashtag 290 | # Hashtag.sync() 291 | 292 | def __str__(self): 293 | title = self.title if len(self.title) <= 20 else self.title[:20].rstrip() + '…' 294 | task_subid = self.id[-32:] 295 | return f"<{type(self).__name__} ...{task_subid} {title}>" 296 | 297 | class Meta(): 298 | """ 299 | Custom metadata for the Task object 300 | """ 301 | order_by = ('lastModifiedDateTime', 'id') 302 | expect_revisions = True 303 | -------------------------------------------------------------------------------- /src/mstodo/models/task_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import date, datetime, timedelta 3 | 4 | from workflow import MATCH_ALL, MATCH_ALLCHARS 5 | 6 | from mstodo.models.preferences import Preferences, DEFAULT_TASKFOLDER_MOST_RECENT 7 | from mstodo.util import parsedatetime_calendar, wf_wrapper 8 | 9 | # Up to 8 words (sheesh!) followed by a colon 10 | LIST_TITLE_PATTERN = re.compile(r'^((?:[^\s:]+ *){0,8}):', 11 | re.UNICODE | re.IGNORECASE) 12 | 13 | # The word "in" followed optionally by "list" 14 | INFIX_LIST_KEYWORD_PATTERN = re.compile(r'\bin\s+(list\s+)?', 15 | re.UNICODE | re.IGNORECASE) 16 | 17 | # `every N units` optionally preceded by `repeat` 18 | RECURRENCE_PATTERN = re.compile( 19 | r'(?:\brepeat(?:ing|s)?:? )?(?:\bevery *(\d*) ' + 20 | r'*((?:day|week|month|year|d|w|m|y|da|wk|mo|yr)s?\b)?' + 21 | r'|(daily|weekly|monthly|yearly|annually))', re.UNICODE | re.IGNORECASE) 22 | RECURRENCE_BY_DATE_PATTERN = re.compile( 23 | r'(?:\brepeat:? )?\bevery *((?:\S+ *){0,2})', re.UNICODE | re.IGNORECASE) 24 | 25 | REMINDER_PATTERN = re.compile( 26 | r'(\b(?:remind me|reminder|remind|r|alarm)\b:? *)(.*)', 27 | re.UNICODE | re.IGNORECASE) 28 | 29 | # Anything following the `due` keyword 30 | DUE_PATTERN = re.compile(r'(\bdue:?\b\s*)(.*)', re.UNICODE | re.IGNORECASE) 31 | 32 | NOT_DUE_PATTERN = re.compile(r'not? due( date)?', re.UNICODE | re.IGNORECASE) 33 | 34 | HASHTAG_PROMPT_PATTERN = re.compile(r'(?:^|[\s:])(#\S*)$', re.UNICODE) 35 | 36 | # An asterisk at the end of the phrase 37 | STAR_PATTERN = re.compile(r'\*$', re.UNICODE) 38 | 39 | # Tabs or multiple consecutive spaces 40 | WHITESPACE_CLEANUP_PATTERN = re.compile(r'\t|\s{2,}', re.UNICODE) 41 | 42 | # Split words ignoring leading and trailing punctuation 43 | WORD_SEPARATOR_PATTERN = re.compile(r'\W*\s+\W*', re.UNICODE) 44 | 45 | # Anything following the '//' delimiter 46 | SLASHES_PATTERN = re.compile(r'( //)(.*)$', re.DOTALL) 47 | 48 | # Maps first letter to the API recurrence type 49 | RECURRENCE_TYPES = { 50 | 'd': 'day', 51 | 'w': 'week', 52 | 'm': 'month', 53 | 'y': 'year', 54 | # for "annually" 55 | 'a': 'year' 56 | } 57 | 58 | 59 | class TaskParser(): 60 | """ 61 | Utility function to handle task creation and split out relevant content into title, note, recurrence, 62 | reminder and so on. 63 | """ 64 | phrase = None 65 | title = None 66 | list_id = None 67 | list_title = None 68 | due_date = None 69 | recurrence_type = None 70 | recurrence_count = None 71 | reminder_date = None 72 | hashtag_prompt = None 73 | assignee_id = None 74 | starred = False 75 | completed = False 76 | note = None 77 | 78 | has_list_prompt = False 79 | has_due_date_prompt = False 80 | has_recurrence_prompt = False 81 | has_reminder_prompt = False 82 | has_hashtag_prompt = False 83 | 84 | _list_phrase = None 85 | _due_date_phrase = None 86 | _recurrence_phrase = None 87 | _reminder_phrase = None 88 | _starred_phrase = None 89 | _note_phrase = None 90 | 91 | def __init__(self, phrase): 92 | self.phrase = phrase.strip() 93 | self._parse() 94 | 95 | def _parse(self): 96 | cls = type(self) 97 | phrase = self.phrase 98 | cal = parsedatetime_calendar() 99 | wf = wf_wrapper() 100 | taskfolders = wf.stored_data('taskfolders') 101 | prefs = Preferences.current_prefs() 102 | ignore_due_date = False 103 | 104 | match = re.search(HASHTAG_PROMPT_PATTERN, phrase) 105 | if match: 106 | self.hashtag_prompt = match.group(1) 107 | self.has_hashtag_prompt = True 108 | 109 | match = re.search(SLASHES_PATTERN, phrase) 110 | if match: 111 | self._note_phrase = match.group(1) + match.group(2) 112 | self.note = re.sub( 113 | WHITESPACE_CLEANUP_PATTERN, ' ', match.group(2)).strip() 114 | phrase = phrase[:match.start()] + phrase[match.end():] 115 | 116 | match = re.search(STAR_PATTERN, phrase) 117 | if match: 118 | self.starred = True 119 | self._starred_phrase = match.group() 120 | phrase = phrase[:match.start()] + phrase[match.end():] 121 | 122 | match = re.search(NOT_DUE_PATTERN, phrase) 123 | if match: 124 | ignore_due_date = True 125 | phrase = phrase[:match.start()] + phrase[match.end():] 126 | 127 | match = re.search(LIST_TITLE_PATTERN, phrase) 128 | if taskfolders and match: 129 | if match.group(1): 130 | matching_taskfolders = wf.filter( 131 | match.group(1), 132 | taskfolders, 133 | lambda l: l['title'], 134 | # Ignore MATCH_ALLCHARS which is expensive and inaccurate 135 | match_on=MATCH_ALL ^ MATCH_ALLCHARS 136 | ) 137 | 138 | # Take the first match as the desired list 139 | if matching_taskfolders: 140 | self.list_id = matching_taskfolders[0]['id'] 141 | self.list_title = matching_taskfolders[0]['title'] 142 | # The list name was empty 143 | else: 144 | self.has_list_prompt = True 145 | 146 | if self.list_title or self.has_list_prompt: 147 | self._list_phrase = match.group() 148 | phrase = phrase[:match.start()] + phrase[match.end():] 149 | 150 | # Parse and remove the recurrence phrase first so that any dates do 151 | # not interfere with the due date 152 | match = re.search(RECURRENCE_PATTERN, phrase) 153 | if match: 154 | type_phrase = match.group(2) if match.group(2) else match.group(3) 155 | if type_phrase: 156 | # Look up the recurrence type based on the first letter of the 157 | # work or abbreviation used in the phrase 158 | self.recurrence_type = RECURRENCE_TYPES[type_phrase[0].lower()] 159 | self.recurrence_count = int(match.group(1) or 1) 160 | else: 161 | match = re.search(RECURRENCE_BY_DATE_PATTERN, phrase) 162 | if match: 163 | recurrence_phrase = match.group() 164 | dates = cal.nlp(match.group(1), version=2) 165 | 166 | if dates: 167 | # Only remove the first date following `every` 168 | datetime_info = dates[0] 169 | # Set due_date if a datetime was found and it is not time only 170 | if datetime_info[1].hasDate: 171 | self.due_date = datetime_info[0].date() 172 | date_expression = datetime_info[4] 173 | 174 | # FIXME: This logic could be improved to better 175 | # differentiate between week and year expressions 176 | 177 | # If the date expression is only one word and the next 178 | # due date is less than one week from now, set a 179 | # weekly recurrence, e.g. every Tuesday 180 | if len(date_expression.split(' ')) == 1 \ 181 | and self.due_date < date.today() + timedelta(days=8): 182 | self.recurrence_count = 1 183 | self.recurrence_type = 'week' 184 | # Otherwise expect a multi-word value like a date, 185 | # e.g. every May 17 186 | else: 187 | self.recurrence_count = 1 188 | self.recurrence_type = 'year' 189 | 190 | self.has_recurrence_prompt = False 191 | 192 | # Pull in any words between the `due` keyword and the 193 | # actual date text 194 | date_pattern = re.escape(date_expression) 195 | date_pattern = r'.*?' + date_pattern 196 | 197 | # Prepare to set the recurrence phrase below 198 | match = re.search(date_pattern, recurrence_phrase) 199 | 200 | # This is just the "every" keyword with no date following 201 | if not self.recurrence_type: 202 | self.has_recurrence_prompt = True 203 | 204 | self._recurrence_phrase = match.group() 205 | phrase = phrase.replace(self._recurrence_phrase, '', 1) 206 | 207 | 208 | reminder_info = None 209 | match = re.search(REMINDER_PATTERN, phrase) 210 | if match: 211 | datetimes = cal.nlp(match.group(2), version=2) 212 | 213 | # If there is at least one date immediately following the reminder 214 | # phrase use it as the reminder date 215 | if datetimes and datetimes[0][2] == 0: 216 | # Only remove the first date following the keyword 217 | reminder_info = datetimes[0] 218 | 219 | self._reminder_phrase = match.group(1) + reminder_info[4] 220 | phrase = phrase.replace(self._reminder_phrase, '', 1) 221 | # Otherwise if there is just a reminder phrase, set the reminder 222 | # to the default time on the date due 223 | else: 224 | # There is no text following the reminder phrase, prompt for a reminder 225 | if not match.group(2): 226 | self.has_reminder_prompt = True 227 | self._reminder_phrase = match.group(1) 228 | 229 | # Careful, this might just be the letter "r" so rather than 230 | # replacing it is better to strip out by index 231 | phrase = phrase[:match.start(1)] + phrase[match.end(1):] 232 | 233 | 234 | due_keyword = None 235 | potential_date_phrase = None 236 | if not ignore_due_date: 237 | match = re.search(DUE_PATTERN, phrase) 238 | # Search for the due date only following the `due` keyword 239 | if match: 240 | due_keyword = match.group(1) 241 | 242 | if match.group(2): 243 | potential_date_phrase = match.group(2) 244 | # Otherwise find a due date anywhere in the phrase 245 | elif not prefs.explicit_keywords: 246 | potential_date_phrase = phrase 247 | 248 | if potential_date_phrase: 249 | dates = cal.nlp(potential_date_phrase, version=2) 250 | 251 | if dates: 252 | # Only remove the first date following `due` 253 | datetime_info = dates[0] 254 | # Set due_date if a datetime was found and it is not time only 255 | if datetime_info[1].hasDate: 256 | self.due_date = datetime_info[0].date() 257 | elif datetime_info[1].hasTime and not self.due_date: 258 | self.due_date = date.today() 259 | 260 | if self.due_date: 261 | # Pull in any words between the `due` keyword and the 262 | # actual date text 263 | date_pattern = re.escape(datetime_info[4]) 264 | 265 | if due_keyword: 266 | date_pattern = re.escape(due_keyword) + r'.*?' + date_pattern 267 | 268 | due_date_phrase_match = re.search(date_pattern, phrase) 269 | 270 | if due_date_phrase_match: 271 | self._due_date_phrase = due_date_phrase_match.group() 272 | phrase = phrase.replace(self._due_date_phrase, '', 1) 273 | 274 | # If the due date specifies a time, set it as the reminder 275 | if datetime_info[1].hasTime: 276 | if datetime_info[1].hasDate: 277 | self.reminder_date = datetime_info[0] 278 | elif self.due_date: 279 | self.reminder_date = datetime.combine(self.due_date, datetime_info[0].time()) 280 | # Just a time component 281 | else: 282 | due_keyword = None 283 | # No dates in the phrase 284 | else: 285 | due_keyword = None 286 | 287 | # The word due was not followed by a date 288 | if due_keyword and not self._due_date_phrase: 289 | self.has_due_date_prompt = True 290 | self._due_date_phrase = match.group(1) 291 | 292 | # Avoid accidentally replacing "due" inside words elsewhere in the 293 | # string 294 | phrase = phrase[:match.start(1)] + phrase[match.end(1):] 295 | 296 | if self.recurrence_type and not self.due_date: 297 | self.due_date = date.today() 298 | 299 | if self._reminder_phrase: 300 | # If a due date is set, a time-only reminder is relative to that 301 | # date; otherwise if there is no due date it is relative to today 302 | reference_date = self.due_date if self.due_date else date.today() 303 | 304 | if reminder_info: 305 | (dt, datetime_context, _, _, _) = reminder_info 306 | 307 | # Date and time; use as-is 308 | if datetime_context.hasTime and datetime_context.hasDate: 309 | self.reminder_date = dt 310 | # Time only; set the reminder on the due day 311 | elif datetime_context.hasTime: 312 | self.reminder_date = cls.reminder_date_combine(reference_date, dt) 313 | # Date only; set the default reminder time on that day 314 | elif datetime_context.hasDate: 315 | self.reminder_date = cls.reminder_date_combine(dt) 316 | 317 | else: 318 | self.reminder_date = cls.reminder_date_combine(reference_date) 319 | 320 | # Look for a list title at the end of the remaining phrase, like 321 | # "in list Office" 322 | if not self.list_title: 323 | matches = re.finditer(INFIX_LIST_KEYWORD_PATTERN, phrase) 324 | for match in matches: 325 | subphrase = phrase[match.end():] 326 | 327 | # Just a couple characters are too likely to result in a false 328 | # positive, but allow it if the letters are capitalized 329 | if len(subphrase) > 2 or subphrase == subphrase.upper(): 330 | matching_taskfolders = wf.filter( 331 | subphrase, 332 | taskfolders, 333 | lambda f: f['title'], 334 | # Ignore MATCH_ALLCHARS which is expensive and inaccurate 335 | match_on=MATCH_ALL ^ MATCH_ALLCHARS 336 | ) 337 | 338 | # Take the first match as the desired list 339 | if matching_taskfolders: 340 | self.list_id = matching_taskfolders[0]['id'] 341 | self.list_title = matching_taskfolders[0]['title'] 342 | self._list_phrase = match.group() + subphrase 343 | phrase = phrase[:match.start()] 344 | break 345 | 346 | # No list parsed, assign to Tasks 347 | if not self.list_title: 348 | if prefs.default_taskfolder_id and taskfolders: 349 | if prefs.default_taskfolder_id == DEFAULT_TASKFOLDER_MOST_RECENT: 350 | self.list_id = prefs.last_taskfolder_id 351 | else: 352 | self.list_id = prefs.default_taskfolder_id 353 | default_taskfolder = next((f for f in taskfolders if f['id'] == self.list_id), None) 354 | if default_taskfolder: 355 | self.list_title = default_taskfolder['title'] 356 | 357 | if not self.list_title: 358 | if taskfolders: 359 | inbox = taskfolders[0] 360 | self.list_id = inbox['id'] 361 | self.list_title = inbox['title'] 362 | else: 363 | self.list_id = 0 364 | self.list_title = 'Tasks' 365 | 366 | # Set an automatic reminder when there is a due date without a 367 | # specified reminder 368 | if self.due_date and not self.reminder_date and prefs.automatic_reminders: 369 | self.reminder_date = cls.reminder_date_combine(self.due_date) 370 | 371 | # Condense extra whitespace remaining in the task title after parsing 372 | self.title = re.sub(WHITESPACE_CLEANUP_PATTERN, ' ', phrase).strip() 373 | 374 | @classmethod 375 | def reminder_date_combine(cls, date_component, time_component=None): 376 | """ 377 | Returns a datetime based on the date portion of the date_component and 378 | the time portion of the time_component with special handling for an 379 | unspecified time_component. Based on the user preferences, a None 380 | time_component will result in either the default reminder time or an 381 | adjustment based on the current time if the reminder date is today. 382 | """ 383 | prefs = Preferences.current_prefs() 384 | 385 | if isinstance(date_component, datetime): 386 | date_component = date_component.date() 387 | 388 | # Set a dynamic reminder date if due today 389 | if date_component == date.today() and time_component is None and prefs.reminder_today_offset: 390 | adjusted_now = datetime.now() 391 | adjusted_now -= timedelta(seconds=adjusted_now.second, microseconds=adjusted_now.microsecond) 392 | time_component = adjusted_now + prefs.reminder_today_offset_timedelta 393 | 394 | # "Round" to nearest 5 minutes, e.g. from :01 to :05, :44 to :45, 395 | # :50 to :50 396 | time_component += timedelta(minutes=(5 - time_component.minute % 5) % 5) 397 | 398 | # Default an unspecified time component on any day other than today to 399 | # the default reminder time 400 | if time_component is None: 401 | time_component = prefs.reminder_time 402 | 403 | if isinstance(time_component, datetime): 404 | time_component = time_component.time() 405 | 406 | return datetime.combine(date_component, time_component) 407 | 408 | def phrase_with(self, title=None, list_title=None, due_date=None, 409 | recurrence=None, reminder_date=None, hashtag=None, starred=None): 410 | components = [] 411 | #@TODO research this logic, assuming it is correct pythonic usage based on examples from other packages 412 | basestring = str 413 | 414 | # Retain the current list 415 | if list_title is None: 416 | if self._list_phrase: 417 | components.append(self._list_phrase) 418 | # Specifies a list by name 419 | elif isinstance(list_title, basestring): 420 | list_title = list_title.replace(':', '') 421 | components.append(list_title + ':') 422 | # Triggers selection of a list 423 | elif list_title: 424 | components.append(':') 425 | # Remove the current value 426 | else: 427 | pass 428 | 429 | # Add the task text 430 | if title: 431 | components.append(title) 432 | elif self.title: 433 | components.append(self.title) 434 | 435 | # Remove the hashtag prompt 436 | if hashtag and self.has_hashtag_prompt and (title or self.title): 437 | components[-1] = components[-1].rsplit(self.hashtag_prompt, 1)[0] 438 | 439 | # Retain the current due date 440 | if due_date is None: 441 | if self._due_date_phrase: 442 | components.append(self._due_date_phrase) 443 | # Specifies a due date phrase 444 | elif isinstance(due_date, basestring): 445 | components.append(due_date) 446 | # Triggers selection of a due date 447 | elif due_date: 448 | components.append('due ') 449 | # Remove the current value 450 | else: 451 | pass 452 | 453 | # Retain the current recurrence 454 | if recurrence is None: 455 | if self._recurrence_phrase: 456 | components.append(self._recurrence_phrase) 457 | # Specifies a recurrence phrase 458 | elif isinstance(recurrence, basestring): 459 | components.append(recurrence) 460 | # Triggers selection of a recurrence 461 | elif recurrence: 462 | components.append('every ') 463 | # Remove the current value 464 | else: 465 | pass 466 | 467 | # Retain the current reminder 468 | if reminder_date is None: 469 | if self._reminder_phrase: 470 | components.append(self._reminder_phrase) 471 | # Specifies a reminder phrase 472 | elif isinstance(reminder_date, basestring): 473 | components.append(reminder_date) 474 | # Triggers selection of a reminder 475 | elif reminder_date: 476 | components.append('remind me ') 477 | # Remove the current value 478 | else: 479 | pass 480 | 481 | # Add the hashtag 482 | if hashtag: 483 | if '#' not in hashtag: 484 | hashtag = '#' + hashtag 485 | components.append(hashtag) 486 | # Removes the star 487 | else: 488 | pass 489 | 490 | # Retain the current star status 491 | if starred is None: 492 | if self._starred_phrase: 493 | components.append(self._starred_phrase) 494 | # Adds a star 495 | elif starred: 496 | components.append('*') 497 | # Removes the star 498 | else: 499 | pass 500 | 501 | # Adds a note 502 | if self.note: 503 | components.append(self._note_phrase) 504 | 505 | # Remove any empty string components, often a blank title 506 | components = [component for component in components if component] 507 | 508 | phrase = ' '.join(components) 509 | phrase = re.sub(WHITESPACE_CLEANUP_PATTERN, ' ', phrase) 510 | 511 | return phrase 512 | -------------------------------------------------------------------------------- /src/mstodo/models/taskfolder.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from peewee import (BooleanField, CharField, PeeweeException) 5 | 6 | from mstodo.models.base import BaseModel 7 | from mstodo.util import wf_wrapper 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | class TaskFolder(BaseModel): 12 | """ 13 | Extends the Base class and refines it for the Taskfolder data structure 14 | """ 15 | id = CharField(primary_key=True) 16 | changeKey = CharField() 17 | title = CharField(index=True) 18 | isDefaultFolder = BooleanField() 19 | parentGroupKey = CharField() 20 | 21 | @classmethod 22 | def sync(cls): 23 | from mstodo.api import taskfolders 24 | start = time.time() 25 | 26 | taskfolders_data = taskfolders.taskfolders() 27 | instances = [] 28 | 29 | log.debug(f"Retrieved all {len(taskfolders_data)} taskfolders in {round(time.time() - start, 3)} seconds") 30 | start = time.time() 31 | 32 | # Hacky translation of mstodo data model to wunderlist data model 33 | # to avoid changing naming in rest of the files 34 | for taskfolder in taskfolders_data: 35 | for (key,value) in taskfolder.copy().items(): 36 | if key == "name": 37 | taskfolder['title'] = value 38 | 39 | wf_wrapper().store_data('taskfolders', taskfolders_data) 40 | 41 | try: 42 | instances = cls.select(cls.id, cls.changeKey, cls.title) 43 | except PeeweeException: 44 | pass 45 | 46 | log.debug(f"Loaded all {len(instances)} taskfolders from \ 47 | the database in {round(time.time() - start, 3)} seconds") 48 | 49 | return cls._perform_updates(instances, taskfolders_data) 50 | 51 | @classmethod 52 | def _populate_api_extras(cls, info): 53 | from mstodo.api.taskfolders import update_taskfolder_with_tasks_count 54 | 55 | update_taskfolder_with_tasks_count(info) 56 | 57 | return info 58 | 59 | def __str__(self): 60 | return f"<{type(self).__name__} {self.id} {self.title}>" 61 | 62 | def _sync_children(self): 63 | pass 64 | #@TODO figure out how to sync tasks for each folder separately 65 | # from mstodo.models.task import Task 66 | # Task.sync_tasks_in_taskfolder(self) 67 | 68 | class Meta: 69 | """ 70 | Custom metadata for the Taskfolder object 71 | """ 72 | order_by = ('changeKey', 'id') 73 | has_children = False 74 | expect_revisions = True 75 | -------------------------------------------------------------------------------- /src/mstodo/models/user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from peewee import TextField, CharField 5 | 6 | from mstodo.models.base import BaseModel 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | class User(BaseModel): 11 | """ 12 | Extends the Base class and refines it for the User data structure 13 | """ 14 | id = CharField(primary_key=True) 15 | name = TextField(null=True) 16 | displayName = TextField(null=True) 17 | givenName = TextField(null=True) 18 | surname = TextField(null=True) 19 | userPrincipalName = TextField(null=True) 20 | mail = TextField(null=True) 21 | mobilePhone = TextField(null=True) 22 | jobTitle = TextField(null=True) 23 | officeLocation = TextField(null=True) 24 | preferredLanguage = TextField(null=True) 25 | # businessPhones": [], 26 | 27 | @classmethod 28 | def sync(cls): 29 | from mstodo.api import user 30 | 31 | start = time.time() 32 | instance = None 33 | user_data = user.user() 34 | log.debug(f"Retrieved User in {round(time.time() - start, 3)}") 35 | 36 | try: 37 | instance = cls.get() 38 | except User.DoesNotExist: 39 | pass 40 | 41 | return cls._perform_updates([instance], [user_data]) 42 | -------------------------------------------------------------------------------- /src/mstodo/sync.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import logging 4 | from datetime import datetime 5 | 6 | from workflow.notify import notify 7 | from workflow.background import is_running 8 | 9 | from mstodo.util import wf_wrapper 10 | 11 | log = logging.getLogger(__name__) 12 | wf = wf_wrapper() 13 | 14 | def sync(background=False): 15 | from mstodo.models import base, task, user, taskfolder, hashtag 16 | from peewee import OperationalError 17 | 18 | # If a sync is already running, wait for it to finish. Otherwise, store 19 | # the current pid in alfred-workflow's pid cache file 20 | if not background: 21 | if is_running('sync'): 22 | wait_count = 0 23 | while is_running('sync'): 24 | time.sleep(.25) 25 | wait_count += 1 26 | 27 | if wait_count == 2: 28 | notify( 29 | title='Please wait...', 30 | message='The workflow is making sure your tasks are up-to-date' 31 | ) 32 | 33 | return False 34 | 35 | log.info("Running manual sync") 36 | notify( 37 | title='Manual sync initiated', 38 | message='The workflow is making sure your tasks are up-to-date' 39 | ) 40 | 41 | pidfile = wf.cachefile('sync.pid') 42 | with open(pidfile, 'w', encoding="utf-8") as file_obj: 43 | #@TODO check if this needs to be byte-written? May be due to pickling the program state? 44 | # file_obj.write(os.getpid().to_bytes(length=4, byteorder=sys.byteorder)) 45 | file_obj.write(str(os.getpid())) 46 | else: 47 | log.info('Running background sync') 48 | 49 | 50 | base.BaseModel._meta.database.create_tables([ 51 | taskfolder.TaskFolder, 52 | task.Task, 53 | user.User, 54 | hashtag.Hashtag 55 | ], safe=True) 56 | 57 | # Perform a query that requires the latest schema; if it fails due to a 58 | # mismatched scheme, delete the old database and re-sync 59 | try: 60 | task.Task.select().where(task.Task.recurrence_count > 0).count() 61 | hashtag.Hashtag.select().where(hashtag.Hashtag.tag == '').count() 62 | except OperationalError: 63 | base.BaseModel._meta.database.close() 64 | wf.clear_data(lambda f: 'mstodo.db' in f) 65 | 66 | # Make sure that this sync does not try to wait until its own process 67 | # finishes 68 | sync(background=True) 69 | return 70 | 71 | first_sync = False 72 | 73 | try: 74 | # get root item from DB. If it doesn't exist then make this the first sync. 75 | user.User.get() 76 | except user.User.DoesNotExist: 77 | first_sync = True 78 | wf.cache_data('last_sync',datetime.utcnow()) 79 | notify( 80 | title='Please wait...', 81 | message='The workflow is syncing tasks for the first time' 82 | ) 83 | 84 | user.User.sync() 85 | taskfolder.TaskFolder.sync() 86 | if first_sync: 87 | task.Task.sync_all_tasks() 88 | else: 89 | task.Task.sync_modified_tasks() 90 | hashtag.Hashtag.sync() 91 | #@TODO move this into a child sync of the relevant tasks once bugfix is completed 92 | 93 | sync_completion_time = datetime.utcnow() 94 | 95 | if first_sync or not background: 96 | log.info(f"Sync completed at {sync_completion_time}") 97 | notify( 98 | title='Sync has completed', 99 | message='All of your tasks are now available for browsing' 100 | ) 101 | 102 | wf.cache_data('last_sync',sync_completion_time) 103 | log.debug(f"This sync time: {sync_completion_time}") 104 | return True 105 | 106 | 107 | def background_sync(): 108 | from workflow.background import run_in_background 109 | task_id = 'sync' 110 | log.debug(f"Last sync time was: {str(wf.cached_data('last_sync', max_age=0))}") 111 | 112 | # Only runs if another sync is not already in progress 113 | run_in_background(task_id, [ 114 | '/usr/bin/env', 115 | 'python3', 116 | wf.workflowfile('alfred_mstodo_workflow.py'), 117 | 'pref sync background', 118 | '--commit' 119 | ]) 120 | 121 | 122 | def background_sync_if_necessary(seconds=30): 123 | last_sync = wf.cached_data('last_sync', max_age=0) 124 | 125 | # Avoid syncing on every keystroke, background_sync will also prevent 126 | # multiple concurrent syncs 127 | if last_sync is None or (datetime.utcnow() - last_sync).total_seconds() > seconds: 128 | background_sync() 129 | -------------------------------------------------------------------------------- /src/mstodo/util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from datetime import date, datetime, timedelta 4 | from workflow import Workflow 5 | from mstodo import __githubslug__, __version__ 6 | 7 | _workflow = None 8 | 9 | SYMBOLS = { 10 | 'star': '★', 11 | 'recurrence': '↻', 12 | 'reminder': '⏰', 13 | 'note': '✏️', 14 | 'overdue_1x': '⚠️', 15 | 'overdue_2x': '❗️' 16 | } 17 | 18 | def wf_wrapper(): 19 | global _workflow 20 | 21 | if _workflow is None: 22 | _workflow = Workflow( 23 | capture_args=False, 24 | update_settings={ 25 | 'github_slug': __githubslug__, 26 | 'version':__version__, 27 | # Check for updates daily 28 | #@TODO: check less frequently as the workflow becomes more 29 | # stable 30 | 'frequency': 1, 31 | 'prerelease': '-' in __version__ 32 | } 33 | ) 34 | 35 | # Override Alfred PyWorkflow logger output configuration 36 | _workflow.logger = logging.getLogger('workflow') 37 | 38 | return _workflow 39 | 40 | def parsedatetime_calendar(): 41 | from parsedatetime import Calendar, VERSION_CONTEXT_STYLE 42 | 43 | return Calendar(parsedatetime_constants(), version=VERSION_CONTEXT_STYLE) 44 | 45 | def parsedatetime_constants(): 46 | from parsedatetime import Constants 47 | from mstodo.models.preferences import Preferences 48 | 49 | loc = Preferences.current_prefs().date_locale or user_locale() 50 | 51 | return Constants(loc) 52 | 53 | def user_locale(): 54 | import locale 55 | 56 | loc = locale.getlocale(locale.LC_TIME)[0] 57 | 58 | if not loc: 59 | # In case the LC_* environment variables are misconfigured, catch 60 | # an exception that may be thrown 61 | try: 62 | loc = locale.getdefaultlocale()[0] 63 | except IndexError: 64 | loc = 'en_US' 65 | 66 | return loc 67 | 68 | def format_time(time, fmt): 69 | cnst = parsedatetime_constants() 70 | 71 | expr = cnst.locale.timeFormats[fmt] 72 | expr = (expr 73 | .replace('HH', '%H') 74 | .replace('h', '%I') 75 | .replace('mm', '%M') 76 | .replace('ss', '%S') 77 | .replace('a', '%p') 78 | .replace('z', '%Z') 79 | .replace('v', '%z')) 80 | 81 | return time.strftime(expr).lstrip('0') 82 | 83 | def short_relative_formatted_date(dt): 84 | dt_date = dt.date() if isinstance(dt, datetime) else dt 85 | today = date.today() 86 | # Mar 3, 2016. Note this is a naive date in local TZ 87 | date_format = '%b %d, %Y' 88 | 89 | if dt_date == today: 90 | return 'today' 91 | if dt_date == today + timedelta(days=1): 92 | return 'tomorrow' 93 | if dt_date == today - timedelta(days=1): 94 | return 'yesterday' 95 | if dt_date.year == today.year: 96 | # Wed, Mar 3 97 | date_format = '%a, %b %d' 98 | 99 | return dt.strftime(date_format) 100 | 101 | def relaunch_alfred(command='td'): 102 | import subprocess 103 | 104 | alfred_major_version = wf_wrapper().alfred_version.tuple[0] 105 | 106 | subprocess.call([ 107 | '/usr/bin/env', 'osascript', '-l', 'JavaScript', 108 | 'bin/launch_alfred.scpt', command, str(alfred_major_version) 109 | ]) 110 | 111 | def utc_to_local(utc_dt): 112 | import calendar 113 | 114 | # get integer timestamp to avoid precision lost. Returns naive local datetime 115 | timestamp = calendar.timegm(utc_dt.timetuple()) 116 | local_dt = datetime.fromtimestamp(timestamp) 117 | return local_dt.replace(microsecond=utc_dt.microsecond) 118 | -------------------------------------------------------------------------------- /src/version: -------------------------------------------------------------------------------- 1 | 0.2.2 -------------------------------------------------------------------------------- /tests/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johandebeurs/alfred-mstodo-workflow/a13b60afadd908a678cba5d7960638b545c3b880/tests/.DS_Store --------------------------------------------------------------------------------