calendula 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calendula-0.1.0/LICENSE +21 -0
- calendula-0.1.0/PKG-INFO +135 -0
- calendula-0.1.0/README.md +110 -0
- calendula-0.1.0/pyproject.toml +63 -0
- calendula-0.1.0/pyproject.toml.orig +44 -0
- calendula-0.1.0/src/calendula/__init__.py +8 -0
- calendula-0.1.0/src/calendula/cache.py +87 -0
- calendula-0.1.0/src/calendula/cli.py +88 -0
- calendula-0.1.0/src/calendula/events.py +153 -0
- calendula-0.1.0/src/calendula/get_calendar.ps1 +100 -0
- calendula-0.1.0/src/calendula/outlook.py +151 -0
- calendula-0.1.0/src/calendula/run_hidden.vbs +2 -0
- calendula-0.1.0/src/calendula/server.py +115 -0
calendula-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miki Yutani
|
|
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.
|
calendula-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: calendula
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that reads your Outlook (classic) calendar from WSL
|
|
5
|
+
Keywords: mcp,outlook,calendar,wsl,claude-code
|
|
6
|
+
Author: Miki Yutani
|
|
7
|
+
Author-email: Miki Yutani <mkyutani@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
20
|
+
Requires-Dist: mcp>=2.3,<3
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Project-URL: Homepage, https://github.com/mkyutani/calendula
|
|
23
|
+
Project-URL: Repository, https://github.com/mkyutani/calendula
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# calendula
|
|
27
|
+
|
|
28
|
+
[](https://opensource.org/licenses/MIT)
|
|
29
|
+
[](https://www.python.org/downloads/)
|
|
30
|
+
[](https://pypi.org/project/calendula/)
|
|
31
|
+
|
|
32
|
+
An [MCP](https://modelcontextprotocol.io/) server that lets Claude Code (or any MCP client) read your **Outlook (classic)** calendar from **WSL**.
|
|
33
|
+
|
|
34
|
+
- Read-only: list the events of a day (or several), or look up the event at a given time with its attendees and body
|
|
35
|
+
- Talks to Outlook on Windows through PowerShell and COM: no Microsoft Graph, no admin consent, no tokens
|
|
36
|
+
- Keeps what it fetched in memory only; nothing is written to disk (see [Caching](#caching))
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Windows with **Outlook (classic)** running. The new Outlook has no COM interface and does not work
|
|
41
|
+
- **WSL**: calendula runs in the Linux distribution and calls `wscript.exe` and `powershell.exe` through WSL interop
|
|
42
|
+
- Python 3.11 or later
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv tool install calendula # or: pipx install calendula
|
|
48
|
+
calendula list # prints today's events (checks that Outlook answers)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Register it with Claude Code for all your projects:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add --scope user calendula -- calendula
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Other MCP clients: run `calendula` as a stdio server.
|
|
58
|
+
|
|
59
|
+
Then ask things like "What's on my calendar tomorrow afternoon?" or "Who is in the 14:00 meeting?".
|
|
60
|
+
|
|
61
|
+
To let Claude read the calendar without asking each time, allow the two read-only tools in `~/.claude/settings.json`:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"permissions": {
|
|
66
|
+
"allow": ["mcp__calendula__list_events", "mcp__calendula__get_event"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Tools
|
|
72
|
+
|
|
73
|
+
| Tool | Arguments | Returns |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `list_events` | `date` (YYYY-MM-DD, default today), `days` (1-31, default 1), `refresh` | Each day's events: time, subject, location |
|
|
76
|
+
| `get_event` | `time` (HH:MM), `date`, `refresh` | The event running at that time (or the one starting nearest to it, with a note): location, organizer, attendees, status, body |
|
|
77
|
+
| `restart_outlook` | none | Force-quits Outlook and starts it again, to recover a broken COM connection. Unsaved drafts are lost, so the model is told to ask you first |
|
|
78
|
+
|
|
79
|
+
Every answer starts with the day and the time it was fetched:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
2026-11-05 (Thu) fetched 2026-11-05 10:32
|
|
83
|
+
09:00-10:00 Weekly sync [Room A]
|
|
84
|
+
13:30-14:30 Customer interview
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
An event that spans several days (a conference, a meeting past midnight) appears on each day it covers. Bodies are kept whole except for blank lines and the Teams join block.
|
|
88
|
+
|
|
89
|
+
## Caching
|
|
90
|
+
|
|
91
|
+
Fetching takes a few seconds (PowerShell starts, then Outlook answers over COM), so fetched days are kept in the server's memory:
|
|
92
|
+
|
|
93
|
+
- **Today and later** are reused for up to 1 hour, then fetched again
|
|
94
|
+
- **Past days** fetched after they ended are kept for the life of the server (one Claude Code session)
|
|
95
|
+
- `refresh: true` always fetches; ask for it when the calendar has just changed
|
|
96
|
+
- A request fetches only the days that are missing or stale, in one call
|
|
97
|
+
- When fetching fails, the error is returned as is; older copies are never shown instead
|
|
98
|
+
|
|
99
|
+
Nothing is written to disk. Set `CALENDULA_TTL_MINUTES` to change the 1 hour.
|
|
100
|
+
|
|
101
|
+
## Command line
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
calendula run the MCP server on stdio (same as `calendula serve`)
|
|
105
|
+
calendula list [DATE] [DAYS] print events, fetched from Outlook now
|
|
106
|
+
calendula show TIME [--date DATE] print the event at a time
|
|
107
|
+
calendula --version
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Troubleshooting
|
|
111
|
+
|
|
112
|
+
- **`CO_E_SERVER_EXEC_FAILURE` (0x80080005) and similar COM errors**: Outlook's COM connection often breaks after sleep or a network change. Restart Outlook (Claude will ask before calling `restart_outlook`)
|
|
113
|
+
- **`Outlook gave no answer`**: check that Outlook (classic) is running
|
|
114
|
+
- **`calendula needs WSL ...`**: calendula only works inside WSL on a Windows PC with Outlook
|
|
115
|
+
|
|
116
|
+
## How it works
|
|
117
|
+
|
|
118
|
+
For each fetch, calendula writes `get_calendar.ps1` (as UTF-16 with a BOM, which Windows PowerShell 5.1 reads correctly) into a temporary directory under `/tmp`, and runs it hidden through `wscript.exe` and `run_hidden.vbs`, using the `\\wsl.localhost\...` path. The script reads the default calendar folder through `Outlook.Application`, including recurring events, and writes `events.json`, which calendula parses.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv sync
|
|
124
|
+
uv run pytest # needs neither Windows nor Outlook
|
|
125
|
+
CALENDULA_WINDOWS_TESTS=1 uv run pytest # on WSL: also the PowerShell side, still without Outlook
|
|
126
|
+
uv run ruff check && uv run ruff format --check
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
What is tested where, and why, is in [docs/test-design.md](docs/test-design.md); the behavior it checks is specified in [docs/spec.md](docs/spec.md).
|
|
130
|
+
|
|
131
|
+
Releases: bump `version` in `pyproject.toml`, then push a tag `vX.Y.Z`. GitHub Actions tests, builds, publishes to TestPyPI, installs from there, publishes to PyPI (Trusted Publishing), and creates a GitHub release.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# calendula
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://pypi.org/project/calendula/)
|
|
6
|
+
|
|
7
|
+
An [MCP](https://modelcontextprotocol.io/) server that lets Claude Code (or any MCP client) read your **Outlook (classic)** calendar from **WSL**.
|
|
8
|
+
|
|
9
|
+
- Read-only: list the events of a day (or several), or look up the event at a given time with its attendees and body
|
|
10
|
+
- Talks to Outlook on Windows through PowerShell and COM: no Microsoft Graph, no admin consent, no tokens
|
|
11
|
+
- Keeps what it fetched in memory only; nothing is written to disk (see [Caching](#caching))
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Windows with **Outlook (classic)** running. The new Outlook has no COM interface and does not work
|
|
16
|
+
- **WSL**: calendula runs in the Linux distribution and calls `wscript.exe` and `powershell.exe` through WSL interop
|
|
17
|
+
- Python 3.11 or later
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv tool install calendula # or: pipx install calendula
|
|
23
|
+
calendula list # prints today's events (checks that Outlook answers)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Register it with Claude Code for all your projects:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
claude mcp add --scope user calendula -- calendula
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Other MCP clients: run `calendula` as a stdio server.
|
|
33
|
+
|
|
34
|
+
Then ask things like "What's on my calendar tomorrow afternoon?" or "Who is in the 14:00 meeting?".
|
|
35
|
+
|
|
36
|
+
To let Claude read the calendar without asking each time, allow the two read-only tools in `~/.claude/settings.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"permissions": {
|
|
41
|
+
"allow": ["mcp__calendula__list_events", "mcp__calendula__get_event"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Arguments | Returns |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `list_events` | `date` (YYYY-MM-DD, default today), `days` (1-31, default 1), `refresh` | Each day's events: time, subject, location |
|
|
51
|
+
| `get_event` | `time` (HH:MM), `date`, `refresh` | The event running at that time (or the one starting nearest to it, with a note): location, organizer, attendees, status, body |
|
|
52
|
+
| `restart_outlook` | none | Force-quits Outlook and starts it again, to recover a broken COM connection. Unsaved drafts are lost, so the model is told to ask you first |
|
|
53
|
+
|
|
54
|
+
Every answer starts with the day and the time it was fetched:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
2026-11-05 (Thu) fetched 2026-11-05 10:32
|
|
58
|
+
09:00-10:00 Weekly sync [Room A]
|
|
59
|
+
13:30-14:30 Customer interview
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
An event that spans several days (a conference, a meeting past midnight) appears on each day it covers. Bodies are kept whole except for blank lines and the Teams join block.
|
|
63
|
+
|
|
64
|
+
## Caching
|
|
65
|
+
|
|
66
|
+
Fetching takes a few seconds (PowerShell starts, then Outlook answers over COM), so fetched days are kept in the server's memory:
|
|
67
|
+
|
|
68
|
+
- **Today and later** are reused for up to 1 hour, then fetched again
|
|
69
|
+
- **Past days** fetched after they ended are kept for the life of the server (one Claude Code session)
|
|
70
|
+
- `refresh: true` always fetches; ask for it when the calendar has just changed
|
|
71
|
+
- A request fetches only the days that are missing or stale, in one call
|
|
72
|
+
- When fetching fails, the error is returned as is; older copies are never shown instead
|
|
73
|
+
|
|
74
|
+
Nothing is written to disk. Set `CALENDULA_TTL_MINUTES` to change the 1 hour.
|
|
75
|
+
|
|
76
|
+
## Command line
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
calendula run the MCP server on stdio (same as `calendula serve`)
|
|
80
|
+
calendula list [DATE] [DAYS] print events, fetched from Outlook now
|
|
81
|
+
calendula show TIME [--date DATE] print the event at a time
|
|
82
|
+
calendula --version
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Troubleshooting
|
|
86
|
+
|
|
87
|
+
- **`CO_E_SERVER_EXEC_FAILURE` (0x80080005) and similar COM errors**: Outlook's COM connection often breaks after sleep or a network change. Restart Outlook (Claude will ask before calling `restart_outlook`)
|
|
88
|
+
- **`Outlook gave no answer`**: check that Outlook (classic) is running
|
|
89
|
+
- **`calendula needs WSL ...`**: calendula only works inside WSL on a Windows PC with Outlook
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
For each fetch, calendula writes `get_calendar.ps1` (as UTF-16 with a BOM, which Windows PowerShell 5.1 reads correctly) into a temporary directory under `/tmp`, and runs it hidden through `wscript.exe` and `run_hidden.vbs`, using the `\\wsl.localhost\...` path. The script reads the default calendar folder through `Outlook.Application`, including recurring events, and writes `events.json`, which calendula parses.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync
|
|
99
|
+
uv run pytest # needs neither Windows nor Outlook
|
|
100
|
+
CALENDULA_WINDOWS_TESTS=1 uv run pytest # on WSL: also the PowerShell side, still without Outlook
|
|
101
|
+
uv run ruff check && uv run ruff format --check
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
What is tested where, and why, is in [docs/test-design.md](docs/test-design.md); the behavior it checks is specified in [docs/spec.md](docs/spec.md).
|
|
105
|
+
|
|
106
|
+
Releases: bump `version` in `pyproject.toml`, then push a tag `vX.Y.Z`. GitHub Actions tests, builds, publishes to TestPyPI, installs from there, publishes to PyPI (Trusted Publishing), and creates a GitHub release.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "calendula"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server that reads your Outlook (classic) calendar from WSL"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = [
|
|
10
|
+
"mcp",
|
|
11
|
+
"outlook",
|
|
12
|
+
"calendar",
|
|
13
|
+
"wsl",
|
|
14
|
+
"claude-code",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Office/Business :: Scheduling",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["mcp>=2.3,<3"]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Miki Yutani"
|
|
32
|
+
email = "mkyutani@gmail.com"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/mkyutani/calendula"
|
|
36
|
+
Repository = "https://github.com/mkyutani/calendula"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
calendula = "calendula.cli:main"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.3.5",
|
|
44
|
+
"ruff>=0.16.10",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.13.7,<0.14"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = [
|
|
53
|
+
"E",
|
|
54
|
+
"F",
|
|
55
|
+
"W",
|
|
56
|
+
"I",
|
|
57
|
+
"N",
|
|
58
|
+
"UP",
|
|
59
|
+
"B",
|
|
60
|
+
"A",
|
|
61
|
+
"C4",
|
|
62
|
+
]
|
|
63
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "calendula"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server that reads your Outlook (classic) calendar from WSL"
|
|
5
|
+
authors = [{ name = "Miki Yutani", email = "mkyutani@gmail.com" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["mcp", "outlook", "calendar", "wsl", "claude-code"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: End Users/Desktop",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Office/Business :: Scheduling",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["mcp>=2.3,<3"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/mkyutani/calendula"
|
|
27
|
+
Repository = "https://github.com/mkyutani/calendula"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
calendula = "calendula.cli:main"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.3.5",
|
|
35
|
+
"ruff>=0.16.10",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.13.7,<0.14"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4"]
|
|
44
|
+
ignore = ["E501"] # Line too long (handled by ruff format)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""calendula: an MCP server that reads your Outlook (classic) calendar from WSL."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("calendula")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
8
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""In-memory cache of fetched days. Nothing is written to disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date, datetime, timedelta
|
|
10
|
+
|
|
11
|
+
from .events import Event
|
|
12
|
+
|
|
13
|
+
MAX_DAYS = 31
|
|
14
|
+
TTL_MINUTES = 60
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ttl_from_env(value: str | None) -> timedelta:
|
|
18
|
+
"""CALENDULA_TTL_MINUTES as a timedelta; anything but a positive integer means the default."""
|
|
19
|
+
try:
|
|
20
|
+
minutes = int(value) if value is not None else TTL_MINUTES
|
|
21
|
+
except ValueError:
|
|
22
|
+
minutes = TTL_MINUTES
|
|
23
|
+
return timedelta(minutes=minutes if minutes > 0 else TTL_MINUTES)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_TTL = ttl_from_env(os.environ.get("CALENDULA_TTL_MINUTES"))
|
|
27
|
+
|
|
28
|
+
Fetcher = Callable[[date, int], dict[date, list[Event]]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Day:
|
|
33
|
+
date: date
|
|
34
|
+
fetched: datetime
|
|
35
|
+
events: list[Event]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Calendar:
|
|
39
|
+
"""Days fetched from Outlook, kept for the life of the server process.
|
|
40
|
+
|
|
41
|
+
- A day that was over when it was fetched (yesterday or before) is kept and never fetched again.
|
|
42
|
+
- Any other day (today, the future, or a past day fetched before it ended) is fetched again
|
|
43
|
+
once it is older than `ttl`.
|
|
44
|
+
- `refresh=True` always fetches.
|
|
45
|
+
Missing or stale days in a request are fetched in one call covering them.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
fetch: Fetcher,
|
|
51
|
+
ttl: timedelta = DEFAULT_TTL,
|
|
52
|
+
now: Callable[[], datetime] = datetime.now,
|
|
53
|
+
):
|
|
54
|
+
self._fetch = fetch
|
|
55
|
+
self._ttl = ttl
|
|
56
|
+
self._now = now
|
|
57
|
+
self._days: dict[date, Day] = {}
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
def _stale(self, d: date, now: datetime) -> bool:
|
|
61
|
+
day = self._days.get(d)
|
|
62
|
+
if day is None:
|
|
63
|
+
return True
|
|
64
|
+
if day.fetched.date() > d:
|
|
65
|
+
return False
|
|
66
|
+
return now - day.fetched >= self._ttl
|
|
67
|
+
|
|
68
|
+
def days(self, start: date, count: int = 1, refresh: bool = False) -> list[Day]:
|
|
69
|
+
if not 1 <= count <= MAX_DAYS:
|
|
70
|
+
raise ValueError(f"days must be between 1 and {MAX_DAYS}")
|
|
71
|
+
wanted = [start + timedelta(days=i) for i in range(count)]
|
|
72
|
+
with self._lock:
|
|
73
|
+
now = self._now()
|
|
74
|
+
need = [d for d in wanted if refresh or self._stale(d, now)]
|
|
75
|
+
if need:
|
|
76
|
+
first, last = need[0], need[-1]
|
|
77
|
+
span = (last - first).days + 1
|
|
78
|
+
fetched = self._fetch(first, span)
|
|
79
|
+
at = self._now()
|
|
80
|
+
for i in range(span):
|
|
81
|
+
d = first + timedelta(days=i)
|
|
82
|
+
self._days[d] = Day(d, at, fetched.get(d, []))
|
|
83
|
+
return [self._days[d] for d in wanted]
|
|
84
|
+
|
|
85
|
+
def clear(self) -> None:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._days.clear()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Command line: run the MCP server, or print events for a quick check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from . import __version__, outlook
|
|
10
|
+
from .cache import MAX_DAYS
|
|
11
|
+
from .events import day_heading, parse_date, parse_time, pick
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _serve(_args: argparse.Namespace) -> int:
|
|
15
|
+
from .server import build_server
|
|
16
|
+
|
|
17
|
+
build_server().run("stdio")
|
|
18
|
+
return 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _list(args: argparse.Namespace) -> int:
|
|
22
|
+
start = parse_date(args.date, datetime.now().date())
|
|
23
|
+
if not 1 <= args.days <= MAX_DAYS:
|
|
24
|
+
raise ValueError(f"days must be between 1 and {MAX_DAYS}")
|
|
25
|
+
days = outlook.fetch(start, args.days)
|
|
26
|
+
fetched = datetime.now()
|
|
27
|
+
blocks = []
|
|
28
|
+
for d in sorted(days):
|
|
29
|
+
lines = [day_heading(d, fetched)] + (
|
|
30
|
+
[e.summary(d) for e in days[d]] or ["(no events)"]
|
|
31
|
+
)
|
|
32
|
+
blocks.append("\n".join(lines))
|
|
33
|
+
print("\n\n".join(blocks))
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _show(args: argparse.Namespace) -> int:
|
|
38
|
+
day = parse_date(args.date, datetime.now().date())
|
|
39
|
+
at = parse_time(args.time)
|
|
40
|
+
events = outlook.fetch(day, 1).get(day, [])
|
|
41
|
+
event, running = pick(events, datetime.combine(day, at))
|
|
42
|
+
print(day_heading(day, datetime.now()))
|
|
43
|
+
if event is None:
|
|
44
|
+
print("No timed events on this day.")
|
|
45
|
+
return 0
|
|
46
|
+
if not running:
|
|
47
|
+
print(
|
|
48
|
+
f"No event is running at {at:%H:%M}; this is the one starting nearest to it."
|
|
49
|
+
)
|
|
50
|
+
print(event.detail(day))
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> int:
|
|
55
|
+
p = argparse.ArgumentParser(
|
|
56
|
+
prog="calendula",
|
|
57
|
+
description="MCP server that reads your Outlook (classic) calendar from WSL. "
|
|
58
|
+
"With no command, runs the server on stdio.",
|
|
59
|
+
)
|
|
60
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
61
|
+
sub = p.add_subparsers(dest="command")
|
|
62
|
+
sub.add_parser(
|
|
63
|
+
"serve", help="run the MCP server on stdio (the default)"
|
|
64
|
+
).set_defaults(func=_serve)
|
|
65
|
+
pl = sub.add_parser("list", help="print events, fetched from Outlook now")
|
|
66
|
+
pl.add_argument("date", nargs="?", help="first day, YYYY-MM-DD (default: today)")
|
|
67
|
+
pl.add_argument(
|
|
68
|
+
"days", nargs="?", type=int, default=1, help="number of days (default: 1)"
|
|
69
|
+
)
|
|
70
|
+
pl.set_defaults(func=_list)
|
|
71
|
+
ps = sub.add_parser(
|
|
72
|
+
"show", help="print the event at a time, fetched from Outlook now"
|
|
73
|
+
)
|
|
74
|
+
ps.add_argument("time", help="HH:MM")
|
|
75
|
+
ps.add_argument("--date", help="YYYY-MM-DD (default: today)")
|
|
76
|
+
ps.set_defaults(func=_show)
|
|
77
|
+
|
|
78
|
+
args = p.parse_args(argv)
|
|
79
|
+
func = getattr(args, "func", _serve)
|
|
80
|
+
try:
|
|
81
|
+
return func(args)
|
|
82
|
+
except (ValueError, outlook.OutlookError) as e:
|
|
83
|
+
print(f"calendula: {e}", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Calendar events: parsing what the PowerShell script writes, and formatting them for the model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, datetime, time, timedelta
|
|
8
|
+
|
|
9
|
+
# Outlook OlBusyStatus
|
|
10
|
+
BUSY = {
|
|
11
|
+
0: "Free",
|
|
12
|
+
1: "Tentative",
|
|
13
|
+
2: "Busy",
|
|
14
|
+
3: "Out of office",
|
|
15
|
+
4: "Working elsewhere",
|
|
16
|
+
}
|
|
17
|
+
DOW = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Event:
|
|
22
|
+
start: datetime
|
|
23
|
+
end: datetime
|
|
24
|
+
subject: str
|
|
25
|
+
all_day: bool = False
|
|
26
|
+
location: str = ""
|
|
27
|
+
organizer: str = ""
|
|
28
|
+
required: str = ""
|
|
29
|
+
optional: str = ""
|
|
30
|
+
categories: str = ""
|
|
31
|
+
busy: int | None = None
|
|
32
|
+
body: tuple[str, ...] = field(default_factory=tuple)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_json(cls, d: dict) -> Event:
|
|
36
|
+
def s(key: str) -> str:
|
|
37
|
+
v = d.get(key)
|
|
38
|
+
return "" if v is None else str(v).strip()
|
|
39
|
+
|
|
40
|
+
body = d.get("body") or []
|
|
41
|
+
if isinstance(body, str): # PowerShell may collapse a one-item array
|
|
42
|
+
body = [body]
|
|
43
|
+
busy = d.get("busy")
|
|
44
|
+
return cls(
|
|
45
|
+
start=datetime.fromisoformat(d["start"]),
|
|
46
|
+
end=datetime.fromisoformat(d["end"]),
|
|
47
|
+
subject=s("subject"),
|
|
48
|
+
all_day=bool(d.get("all_day")),
|
|
49
|
+
location=s("location"),
|
|
50
|
+
organizer=s("organizer"),
|
|
51
|
+
required=s("required"),
|
|
52
|
+
optional=s("optional"),
|
|
53
|
+
categories=s("categories"),
|
|
54
|
+
busy=int(busy) if busy is not None else None,
|
|
55
|
+
body=tuple(str(x) for x in body if str(x).strip()),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def span_on(self, day: date) -> str:
|
|
59
|
+
"""The time span as shown on `day` (spec 3.1, "Time span on day D")."""
|
|
60
|
+
if self.all_day:
|
|
61
|
+
return "All day"
|
|
62
|
+
start = (
|
|
63
|
+
f"{self.start:%H:%M}"
|
|
64
|
+
if self.start.date() == day
|
|
65
|
+
else f"{self.start:%m-%d %H:%M}"
|
|
66
|
+
)
|
|
67
|
+
if self.end.date() == day:
|
|
68
|
+
end = f"{self.end:%H:%M}"
|
|
69
|
+
elif self.end == datetime.combine(day + timedelta(days=1), time()):
|
|
70
|
+
end = "24:00"
|
|
71
|
+
else:
|
|
72
|
+
end = f"{self.end:%m-%d %H:%M}"
|
|
73
|
+
return f"{start}-{end}"
|
|
74
|
+
|
|
75
|
+
def summary(self, day: date) -> str:
|
|
76
|
+
line = f"{self.span_on(day):<11} {self.subject}"
|
|
77
|
+
if self.location:
|
|
78
|
+
line += f" [{self.location}]"
|
|
79
|
+
return line
|
|
80
|
+
|
|
81
|
+
def detail(self, day: date) -> str:
|
|
82
|
+
lines = [f"{self.span_on(day)} {self.subject}"]
|
|
83
|
+
for label, value in (
|
|
84
|
+
("Location", self.location),
|
|
85
|
+
("Organizer", self.organizer),
|
|
86
|
+
("Required attendees", self.required),
|
|
87
|
+
("Optional attendees", self.optional),
|
|
88
|
+
("Categories", self.categories),
|
|
89
|
+
("Status", BUSY.get(self.busy, "") if self.busy is not None else ""),
|
|
90
|
+
):
|
|
91
|
+
if value:
|
|
92
|
+
lines.append(f"{label}: {value}")
|
|
93
|
+
if self.body:
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.extend(self.body)
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_days(data: dict) -> dict[date, list[Event]]:
|
|
100
|
+
"""{date: [events sorted by start]} from the JSON the PowerShell script writes."""
|
|
101
|
+
out: dict[date, list[Event]] = {}
|
|
102
|
+
for day in data.get("days") or []:
|
|
103
|
+
d = date.fromisoformat(day["date"])
|
|
104
|
+
events = day.get("events") or []
|
|
105
|
+
if isinstance(events, dict): # PowerShell may collapse a one-item array
|
|
106
|
+
events = [events]
|
|
107
|
+
out[d] = sorted(
|
|
108
|
+
(Event.from_json(e) for e in events), key=lambda e: (not e.all_day, e.start)
|
|
109
|
+
)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pick(events: list[Event], at: datetime) -> tuple[Event | None, bool]:
|
|
114
|
+
"""(event, running) at the date and time `at` (spec 2.4, B-PICK-1 to B-PICK-5).
|
|
115
|
+
|
|
116
|
+
Running: start <= at < end; of several, the latest start (ties: the first). Otherwise the event
|
|
117
|
+
whose start is nearest to `at` (ties: the earlier), with running False. All-day events are ignored.
|
|
118
|
+
"""
|
|
119
|
+
timed = [e for e in events if not e.all_day]
|
|
120
|
+
running = [e for e in timed if e.start <= at < e.end]
|
|
121
|
+
if running:
|
|
122
|
+
return max(running, key=lambda e: e.start), True
|
|
123
|
+
if timed:
|
|
124
|
+
return min(timed, key=lambda e: abs(e.start - at)), False
|
|
125
|
+
return None, False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def day_heading(d: date, fetched: datetime) -> str:
|
|
129
|
+
return f"{d.isoformat()} ({DOW[d.weekday()]}) fetched {fetched:%Y-%m-%d %H:%M}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
DATE_RE = re.compile(r"^(?:(\d{4})[-/])?(\d{1,2})[-/](\d{1,2})$")
|
|
133
|
+
TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_date(text: str | None, today: date) -> date:
|
|
137
|
+
"""YYYY-MM-DD, YYYY/MM/DD, or MM-DD / MM/DD (this year). Empty means today."""
|
|
138
|
+
if not text or not text.strip():
|
|
139
|
+
return today
|
|
140
|
+
m = DATE_RE.match(text.strip())
|
|
141
|
+
if not m:
|
|
142
|
+
raise ValueError(f"invalid date: {text!r} (use YYYY-MM-DD)")
|
|
143
|
+
try:
|
|
144
|
+
return date(int(m[1] or today.year), int(m[2]), int(m[3]))
|
|
145
|
+
except ValueError:
|
|
146
|
+
raise ValueError(f"invalid date: {text!r}") from None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_time(text: str) -> time:
|
|
150
|
+
m = TIME_RE.match(text.strip())
|
|
151
|
+
if not m or int(m[1]) > 23 or int(m[2]) > 59:
|
|
152
|
+
raise ValueError(f"invalid time: {text!r} (use HH:MM)")
|
|
153
|
+
return time(int(m[1]), int(m[2]))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Writes Outlook (classic) calendar events as JSON. Run by calendula (outlook.py) through run_hidden.vbs.
|
|
2
|
+
#
|
|
3
|
+
# Input (next to this script): start.txt first day, yyyyMMdd
|
|
4
|
+
# days.txt number of days
|
|
5
|
+
# Output: events.json {"days": [{"date": "yyyy-MM-dd", "events": [...]}]} (UTF-8, no BOM)
|
|
6
|
+
# error.txt "ERROR: <message>" when something fails (exit code 1)
|
|
7
|
+
#
|
|
8
|
+
# When dot-sourced (". .\get_calendar.ps1"), only the functions are defined; the tests use that.
|
|
9
|
+
# The contract is in docs/spec.md ("PowerShell script").
|
|
10
|
+
|
|
11
|
+
$inv = [Globalization.CultureInfo]::InvariantCulture
|
|
12
|
+
|
|
13
|
+
# Body lines: trimmed, blank lines dropped, and the Teams meeting block dropped
|
|
14
|
+
# (a line of 10+ underscores followed by "Microsoft Teams", up to the next such line or the end).
|
|
15
|
+
# An underscore line used for anything else (a quoted mail, say) is kept with what follows it.
|
|
16
|
+
function Get-BodyLines([string]$body) {
|
|
17
|
+
$raw = $body -split "`r?`n"
|
|
18
|
+
$out = [System.Collections.Generic.List[string]]::new()
|
|
19
|
+
$skip = $false
|
|
20
|
+
for ($j = 0; $j -lt $raw.Count; $j++) {
|
|
21
|
+
$t = $raw[$j].Trim()
|
|
22
|
+
if ($t -match '^_{10,}$') {
|
|
23
|
+
if ($skip) { $skip = $false; continue }
|
|
24
|
+
$k = $j + 1
|
|
25
|
+
while ($k -lt $raw.Count -and $raw[$k].Trim() -eq "") { $k++ }
|
|
26
|
+
if ($k -lt $raw.Count -and $raw[$k] -match 'Microsoft Teams') { $skip = $true; continue }
|
|
27
|
+
}
|
|
28
|
+
if ($skip -or $t -eq "") { continue }
|
|
29
|
+
$out.Add($t)
|
|
30
|
+
}
|
|
31
|
+
return ,$out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# One appointment as an ordered dictionary with the fields of events.json.
|
|
35
|
+
function ConvertTo-EventRecord($item) {
|
|
36
|
+
[ordered]@{
|
|
37
|
+
start = $item.Start.ToString("yyyy-MM-ddTHH:mm:ss", $inv)
|
|
38
|
+
end = $item.End.ToString("yyyy-MM-ddTHH:mm:ss", $inv)
|
|
39
|
+
all_day = [bool]$item.AllDayEvent
|
|
40
|
+
subject = [string]$item.Subject
|
|
41
|
+
location = [string]$item.Location
|
|
42
|
+
organizer = [string]$item.Organizer
|
|
43
|
+
required = [string]$item.RequiredAttendees
|
|
44
|
+
optional = [string]$item.OptionalAttendees
|
|
45
|
+
categories = [string]$item.Categories
|
|
46
|
+
busy = [int]$item.BusyStatus
|
|
47
|
+
body = (Get-BodyLines ($item.Body + "")).ToArray()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# The Restrict filter for the appointments that overlap $day: they start before the next day
|
|
52
|
+
# and end after this day began (an all-day event ends at 00:00 of the day after its last day).
|
|
53
|
+
function Get-DayFilter([datetime]$day) {
|
|
54
|
+
"[Start] < '" + $day.AddDays(1).ToString("g") + "' AND [End] > '" + $day.ToString("g") + "'"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Records of the appointments in $folder (a calendar MAPIFolder) that overlap $day, sorted by start.
|
|
58
|
+
# Always an array, even with zero or one event.
|
|
59
|
+
function Get-DayEvents($folder, [datetime]$day) {
|
|
60
|
+
$items = $folder.Items
|
|
61
|
+
$items.IncludeRecurrences = $true
|
|
62
|
+
$items.Sort("[Start]")
|
|
63
|
+
$events = [System.Collections.Generic.List[object]]::new()
|
|
64
|
+
foreach ($item in ($items.Restrict((Get-DayFilter $day)) | Sort-Object Start)) {
|
|
65
|
+
$events.Add((ConvertTo-EventRecord $item))
|
|
66
|
+
}
|
|
67
|
+
return ,$events.ToArray()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# events.json text for a list of @{date; events}.
|
|
71
|
+
function ConvertTo-CalendarJson($days) {
|
|
72
|
+
ConvertTo-Json -InputObject ([ordered]@{ days = @($days) }) -Depth 6 -Compress
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Invoke-Main {
|
|
76
|
+
$outFile = "$PSScriptRoot\events.json"
|
|
77
|
+
$errorFile = "$PSScriptRoot\error.txt"
|
|
78
|
+
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
|
79
|
+
Remove-Item $outFile -ErrorAction SilentlyContinue
|
|
80
|
+
Remove-Item $errorFile -ErrorAction SilentlyContinue
|
|
81
|
+
try {
|
|
82
|
+
$start = [DateTime]::ParseExact((Get-Content "$PSScriptRoot\start.txt" -Raw).Trim(), "yyyyMMdd", $inv)
|
|
83
|
+
$count = [int](Get-Content "$PSScriptRoot\days.txt" -Raw).Trim()
|
|
84
|
+
|
|
85
|
+
$ol = New-Object -ComObject Outlook.Application
|
|
86
|
+
$folder = $ol.GetNamespace("MAPI").GetDefaultFolder(9) # olFolderCalendar
|
|
87
|
+
|
|
88
|
+
$days = [System.Collections.Generic.List[object]]::new()
|
|
89
|
+
for ($i = 0; $i -lt $count; $i++) {
|
|
90
|
+
$day = $start.AddDays($i)
|
|
91
|
+
$days.Add([ordered]@{ date = $day.ToString("yyyy-MM-dd", $inv); events = (Get-DayEvents $folder $day) })
|
|
92
|
+
}
|
|
93
|
+
[System.IO.File]::WriteAllText($outFile, (ConvertTo-CalendarJson $days), $utf8)
|
|
94
|
+
} catch {
|
|
95
|
+
[System.IO.File]::WriteAllText($errorFile, "ERROR: " + $_.Exception.Message, $utf8)
|
|
96
|
+
exit 1
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ($MyInvocation.InvocationName -ne '.') { Invoke-Main }
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Bridge from WSL to Outlook (classic) on Windows through PowerShell and COM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .events import Event, parse_days
|
|
15
|
+
|
|
16
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
17
|
+
FETCH_TIMEOUT = 180 # seconds
|
|
18
|
+
RESTART_WAIT = 120 # seconds to wait for OUTLOOK.EXE to appear
|
|
19
|
+
RESTART_SETTLE = 15 # seconds to wait after it appears, before COM answers
|
|
20
|
+
COM_ERROR_RE = re.compile(
|
|
21
|
+
r"CO_E_SERVER_EXEC_FAILURE|80080005|800706BA|800706BE|80010108|RPC_E_", re.I
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
RESTART_HINT = (
|
|
25
|
+
"Outlook's COM connection seems broken. Ask the user whether Outlook may be closed and restarted "
|
|
26
|
+
"(unsaved drafts are lost); if they agree, call restart_outlook and then retry once."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OutlookError(Exception):
|
|
31
|
+
"""Fetching from Outlook failed. `restart` is True when restarting Outlook is likely to help."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, restart: bool = False):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.restart = restart
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
msg = super().__str__()
|
|
39
|
+
return f"{msg}\n{RESTART_HINT}" if self.restart else msg
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require(*commands: str) -> None:
|
|
43
|
+
missing = [c for c in commands if shutil.which(c) is None]
|
|
44
|
+
if missing:
|
|
45
|
+
raise OutlookError(
|
|
46
|
+
"calendula needs WSL with Outlook (classic) on Windows; not found: "
|
|
47
|
+
+ ", ".join(missing)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _winpath(path: Path) -> str:
|
|
52
|
+
return subprocess.run(
|
|
53
|
+
["wslpath", "-w", str(path)], check=True, capture_output=True, text=True
|
|
54
|
+
).stdout.strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_powershell(script: Path, workdir: Path) -> None:
|
|
58
|
+
"""Run a PowerShell script hidden (no console window) through wscript and run_hidden.vbs."""
|
|
59
|
+
ps1 = workdir / script.name
|
|
60
|
+
# Windows PowerShell 5.1 reads a BOM-less script as the ANSI code page; UTF-16LE with a BOM is safe.
|
|
61
|
+
ps1.write_bytes(
|
|
62
|
+
b"\xff\xfe" + script.read_text(encoding="utf-8").encode("utf-16-le")
|
|
63
|
+
)
|
|
64
|
+
vbs = workdir / "run_hidden.vbs"
|
|
65
|
+
shutil.copyfile(PACKAGE_DIR / "run_hidden.vbs", vbs)
|
|
66
|
+
try:
|
|
67
|
+
subprocess.run(
|
|
68
|
+
["wscript.exe", "//B", "//Nologo", _winpath(vbs), _winpath(ps1)],
|
|
69
|
+
check=False,
|
|
70
|
+
timeout=FETCH_TIMEOUT,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
)
|
|
73
|
+
except subprocess.TimeoutExpired:
|
|
74
|
+
raise OutlookError(
|
|
75
|
+
f"Outlook did not answer within {FETCH_TIMEOUT} seconds.", restart=True
|
|
76
|
+
) from None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_result(workdir: Path) -> dict[date, list[Event]]:
|
|
80
|
+
"""Read events.json / error.txt that get_calendar.ps1 left in workdir."""
|
|
81
|
+
error = workdir / "error.txt"
|
|
82
|
+
if error.exists():
|
|
83
|
+
text = error.read_text(encoding="utf-8-sig", errors="replace").strip()
|
|
84
|
+
raise OutlookError(
|
|
85
|
+
text or "ERROR: unknown failure", restart=bool(COM_ERROR_RE.search(text))
|
|
86
|
+
)
|
|
87
|
+
out = workdir / "events.json"
|
|
88
|
+
if not out.exists():
|
|
89
|
+
raise OutlookError(
|
|
90
|
+
"Outlook gave no answer (is Outlook (classic) running?).", restart=True
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
return parse_days(json.loads(out.read_text(encoding="utf-8-sig")))
|
|
94
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
95
|
+
raise OutlookError(f"Unexpected output from Outlook: {e}") from None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch(start: date, days: int) -> dict[date, list[Event]]:
|
|
99
|
+
"""Events for `days` days from `start`, straight from Outlook."""
|
|
100
|
+
_require("wslpath", "wscript.exe")
|
|
101
|
+
with tempfile.TemporaryDirectory(prefix="calendula-") as tmp:
|
|
102
|
+
workdir = Path(tmp)
|
|
103
|
+
(workdir / "start.txt").write_text(f"{start:%Y%m%d}\n", encoding="ascii")
|
|
104
|
+
(workdir / "days.txt").write_text(f"{days}\n", encoding="ascii")
|
|
105
|
+
_run_powershell(PACKAGE_DIR / "get_calendar.ps1", workdir)
|
|
106
|
+
return read_result(workdir)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _outlook_running() -> bool:
|
|
110
|
+
r = subprocess.run(
|
|
111
|
+
["tasklist.exe", "/fi", "IMAGENAME eq OUTLOOK.EXE", "/fo", "csv", "/nh"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
check=False,
|
|
115
|
+
)
|
|
116
|
+
return '"outlook.exe"' in r.stdout.lower()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def restart() -> str:
|
|
120
|
+
"""Force-quit Outlook and start it again. Returns what happened."""
|
|
121
|
+
_require("tasklist.exe", "taskkill.exe", "powershell.exe")
|
|
122
|
+
notes = []
|
|
123
|
+
if _outlook_running():
|
|
124
|
+
subprocess.run(
|
|
125
|
+
["taskkill.exe", "/f", "/im", "OUTLOOK.EXE"],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
check=False,
|
|
128
|
+
)
|
|
129
|
+
notes.append("Outlook was closed.")
|
|
130
|
+
# Starting it before the old process is gone hands the start over to that process.
|
|
131
|
+
for _ in range(10):
|
|
132
|
+
if not _outlook_running():
|
|
133
|
+
break
|
|
134
|
+
time.sleep(1)
|
|
135
|
+
else:
|
|
136
|
+
notes.append("Outlook was not running.")
|
|
137
|
+
subprocess.run(
|
|
138
|
+
["powershell.exe", "-NoProfile", "-Command", "Start-Process outlook.exe"],
|
|
139
|
+
capture_output=True,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
deadline = time.monotonic() + RESTART_WAIT
|
|
143
|
+
while not _outlook_running():
|
|
144
|
+
if time.monotonic() > deadline:
|
|
145
|
+
raise OutlookError(
|
|
146
|
+
f"OUTLOOK.EXE did not start within {RESTART_WAIT} seconds."
|
|
147
|
+
)
|
|
148
|
+
time.sleep(3)
|
|
149
|
+
time.sleep(RESTART_SETTLE)
|
|
150
|
+
notes.append("Outlook was started again.")
|
|
151
|
+
return " ".join(notes)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The MCP server: tools that read the Outlook calendar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from mcp.server import MCPServer
|
|
10
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
11
|
+
from mcp.types import ToolAnnotations
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from . import __version__, outlook
|
|
15
|
+
from .cache import DEFAULT_TTL, MAX_DAYS, Calendar
|
|
16
|
+
from .events import day_heading, parse_date, parse_time, pick
|
|
17
|
+
|
|
18
|
+
INSTRUCTIONS = f"""\
|
|
19
|
+
Reads the user's Outlook (classic) calendar on Windows, from WSL. Read-only.
|
|
20
|
+
|
|
21
|
+
- Events for today and later are reused for up to {int(DEFAULT_TTL.total_seconds() // 60)} minutes \
|
|
22
|
+
after they were fetched; past days are fetched once per session. Every answer says when it was fetched.
|
|
23
|
+
Pass refresh=true when the user says the calendar has just changed, or when an exact, current answer matters.
|
|
24
|
+
- Dates are YYYY-MM-DD in the user's local time. Turn words like "tomorrow" or "next Monday" into dates first.
|
|
25
|
+
- If fetching fails, report the error. Never fill in events from memory or from older answers.
|
|
26
|
+
- If the error says Outlook's COM connection is broken, ask the user for permission before calling \
|
|
27
|
+
restart_outlook (it force-quits Outlook and unsaved drafts are lost), then retry once. Do not restart repeatedly.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
DateArg = Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
Field(description="Day as YYYY-MM-DD (or MM-DD for this year). Default: today."),
|
|
33
|
+
]
|
|
34
|
+
RefreshArg = Annotated[
|
|
35
|
+
bool, Field(description="Fetch from Outlook even if a recent copy is cached.")
|
|
36
|
+
]
|
|
37
|
+
READ_ONLY = ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_server(
|
|
41
|
+
calendar: Calendar | None = None, restart: Callable[[], str] = outlook.restart
|
|
42
|
+
) -> MCPServer:
|
|
43
|
+
cal = calendar or Calendar(outlook.fetch)
|
|
44
|
+
server = MCPServer("calendula", version=__version__, instructions=INSTRUCTIONS)
|
|
45
|
+
|
|
46
|
+
def today() -> date:
|
|
47
|
+
return datetime.now().date()
|
|
48
|
+
|
|
49
|
+
@server.tool(annotations=READ_ONLY)
|
|
50
|
+
def list_events(
|
|
51
|
+
date: DateArg = None,
|
|
52
|
+
days: Annotated[
|
|
53
|
+
int, Field(ge=1, le=MAX_DAYS, description="Number of days from `date`.")
|
|
54
|
+
] = 1,
|
|
55
|
+
refresh: RefreshArg = False,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""List events (time, subject, location) day by day. Use get_event for attendees and the body."""
|
|
58
|
+
try:
|
|
59
|
+
fetched = cal.days(parse_date(date, today()), days, refresh)
|
|
60
|
+
except (ValueError, outlook.OutlookError) as e:
|
|
61
|
+
raise ToolError(str(e)) from None
|
|
62
|
+
blocks = []
|
|
63
|
+
for day in fetched:
|
|
64
|
+
lines = [day_heading(day.date, day.fetched)]
|
|
65
|
+
lines += [e.summary(day.date) for e in day.events] or ["(no events)"]
|
|
66
|
+
blocks.append("\n".join(lines))
|
|
67
|
+
return "\n\n".join(blocks)
|
|
68
|
+
|
|
69
|
+
@server.tool(annotations=READ_ONLY)
|
|
70
|
+
def get_event(
|
|
71
|
+
time: Annotated[str, Field(description="Time as HH:MM (24-hour).")],
|
|
72
|
+
date: DateArg = None,
|
|
73
|
+
refresh: RefreshArg = False,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Details of the event at a time: location, organizer, attendees, status, and the body.
|
|
76
|
+
|
|
77
|
+
If no event is running at that time, returns the timed event that starts nearest to it (and says so).
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
at = parse_time(time)
|
|
81
|
+
(day,) = cal.days(parse_date(date, today()), 1, refresh)
|
|
82
|
+
except (ValueError, outlook.OutlookError) as e:
|
|
83
|
+
raise ToolError(str(e)) from None
|
|
84
|
+
event, running = pick(day.events, datetime.combine(day.date, at))
|
|
85
|
+
head = day_heading(day.date, day.fetched)
|
|
86
|
+
if event is None:
|
|
87
|
+
return f"{head}\nNo timed events on this day."
|
|
88
|
+
note = (
|
|
89
|
+
""
|
|
90
|
+
if running
|
|
91
|
+
else f"No event is running at {at:%H:%M}; this is the one starting nearest to it.\n"
|
|
92
|
+
)
|
|
93
|
+
return f"{head}\n{note}{event.detail(day.date)}"
|
|
94
|
+
|
|
95
|
+
@server.tool(
|
|
96
|
+
annotations=ToolAnnotations(
|
|
97
|
+
readOnlyHint=False,
|
|
98
|
+
destructiveHint=True,
|
|
99
|
+
idempotentHint=False,
|
|
100
|
+
openWorldHint=False,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
def restart_outlook() -> str:
|
|
104
|
+
"""Force-quit Outlook (classic) and start it again, to recover a broken COM connection.
|
|
105
|
+
|
|
106
|
+
Unsaved drafts are lost. Call it only after the user has agreed, and only after a fetch failed with a COM error.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
result = restart()
|
|
110
|
+
except outlook.OutlookError as e:
|
|
111
|
+
raise ToolError(str(e)) from None
|
|
112
|
+
cal.clear()
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
return server
|