applescript_handler.py•18.7 kB
import subprocess
from typing import List, Dict, Any, Optional
import json
import re
class AppleScriptHandler:
    """Handles AppleScript execution for Things3 data retrieval."""
    @staticmethod
    def run_script(script: str) -> str:
        """
        Executes an AppleScript and returns its output.
        """
        try:
            result = subprocess.run(
                ['osascript', '-e', script],
                check=True,
                capture_output=True,
                text=True
            )
            return result.stdout.strip()
        except subprocess.CalledProcessError as e:
            raise RuntimeError(f"Failed to execute AppleScript: {e}")
    @staticmethod
    def safe_string_for_applescript(text: str) -> str:
        """
        Safely escape a string for use in AppleScript by handling quotes and special characters.
        """
        if not text:
            return ""
        
        # Replace backslashes first to avoid double escaping
        text = text.replace("\\", "\\\\")
        # Replace quotes with escaped quotes
        text = text.replace('"', '\\"')
        # Replace newlines with \\n
        text = text.replace("\n", "\\n")
        text = text.replace("\r", "\\r")
        
        return text
    @staticmethod
    def parse_applescript_record(record_str: str) -> Dict[str, Any]:
        """
        Parse an AppleScript record string into a Python dictionary.
        More robust than string concatenation for JSON.
        """
        result = {}
        
        # Remove outer braces if present
        record_str = record_str.strip()
        if record_str.startswith('{') and record_str.endswith('}'):
            record_str = record_str[1:-1]
        
        # Split by commas but be careful about nested structures
        parts = []
        current_part = ""
        brace_count = 0
        
        for char in record_str:
            if char == '{':
                brace_count += 1
            elif char == '}':
                brace_count -= 1
            elif char == ',' and brace_count == 0:
                parts.append(current_part.strip())
                current_part = ""
                continue
            current_part += char
        
        if current_part.strip():
            parts.append(current_part.strip())
        
        # Parse each key-value pair
        for part in parts:
            if ':' in part:
                key, value = part.split(':', 1)
                key = key.strip().strip('"\'')
                value = value.strip().strip('"\'')
                result[key] = value
        
        return result
    @staticmethod
    def get_inbox_tasks() -> List[Dict[str, Any]]:
        """
        Retrieves tasks from the Inbox using AppleScript with improved data handling.
        """
        script = '''
        tell application "Things3"
            set inboxTasks to to dos of list "Inbox"
            set resultList to {}
            
            repeat with t in inboxTasks
                set taskTitle to name of t
                
                set taskNotes to ""
                if notes of t is not missing value then
                    set taskNotes to notes of t
                end if
                
                set dueDate to ""
                if due date of t is not missing value then
                    set dueDate to ((due date of t) as string)
                end if
                
                set whenDate to ""
                if activation date of t is not missing value then
                    set whenDate to ((activation date of t) as string)
                end if
                
                set tagText to ""
                try
                    set tagList to tag names of t
                    if tagList is not {} then
                        set AppleScript's text item delimiters to ","
                        set tagText to tagList as string
                        set AppleScript's text item delimiters to ""
                    end if
                end try
                
                -- Create a record for this task
                set taskRecord to {title:taskTitle, notes:taskNotes, due_date:dueDate, when_date:whenDate, tags:tagText}
                set end of resultList to taskRecord
            end repeat
            
            return resultList
        end tell
        '''
        try:
            result = AppleScriptHandler.run_script(script)
            
            # If we get an empty result or AppleScript list, return empty list
            if not result or result == "{}" or result == "":
                return []
            
            # Parse AppleScript record format to extract task data
            tasks = []
            
            # Handle AppleScript list format: {{record1}, {record2}, ...}
            if result.startswith('{{') and result.endswith('}}'):
                # Remove outer braces and split by }, {
                records_str = result[2:-2]  # Remove {{ and }}
                record_strings = re.split(r'\}, \{', records_str)
                
                for record_str in record_strings:
                    # Clean up the record string
                    record_str = record_str.strip()
                    if not record_str.startswith('{'):
                        record_str = '{' + record_str
                    if not record_str.endswith('}'):
                        record_str = record_str + '}'
                    
                    # Parse the record
                    task_data = AppleScriptHandler.parse_applescript_record(record_str)
                    
                    # Convert to our expected format
                    task = {
                        "title": task_data.get("title", ""),
                        "notes": task_data.get("notes", ""),
                        "due_date": task_data.get("due_date", ""),
                        "when": task_data.get("when_date", ""),
                        "tags": task_data.get("tags", "")
                    }
                    tasks.append(task)
            
            return tasks
            
        except Exception as e:
            # Log the error but return empty list rather than crashing
            print(f"Error retrieving inbox tasks: {e}")
            return []
    @staticmethod
    def get_todays_tasks() -> List[Dict[str, Any]]:
        """
        Retrieves today's tasks from Things3 using AppleScript with improved data handling.
        """
        script = '''
        tell application "Things3"
            set todayTasks to to dos of list "Today"
            set resultList to {}
            
            repeat with t in todayTasks
                set taskTitle to name of t
                
                set taskNotes to ""
                if notes of t is not missing value then
                    set taskNotes to notes of t
                end if
                
                set dueDate to ""
                if due date of t is not missing value then
                    set dueDate to ((due date of t) as string)
                end if
                
                set startDate to ""
                try
                    if start date of t is not missing value then
                        set startDate to ((start date of t) as string)
                    end if
                on error
                    set startDate to ""
                end try
                
                set whenDate to ""
                if activation date of t is not missing value then
                    set whenDate to ((activation date of t) as string)
                end if
                
                set tagText to ""
                try
                    set tagList to tag names of t
                    if tagList is not {} then
                        set AppleScript's text item delimiters to ","
                        set tagText to tagList as string
                        set AppleScript's text item delimiters to ""
                    end if
                end try
                
                -- Create a record for this task
                set taskRecord to {title:taskTitle, notes:taskNotes, due_date:dueDate, start_date:startDate, when_date:whenDate, tags:tagText}
                set end of resultList to taskRecord
            end repeat
            
            return resultList
        end tell
        '''
        try:
            result = AppleScriptHandler.run_script(script)
            
            # If we get an empty result, return empty list
            if not result or result == "{}" or result == "":
                return []
            
            # Parse AppleScript record format to extract task data
            tasks = []
            
            # Handle AppleScript list format
            if result.startswith('{{') and result.endswith('}}'):
                records_str = result[2:-2]
                record_strings = re.split(r'\}, \{', records_str)
                
                for record_str in record_strings:
                    record_str = record_str.strip()
                    if not record_str.startswith('{'):
                        record_str = '{' + record_str
                    if not record_str.endswith('}'):
                        record_str = record_str + '}'
                    
                    task_data = AppleScriptHandler.parse_applescript_record(record_str)
                    
                    task = {
                        "title": task_data.get("title", ""),
                        "notes": task_data.get("notes", ""),
                        "due_date": task_data.get("due_date", ""),
                        "start_date": task_data.get("start_date", ""),
                        "when": task_data.get("when_date", ""),
                        "tags": task_data.get("tags", "")
                    }
                    tasks.append(task)
            
            return tasks
            
        except Exception as e:
            print(f"Error retrieving today's tasks: {e}")
            return []
    @staticmethod
    def get_projects() -> List[Dict[str, str]]:
        """
        Retrieves all projects from Things3 using AppleScript with improved data handling.
        """
        script = '''
        tell application "Things3"
            set projectList to projects
            set resultList to {}
            
            repeat with p in projectList
                set projectTitle to name of p
                set projectNotes to ""
                if notes of p is not missing value then
                    set projectNotes to notes of p
                end if
                
                -- Create a record for this project
                set projectRecord to {title:projectTitle, notes:projectNotes}
                set end of resultList to projectRecord
            end repeat
            
            return resultList
        end tell
        '''
        try:
            result = AppleScriptHandler.run_script(script)
            
            if not result or result == "{}" or result == "":
                return []
            
            projects = []
            
            # Handle AppleScript list format
            if result.startswith('{{') and result.endswith('}}'):
                records_str = result[2:-2]
                record_strings = re.split(r'\}, \{', records_str)
                
                for record_str in record_strings:
                    record_str = record_str.strip()
                    if not record_str.startswith('{'):
                        record_str = '{' + record_str
                    if not record_str.endswith('}'):
                        record_str = record_str + '}'
                    
                    project_data = AppleScriptHandler.parse_applescript_record(record_str)
                    
                    project = {
                        "title": project_data.get("title", ""),
                        "notes": project_data.get("notes", "")
                    }
                    projects.append(project)
            
            return projects
            
        except Exception as e:
            print(f"Error retrieving projects: {e}")
            return []
    @staticmethod
    def validate_things3_access() -> bool:
        """
        Validate that Things3 is accessible and responsive.
        """
        try:
            script = '''
            tell application "Things3"
                return name of application "Things3"
            end tell
            '''
            result = AppleScriptHandler.run_script(script)
            return "Things3" in result
        except Exception:
            return False
    @staticmethod
    def complete_todo_by_title(title_search: str) -> bool:
        """
        Mark a todo as completed by searching for its title.
        """
        script = f'''
        tell application "Things3"
            set foundTodo to missing value
            
            -- Search in Today list
            set todayTodos to to dos of list "Today"
            repeat with t in todayTodos
                if name of t contains "{AppleScriptHandler.safe_string_for_applescript(title_search)}" then
                    set foundTodo to t
                    exit repeat
                end if
            end repeat
            
            -- If not found in Today, search in Inbox
            if foundTodo is missing value then
                set inboxTodos to to dos of list "Inbox"
                repeat with t in inboxTodos
                    if name of t contains "{AppleScriptHandler.safe_string_for_applescript(title_search)}" then
                        set foundTodo to t
                        exit repeat
                    end if
                end repeat
            end if
            
            -- If not found in standard lists, search all todos
            if foundTodo is missing value then
                set allTodos to to dos
                repeat with t in allTodos
                    if name of t contains "{AppleScriptHandler.safe_string_for_applescript(title_search)}" and status of t is not completed then
                        set foundTodo to t
                        exit repeat
                    end if
                end repeat
            end if
            
            -- Complete the todo if found
            if foundTodo is not missing value then
                set status of foundTodo to completed
                return "COMPLETED:" & name of foundTodo
            else
                return "NOT_FOUND"
            end if
        end tell
        '''
        
        try:
            result = AppleScriptHandler.run_script(script)
            return result.startswith("COMPLETED:")
        except Exception:
            return False
    @staticmethod
    def search_todos(query: str) -> List[Dict[str, Any]]:
        """
        Search for todos by title or content.
        """
        script = f'''
        tell application "Things3"
            set searchQuery to "{AppleScriptHandler.safe_string_for_applescript(query)}"
            set foundTodos to {{}}
            set allTodos to to dos
            
            repeat with t in allTodos
                set taskTitle to name of t
                set taskNotes to ""
                if notes of t is not missing value then
                    set taskNotes to notes of t
                end if
                
                -- Check if query matches title or notes
                if taskTitle contains searchQuery or taskNotes contains searchQuery then
                    set taskStatus to "incomplete"
                    if status of t is completed then
                        set taskStatus to "completed"
                    end if
                    
                    set dueDate to ""
                    if due date of t is not missing value then
                        set dueDate to ((due date of t) as string)
                    end if
                    
                    set whenDate to ""
                    if activation date of t is not missing value then
                        set whenDate to ((activation date of t) as string)
                    end if
                    
                    set tagText to ""
                    try
                        set tagList to tag names of t
                        if tagList is not {{}} then
                            set AppleScript's text item delimiters to ","
                            set tagText to tagList as string
                            set AppleScript's text item delimiters to ""
                        end if
                    end try
                    
                    set taskRecord to {{title:taskTitle, notes:taskNotes, status:taskStatus, due_date:dueDate, when_date:whenDate, tags:tagText}}
                    set end of foundTodos to taskRecord
                end if
            end repeat
            
            return foundTodos
        end tell
        '''
        
        try:
            result = AppleScriptHandler.run_script(script)
            
            if not result or result == "{{}}" or result == "":
                return []
            
            todos = []
            
            # Handle AppleScript list format
            if result.startswith('{{{{') and result.endswith('}}}}'):
                records_str = result[4:-4]  # Remove outer {{ and }}
                record_strings = re.split(r'\}\}, \{\{', records_str)
                
                for record_str in record_strings:
                    record_str = record_str.strip()
                    if not record_str.startswith('{{'):
                        record_str = '{{' + record_str
                    if not record_str.endswith('}}'):
                        record_str = record_str + '}}'
                    
                    # Remove the outer braces for parsing
                    inner_record = record_str[2:-2]
                    task_data = AppleScriptHandler.parse_applescript_record('{' + inner_record + '}')
                    
                    todo = {
                        "title": task_data.get("title", ""),
                        "notes": task_data.get("notes", ""),
                        "status": task_data.get("status", "unknown"),
                        "due_date": task_data.get("due_date", ""),
                        "when": task_data.get("when_date", ""),
                        "tags": task_data.get("tags", "")
                    }
                    todos.append(todo)
            
            return todos
            
        except Exception as e:
            print(f"Error searching todos: {e}")
            return []