├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | > 
53 | >
54 | > **td Buy clicky keyboard in shopping due sat**
55 | >
56 | > 
57 |
58 | > **td Rearrange file cabinet tomorrow in WO**
59 | >
60 | > 
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 | > 
72 | >
73 | > **td Make a New Year's resolution reminder: Jan 1 at midnight**
74 | >
75 | > 
76 | >
77 | > **td weekly meeting notes r 8am due 1d**
78 | >
79 | > 
80 | >
81 | > **td Ask about app icon at dinner tomorrow**
82 | >
83 | > 
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 | 
108 |
109 | #### View a list
110 | 
111 |
112 | #### Search within a list
113 | 
114 |
115 | #### Search across all lists
116 |
117 | Your search will match against tasks as well as list names.
118 |
119 | 
120 |
121 | #### Browse tasks by hashtag
122 |
123 | Type the hash symbol # to view and select a tag.
124 |
125 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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
--------------------------------------------------------------------------------