iflow-mcp_excelsier-things3-enhanced-mcp 1.0.0__py3-none-any.whl
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.
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/METADATA +444 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/RECORD +20 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/WHEEL +4 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/licenses/LICENSE +24 -0
- things_mcp/__init__.py +5 -0
- things_mcp/applescript_bridge.py +335 -0
- things_mcp/cache.py +240 -0
- things_mcp/config.py +120 -0
- things_mcp/fast_server.py +633 -0
- things_mcp/formatters.py +128 -0
- things_mcp/handlers.py +601 -0
- things_mcp/logging_config.py +218 -0
- things_mcp/mcp_tools.py +465 -0
- things_mcp/simple_server.py +687 -0
- things_mcp/simple_url_scheme.py +230 -0
- things_mcp/tag_handler.py +111 -0
- things_mcp/things_server.py +106 -0
- things_mcp/url_scheme.py +318 -0
- things_mcp/utils.py +360 -0
things_mcp/url_scheme.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
import webbrowser
|
|
3
|
+
import things
|
|
4
|
+
import subprocess
|
|
5
|
+
import platform
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import logging
|
|
9
|
+
import json
|
|
10
|
+
from typing import Optional, Dict, Any, Union, Callable
|
|
11
|
+
from .utils import app_state, circuit_breaker, dead_letter_queue, rate_limiter, is_things_running
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
def launch_things() -> bool:
|
|
16
|
+
"""Launch Things app if not already running.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
bool: True if successful, False otherwise
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
if is_things_running():
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
['open', '-a', 'Things3'],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
check=False
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Give Things time to launch
|
|
33
|
+
time.sleep(2)
|
|
34
|
+
|
|
35
|
+
return is_things_running()
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(f"Error launching Things: {str(e)}")
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def execute_url(url: str) -> bool:
|
|
41
|
+
"""Execute a Things URL by opening it in the default browser.
|
|
42
|
+
Returns True if successful, False otherwise.
|
|
43
|
+
"""
|
|
44
|
+
# Ensure any + signs in the URL are replaced with %20
|
|
45
|
+
url = url.replace("+", "%20")
|
|
46
|
+
|
|
47
|
+
# Log the URL for debugging
|
|
48
|
+
logger.debug(f"Executing URL: {url}")
|
|
49
|
+
|
|
50
|
+
# Apply rate limiting
|
|
51
|
+
rate_limiter.wait_if_needed()
|
|
52
|
+
|
|
53
|
+
# Check if circuit breaker allows the operation
|
|
54
|
+
if not circuit_breaker.allow_operation():
|
|
55
|
+
logger.warning("Circuit breaker is open, blocking operation")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
# Check if Things is running, attempt to launch if not
|
|
60
|
+
if not is_things_running():
|
|
61
|
+
logger.info("Things is not running, attempting to launch")
|
|
62
|
+
if not launch_things():
|
|
63
|
+
logger.error("Failed to launch Things")
|
|
64
|
+
circuit_breaker.record_failure()
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# Execute the URL
|
|
68
|
+
result = webbrowser.open(url)
|
|
69
|
+
|
|
70
|
+
if not result:
|
|
71
|
+
circuit_breaker.record_failure()
|
|
72
|
+
logger.error(f"Failed to open URL: {url}")
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Add a small delay to allow Things time to process the command
|
|
76
|
+
# Add jitter to prevent thundering herd problem
|
|
77
|
+
delay = 0.5 + random.uniform(0, 0.2) # 0.5-0.7 seconds
|
|
78
|
+
time.sleep(delay)
|
|
79
|
+
|
|
80
|
+
circuit_breaker.record_success()
|
|
81
|
+
return True
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to execute URL: {url}, Error: {str(e)}")
|
|
84
|
+
circuit_breaker.record_failure()
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def execute_xcallback_url(action: str, params: Dict[str, Any]) -> bool:
|
|
89
|
+
"""Execute a Things X-Callback-URL.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
action: The X-Callback action to perform
|
|
93
|
+
params: Parameters for the action
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
bool: True if successful, False otherwise
|
|
97
|
+
"""
|
|
98
|
+
# The correct format for Things URLs (no 'x-callback-url/' prefix)
|
|
99
|
+
base_url = "things:///"
|
|
100
|
+
|
|
101
|
+
# Add callback parameters, but only if we need them
|
|
102
|
+
# For now, avoid using callbacks since we don't have a handler for them
|
|
103
|
+
callback_params = params.copy()
|
|
104
|
+
|
|
105
|
+
# Don't add callback URLs - this avoids the "no application set to open URL" error
|
|
106
|
+
# If we need callbacks later, we'd need to register a URL handler for our app
|
|
107
|
+
|
|
108
|
+
# Construct URL - action is part of the path (not a separate query parameter)
|
|
109
|
+
url = f"{base_url}{action}?{urllib.parse.urlencode(callback_params)}"
|
|
110
|
+
|
|
111
|
+
# Log the URL for debugging
|
|
112
|
+
logger.debug(f"Executing URL: {url}")
|
|
113
|
+
|
|
114
|
+
return execute_url(url)
|
|
115
|
+
|
|
116
|
+
def construct_url(command: str, params: Dict[str, Any]) -> str:
|
|
117
|
+
"""Construct a Things URL from command and parameters."""
|
|
118
|
+
# Pre-process all string parameters to replace any + signs with spaces
|
|
119
|
+
cleaned_params = {}
|
|
120
|
+
for key, value in params.items():
|
|
121
|
+
if isinstance(value, str):
|
|
122
|
+
# Replace any + signs with spaces in the original input
|
|
123
|
+
cleaned_params[key] = value.replace("+", " ")
|
|
124
|
+
else:
|
|
125
|
+
cleaned_params[key] = value
|
|
126
|
+
|
|
127
|
+
# Use the cleaned params from now on
|
|
128
|
+
params = cleaned_params
|
|
129
|
+
|
|
130
|
+
# Start with base URL
|
|
131
|
+
url = f"things:///{command}"
|
|
132
|
+
|
|
133
|
+
# Get authentication token if needed - applies to all commands to ensure reliability
|
|
134
|
+
try:
|
|
135
|
+
# Import here to avoid circular imports
|
|
136
|
+
from . import config
|
|
137
|
+
|
|
138
|
+
# Get token from config system
|
|
139
|
+
token = config.get_things_auth_token()
|
|
140
|
+
|
|
141
|
+
if token:
|
|
142
|
+
# Add token to all params for consistent behavior
|
|
143
|
+
params['auth-token'] = token
|
|
144
|
+
logger.debug(f"Auth token from config used for {command} operation")
|
|
145
|
+
else:
|
|
146
|
+
logger.warning(f"No Things auth token found in config. URL may not work without a token.")
|
|
147
|
+
# Note: We continue without a token, which may cause the operation to fail
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Error getting auth token: {str(e)}")
|
|
150
|
+
# Continue without token - the operation may fail
|
|
151
|
+
|
|
152
|
+
# Disable JSON API for now as it's causing formatting issues
|
|
153
|
+
# JSON API is currently experimental and unreliable
|
|
154
|
+
# We'll use the standard URL scheme instead which is more reliable
|
|
155
|
+
use_json_api = False
|
|
156
|
+
|
|
157
|
+
if False and command in ['add'] and use_json_api:
|
|
158
|
+
# This code is disabled but kept for reference
|
|
159
|
+
logger.info("JSON API is currently disabled due to formatting issues")
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# Standard URL scheme encoding
|
|
163
|
+
if params:
|
|
164
|
+
encoded_params = []
|
|
165
|
+
for key, value in params.items():
|
|
166
|
+
if value is None:
|
|
167
|
+
continue
|
|
168
|
+
# Handle boolean values
|
|
169
|
+
if isinstance(value, bool):
|
|
170
|
+
value = str(value).lower()
|
|
171
|
+
# Handle lists (for tags, checklist items etc)
|
|
172
|
+
elif isinstance(value, list) and key == 'tags':
|
|
173
|
+
# Important: Tags are sensitive to formatting in Things URL scheme
|
|
174
|
+
# Based on testing, using a simple comma-separated list without spaces works best
|
|
175
|
+
encoded_tags = []
|
|
176
|
+
for tag in value:
|
|
177
|
+
# Ensure tag is properly encoded as string
|
|
178
|
+
tag_str = str(tag).strip()
|
|
179
|
+
if tag_str: # Only add non-empty tags
|
|
180
|
+
encoded_tags.append(tag_str)
|
|
181
|
+
|
|
182
|
+
# Only include non-empty tag lists
|
|
183
|
+
if encoded_tags:
|
|
184
|
+
# Join with commas - Things expects comma-separated tags without spaces between commas
|
|
185
|
+
# Use a simple comma with no spacing for maximum compatibility
|
|
186
|
+
value = ','.join(encoded_tags)
|
|
187
|
+
else:
|
|
188
|
+
# If no valid tags, don't include this parameter
|
|
189
|
+
continue
|
|
190
|
+
# Handle other lists
|
|
191
|
+
elif isinstance(value, list):
|
|
192
|
+
value = ','.join(str(v) for v in value)
|
|
193
|
+
|
|
194
|
+
# Ensure proper encoding of the value - use quote_plus to handle spaces correctly
|
|
195
|
+
# Then replace + with %20 to ensure Things handles spaces correctly
|
|
196
|
+
encoded_value = urllib.parse.quote(str(value), safe='')
|
|
197
|
+
# Replace + with %20 for better compatibility with Things
|
|
198
|
+
encoded_value = encoded_value.replace('+', '%20')
|
|
199
|
+
encoded_params.append(f"{key}={encoded_value}")
|
|
200
|
+
|
|
201
|
+
url += "?" + "&".join(encoded_params)
|
|
202
|
+
|
|
203
|
+
return url
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def should_use_json_api() -> bool:
|
|
207
|
+
"""Determine if the JSON API should be used based on Things version."""
|
|
208
|
+
from .utils import detect_things_version
|
|
209
|
+
|
|
210
|
+
version = detect_things_version()
|
|
211
|
+
if not version:
|
|
212
|
+
# Default to using JSON API if version can't be determined
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# Parse version string (e.g., '3.15.4')
|
|
217
|
+
major, minor, _ = map(int, version.split('.'))
|
|
218
|
+
|
|
219
|
+
# JSON API is available in Things 3.4+
|
|
220
|
+
return major > 3 or (major == 3 and minor >= 4)
|
|
221
|
+
except Exception:
|
|
222
|
+
# Default to standard URL scheme if version parsing fails
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
def add_todo(title: str, notes: Optional[str] = None, when: Optional[str] = None,
|
|
226
|
+
deadline: Optional[str] = None, tags: Optional[list[str]] = None,
|
|
227
|
+
checklist_items: Optional[list[str]] = None, list_id: Optional[str] = None,
|
|
228
|
+
list_title: Optional[str] = None, heading: Optional[str] = None,
|
|
229
|
+
completed: Optional[bool] = None) -> str:
|
|
230
|
+
"""Construct URL to add a new todo."""
|
|
231
|
+
params = {
|
|
232
|
+
'title': title,
|
|
233
|
+
'notes': notes,
|
|
234
|
+
'when': when,
|
|
235
|
+
'deadline': deadline,
|
|
236
|
+
'checklist-items': '\n'.join(checklist_items) if checklist_items else None,
|
|
237
|
+
'list-id': list_id,
|
|
238
|
+
'list': list_title,
|
|
239
|
+
'heading': heading,
|
|
240
|
+
'completed': completed
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Handle tags separately since they need to be comma-separated
|
|
244
|
+
if tags:
|
|
245
|
+
params['tags'] = ','.join(tags)
|
|
246
|
+
return construct_url('add', {k: v for k, v in params.items() if v is not None})
|
|
247
|
+
|
|
248
|
+
def add_project(title: str, notes: Optional[str] = None, when: Optional[str] = None,
|
|
249
|
+
deadline: Optional[str] = None, tags: Optional[list[str]] = None,
|
|
250
|
+
area_id: Optional[str] = None, area_title: Optional[str] = None,
|
|
251
|
+
todos: Optional[list[str]] = None) -> str:
|
|
252
|
+
"""Construct URL to add a new project."""
|
|
253
|
+
params = {
|
|
254
|
+
'title': title,
|
|
255
|
+
'notes': notes,
|
|
256
|
+
'when': when,
|
|
257
|
+
'deadline': deadline,
|
|
258
|
+
'area-id': area_id,
|
|
259
|
+
'area': area_title,
|
|
260
|
+
# Change todos to be newline separated
|
|
261
|
+
'to-dos': '\n'.join(todos) if todos else None
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Handle tags separately since they need to be comma-separated
|
|
265
|
+
if tags:
|
|
266
|
+
params['tags'] = ','.join(tags)
|
|
267
|
+
|
|
268
|
+
return construct_url('add-project', {k: v for k, v in params.items() if v is not None})
|
|
269
|
+
|
|
270
|
+
def update_todo(id: str, title: Optional[str] = None, notes: Optional[str] = None,
|
|
271
|
+
when: Optional[str] = None, deadline: Optional[str] = None,
|
|
272
|
+
tags: Optional[Union[list[str], str]] = None, add_tags: Optional[Union[list[str], str]] = None,
|
|
273
|
+
checklist_items: Optional[list[str]] = None,
|
|
274
|
+
completed: Optional[bool] = None, canceled: Optional[bool] = None) -> str:
|
|
275
|
+
"""Construct URL to update an existing todo."""
|
|
276
|
+
params = {
|
|
277
|
+
'id': id,
|
|
278
|
+
'title': title,
|
|
279
|
+
'notes': notes,
|
|
280
|
+
'when': when,
|
|
281
|
+
'deadline': deadline,
|
|
282
|
+
'tags': tags,
|
|
283
|
+
'add-tags': add_tags, # Support for adding tags without replacing existing ones
|
|
284
|
+
'checklist-items': '\n'.join(checklist_items) if checklist_items else None,
|
|
285
|
+
'completed': completed,
|
|
286
|
+
'canceled': canceled
|
|
287
|
+
}
|
|
288
|
+
return construct_url('update', {k: v for k, v in params.items() if v is not None})
|
|
289
|
+
|
|
290
|
+
def update_project(id: str, title: Optional[str] = None, notes: Optional[str] = None,
|
|
291
|
+
when: Optional[str] = None, deadline: Optional[str] = None,
|
|
292
|
+
tags: Optional[list[str]] = None, completed: Optional[bool] = None,
|
|
293
|
+
canceled: Optional[bool] = None) -> str:
|
|
294
|
+
"""Construct URL to update an existing project."""
|
|
295
|
+
params = {
|
|
296
|
+
'id': id,
|
|
297
|
+
'title': title,
|
|
298
|
+
'notes': notes,
|
|
299
|
+
'when': when,
|
|
300
|
+
'deadline': deadline,
|
|
301
|
+
'tags': tags,
|
|
302
|
+
'completed': completed,
|
|
303
|
+
'canceled': canceled
|
|
304
|
+
}
|
|
305
|
+
return construct_url('update-project', {k: v for k, v in params.items() if v is not None})
|
|
306
|
+
|
|
307
|
+
def show(id: str, query: Optional[str] = None, filter_tags: Optional[list[str]] = None) -> str:
|
|
308
|
+
"""Construct URL to show a specific item or list."""
|
|
309
|
+
params = {
|
|
310
|
+
'id': id,
|
|
311
|
+
'query': query,
|
|
312
|
+
'filter': filter_tags
|
|
313
|
+
}
|
|
314
|
+
return construct_url('show', {k: v for k, v in params.items() if v is not None})
|
|
315
|
+
|
|
316
|
+
def search(query: str) -> str:
|
|
317
|
+
"""Construct URL to perform a search."""
|
|
318
|
+
return construct_url('search', {'query': query})
|
things_mcp/utils.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility classes and functions for enhancing Things MCP reliability.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import mcp.types as types
|
|
12
|
+
from typing import Dict, Any, Optional, Callable, List, Union
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_things_running() -> bool:
|
|
18
|
+
"""Check if Things app is running.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
bool: True if Things is running, False otherwise
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
if platform.system() != 'Darwin':
|
|
25
|
+
logger.warning("Things availability check only works on macOS")
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
['osascript', '-e', 'tell application "System Events" to (name of processes) contains "Things3"'],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
check=False
|
|
33
|
+
)
|
|
34
|
+
is_running = result.stdout.strip().lower() == 'true'
|
|
35
|
+
|
|
36
|
+
# If Things is running, also check if it's responsive
|
|
37
|
+
if is_running:
|
|
38
|
+
# Simple ping to see if Things responds
|
|
39
|
+
ping_result = subprocess.run(
|
|
40
|
+
['osascript', '-e', 'tell application "Things3" to return name'],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=False,
|
|
44
|
+
timeout=2 # 2 second timeout
|
|
45
|
+
)
|
|
46
|
+
is_responsive = ping_result.returncode == 0
|
|
47
|
+
|
|
48
|
+
if not is_responsive:
|
|
49
|
+
logger.warning("Things is running but not responsive")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
return is_running
|
|
53
|
+
except subprocess.TimeoutExpired:
|
|
54
|
+
logger.warning("Things app check timed out")
|
|
55
|
+
return False
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"Error checking if Things is running: {str(e)}")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
class ThingsAppState:
|
|
61
|
+
"""Track and manage Things app state"""
|
|
62
|
+
|
|
63
|
+
def __init__(self):
|
|
64
|
+
self.is_available = False
|
|
65
|
+
self.last_check_time = 0
|
|
66
|
+
self.check_interval = 5 # seconds
|
|
67
|
+
self.update_app_state()
|
|
68
|
+
|
|
69
|
+
def update_app_state(self):
|
|
70
|
+
"""Update app availability state"""
|
|
71
|
+
current_time = time.time()
|
|
72
|
+
if current_time - self.last_check_time > self.check_interval:
|
|
73
|
+
self.is_available = is_things_running()
|
|
74
|
+
self.last_check_time = current_time
|
|
75
|
+
return self.is_available
|
|
76
|
+
|
|
77
|
+
def wait_for_app_availability(self, timeout=10):
|
|
78
|
+
"""Wait for app to become available within timeout"""
|
|
79
|
+
start_time = time.time()
|
|
80
|
+
while time.time() - start_time < timeout:
|
|
81
|
+
if self.update_app_state():
|
|
82
|
+
return True
|
|
83
|
+
time.sleep(0.5)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_tool_registration(tools: list[types.Tool]) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Validate that all required Things MCP tools are properly registered.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
tools: List of registered tools
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
bool: True if all required tools are registered, False otherwise
|
|
96
|
+
"""
|
|
97
|
+
required_tool_names = [
|
|
98
|
+
"get-inbox", "get-today", "get-upcoming", "get-anytime",
|
|
99
|
+
"get-someday", "get-logbook", "get-trash", "get-todos",
|
|
100
|
+
"get-projects", "get-areas", "get-tags", "get-tagged-items",
|
|
101
|
+
"search-todos", "search-advanced", "get-recent", "add-todo",
|
|
102
|
+
"search-items", "add-project", "update-todo", "update-project", "show-item"
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
registered_tool_names = [tool.name for tool in tools]
|
|
106
|
+
|
|
107
|
+
# Check if all required tools are registered
|
|
108
|
+
missing_tools = [name for name in required_tool_names if name not in registered_tool_names]
|
|
109
|
+
|
|
110
|
+
if missing_tools:
|
|
111
|
+
logger.error(f"Missing required tool registrations: {missing_tools}")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
# Check if all registered tools have proper descriptions and parameters
|
|
115
|
+
for tool in tools:
|
|
116
|
+
if not tool.description or len(tool.description) < 10:
|
|
117
|
+
logger.warning(f"Tool '{tool.name}' has an insufficient description")
|
|
118
|
+
|
|
119
|
+
# Basic parameter validation could be added here
|
|
120
|
+
# This would depend on your tool schema requirements
|
|
121
|
+
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CircuitBreaker:
|
|
126
|
+
"""Circuit breaker to prevent repeated failed attempts"""
|
|
127
|
+
|
|
128
|
+
# Circuit states
|
|
129
|
+
CLOSED = "closed" # Normal operation
|
|
130
|
+
OPEN = "open" # Not allowing operations
|
|
131
|
+
HALF_OPEN = "half-open" # Testing if system has recovered
|
|
132
|
+
|
|
133
|
+
def __init__(self, failure_threshold=5, recovery_timeout=60):
|
|
134
|
+
self.state = self.CLOSED
|
|
135
|
+
self.failure_count = 0
|
|
136
|
+
self.failure_threshold = failure_threshold
|
|
137
|
+
self.recovery_timeout = recovery_timeout
|
|
138
|
+
self.last_failure_time = 0
|
|
139
|
+
|
|
140
|
+
def record_failure(self):
|
|
141
|
+
"""Record a failure and potentially open the circuit"""
|
|
142
|
+
self.failure_count += 1
|
|
143
|
+
self.last_failure_time = time.time()
|
|
144
|
+
|
|
145
|
+
if self.failure_count >= self.failure_threshold:
|
|
146
|
+
self.state = self.OPEN
|
|
147
|
+
logger.warning(f"Circuit breaker opened after {self.failure_count} failures")
|
|
148
|
+
|
|
149
|
+
def record_success(self):
|
|
150
|
+
"""Record a success and reset the circuit if in half-open state"""
|
|
151
|
+
if self.state == self.HALF_OPEN:
|
|
152
|
+
self.state = self.CLOSED
|
|
153
|
+
self.failure_count = 0
|
|
154
|
+
logger.info("Circuit breaker closed after successful operation")
|
|
155
|
+
elif self.state == self.CLOSED:
|
|
156
|
+
self.failure_count = 0
|
|
157
|
+
|
|
158
|
+
def allow_operation(self):
|
|
159
|
+
"""Check if operation should be allowed"""
|
|
160
|
+
if self.state == self.CLOSED:
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
if self.state == self.OPEN:
|
|
164
|
+
# Check if recovery timeout has elapsed
|
|
165
|
+
if time.time() - self.last_failure_time > self.recovery_timeout:
|
|
166
|
+
self.state = self.HALF_OPEN
|
|
167
|
+
logger.info("Circuit breaker half-open, testing system recovery")
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
# Half-open state allows a single test operation
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DeadLetterQueue:
|
|
176
|
+
"""Store persistently failed operations for manual review"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, dlq_file="things_dlq.json"):
|
|
179
|
+
self.dlq_file = dlq_file
|
|
180
|
+
self.queue = self._load_queue()
|
|
181
|
+
|
|
182
|
+
def _load_queue(self):
|
|
183
|
+
"""Load persisted queue"""
|
|
184
|
+
try:
|
|
185
|
+
if os.path.exists(self.dlq_file):
|
|
186
|
+
with open(self.dlq_file, 'r') as f:
|
|
187
|
+
return json.load(f)
|
|
188
|
+
return []
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Error loading DLQ: {str(e)}")
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
def _save_queue(self):
|
|
194
|
+
"""Persist queue to disk"""
|
|
195
|
+
try:
|
|
196
|
+
with open(self.dlq_file, 'w') as f:
|
|
197
|
+
json.dump(self.queue, f)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Error saving DLQ: {str(e)}")
|
|
200
|
+
|
|
201
|
+
def add_failed_operation(self, operation, params, error, attempts=1):
|
|
202
|
+
"""Add failed operation to dead letter queue"""
|
|
203
|
+
entry = {
|
|
204
|
+
"operation": operation,
|
|
205
|
+
"params": params,
|
|
206
|
+
"error": str(error),
|
|
207
|
+
"attempts": attempts,
|
|
208
|
+
"timestamp": time.time(),
|
|
209
|
+
"added_at": time.strftime("%Y-%m-%d %H:%M:%S")
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
self.queue.append(entry)
|
|
213
|
+
self._save_queue()
|
|
214
|
+
logger.warning(f"Added to DLQ: {operation} with error: {str(error)}")
|
|
215
|
+
|
|
216
|
+
def retry_all(self):
|
|
217
|
+
"""Attempt to retry all operations in the DLQ"""
|
|
218
|
+
if not self.queue:
|
|
219
|
+
return {"success": True, "retried": 0, "failed": 0}
|
|
220
|
+
|
|
221
|
+
from .url_scheme import construct_url, execute_url
|
|
222
|
+
|
|
223
|
+
success_count = 0
|
|
224
|
+
failure_count = 0
|
|
225
|
+
remaining_queue = []
|
|
226
|
+
|
|
227
|
+
for entry in self.queue:
|
|
228
|
+
try:
|
|
229
|
+
url = construct_url(entry["operation"], entry["params"])
|
|
230
|
+
result = retry_operation(lambda: execute_url(url))
|
|
231
|
+
|
|
232
|
+
if result:
|
|
233
|
+
success_count += 1
|
|
234
|
+
else:
|
|
235
|
+
entry["attempts"] += 1
|
|
236
|
+
remaining_queue.append(entry)
|
|
237
|
+
failure_count += 1
|
|
238
|
+
except Exception:
|
|
239
|
+
entry["attempts"] += 1
|
|
240
|
+
remaining_queue.append(entry)
|
|
241
|
+
failure_count += 1
|
|
242
|
+
|
|
243
|
+
self.queue = remaining_queue
|
|
244
|
+
self._save_queue()
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"success": failure_count == 0,
|
|
248
|
+
"retried": success_count + failure_count,
|
|
249
|
+
"failed": failure_count
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class RateLimiter:
|
|
254
|
+
"""Intelligent rate limiter for Things operations"""
|
|
255
|
+
|
|
256
|
+
def __init__(self, operations_per_minute=30):
|
|
257
|
+
self.operations_per_minute = operations_per_minute
|
|
258
|
+
self.operation_interval = 60 / operations_per_minute
|
|
259
|
+
self.last_operation_time = 0
|
|
260
|
+
|
|
261
|
+
def wait_if_needed(self):
|
|
262
|
+
"""Wait if necessary to maintain rate limit"""
|
|
263
|
+
current_time = time.time()
|
|
264
|
+
time_since_last = current_time - self.last_operation_time
|
|
265
|
+
|
|
266
|
+
if time_since_last < self.operation_interval:
|
|
267
|
+
# Need to wait
|
|
268
|
+
wait_time = self.operation_interval - time_since_last
|
|
269
|
+
time.sleep(wait_time)
|
|
270
|
+
|
|
271
|
+
self.last_operation_time = time.time()
|
|
272
|
+
|
|
273
|
+
def __call__(self, func):
|
|
274
|
+
"""Decorator to rate limit a function"""
|
|
275
|
+
def wrapper(*args, **kwargs):
|
|
276
|
+
self.wait_if_needed()
|
|
277
|
+
return func(*args, **kwargs)
|
|
278
|
+
return wrapper
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_auth_token() -> Optional[str]:
|
|
282
|
+
"""Get the Things authentication token from various possible sources.
|
|
283
|
+
|
|
284
|
+
The function tries to get the token from:
|
|
285
|
+
1. Environment variable THINGS_AUTH_TOKEN
|
|
286
|
+
2. Local config file
|
|
287
|
+
3. Hardcoded fallback value
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
str: Authentication token if found, None otherwise
|
|
291
|
+
"""
|
|
292
|
+
# Try environment variable first
|
|
293
|
+
token = os.environ.get('THINGS_AUTH_TOKEN')
|
|
294
|
+
if token:
|
|
295
|
+
logger.info("Using Things authentication token from environment variable")
|
|
296
|
+
return token
|
|
297
|
+
|
|
298
|
+
# Try local config file
|
|
299
|
+
try:
|
|
300
|
+
config_path = os.path.expanduser("~/.things_config.json")
|
|
301
|
+
if os.path.exists(config_path):
|
|
302
|
+
with open(config_path, 'r') as f:
|
|
303
|
+
config = json.load(f)
|
|
304
|
+
if 'auth_token' in config and config['auth_token']:
|
|
305
|
+
logger.info("Using Things authentication token from config file")
|
|
306
|
+
return config['auth_token']
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.warning(f"Failed to read auth token from config file: {str(e)}")
|
|
309
|
+
|
|
310
|
+
# No token found from dynamic sources
|
|
311
|
+
logger.warning("No Things authentication token found in environment or config")
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def detect_things_version():
|
|
316
|
+
"""Detect installed Things version"""
|
|
317
|
+
try:
|
|
318
|
+
# Use AppleScript to get Things version
|
|
319
|
+
script = 'tell application "Things3" to return version'
|
|
320
|
+
result = subprocess.run(
|
|
321
|
+
['osascript', '-e', script],
|
|
322
|
+
capture_output=True,
|
|
323
|
+
text=True,
|
|
324
|
+
check=False
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
328
|
+
return result.stdout.strip()
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error(f"Error detecting Things version: {str(e)}")
|
|
331
|
+
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def validate_tool_registration(tool_list):
|
|
336
|
+
"""Validate that all tools are properly registered"""
|
|
337
|
+
required_tools = [
|
|
338
|
+
"get-inbox", "get-today", "get-upcoming", "get-anytime",
|
|
339
|
+
"get-someday", "get-logbook", "get-trash", "get-todos",
|
|
340
|
+
"get-projects", "get-areas", "get-tags", "get-tagged-items",
|
|
341
|
+
"search-todos", "search-advanced", "get-recent", "add-todo",
|
|
342
|
+
"add-project", "update-todo", "update-project", "show-item"
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
tool_names = [t.name for t in tool_list]
|
|
346
|
+
missing_tools = [tool for tool in required_tools if tool not in tool_names]
|
|
347
|
+
|
|
348
|
+
if missing_tools:
|
|
349
|
+
logger.error(f"Missing tool registrations: {missing_tools}")
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
logger.info(f"All {len(tool_list)} tools are properly registered")
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Create global instances
|
|
357
|
+
app_state = ThingsAppState()
|
|
358
|
+
circuit_breaker = CircuitBreaker()
|
|
359
|
+
dead_letter_queue = DeadLetterQueue()
|
|
360
|
+
rate_limiter = RateLimiter()
|