/**
* Things Client - Interface for Things App URL Scheme
*
* This module handles communication with the Things app through its URL scheme.
* Things provides a URL scheme that allows external applications to interact
* with it by opening specially formatted URLs.
*
* The Things URL scheme format is: things:///command?parameter1=value1¶meter2=value2
* An authentication token is required for most operations.
*/
// Import Node.js built-in modules
import { platform } from 'os'; // For checking if we're on macOS
import { URL } from 'url'; // For URL construction and validation
// Import third-party package for opening URLs
// The 'open' package allows us to open URLs with the default application
import open from 'open';
/**
* Client class for interacting with the Things app via URL scheme
*
* This class encapsulates all the logic for building Things URLs,
* validating parameters, and executing commands.
*/
export class ThingsClient {
constructor() {
// Get authentication token from environment variables
// Environment variables in Node.js are accessed via process.env
this.authToken = process.env.THINGS_AUTHENTICATION_TOKEN;
// Base URL for all Things commands
this.baseUrl = 'things:///';
// Validate the environment
this.validateEnvironment();
}
/**
* Validate that the environment is suitable for Things integration
*
* Things is a macOS-only application, so we need to check that we're
* running on macOS. We also check for the required auth token.
*/
validateEnvironment() {
// Check if we're running on macOS
// The os.platform() function returns the operating system platform
if (platform() !== 'darwin') {
throw new Error('Things app is only available on macOS');
}
// Check if auth token is provided
// Note: For the show commands in this first iteration, the token might not be required,
// but we'll warn if it's missing since it will be needed for other operations
if (!this.authToken) {
console.warn('THINGS_AUTHENTICATION_TOKEN not found in environment variables. Some operations may fail.');
}
}
/**
* Build a properly formatted Things URL
*
* This method constructs a valid Things URL with the given command and parameters.
* It handles URL encoding and parameter validation.
*
* @param {string} command - The Things command (e.g., 'show', 'add')
* @param {Object} params - Parameters for the command
* @returns {string} - The complete Things URL
*/
buildUrl(command, params = {}) {
try {
// Start with the base Things URL and add the command
const url = new URL(command, this.baseUrl);
// Build query string manually to avoid URLSearchParams encoding spaces as '+'
const queryParts = [];
// Add authentication token if available and not a simple show command
// Show commands typically don't require authentication
if (this.authToken && command !== 'show') {
queryParts.push(`auth-token=${encodeURIComponent(this.authToken)}`);
}
// Add all other parameters
// Object.entries() gives us an array of [key, value] pairs
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
// Use encodeURIComponent to properly encode parameters while preserving spaces as %20
queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
});
// Manually set the search string to avoid URLSearchParams automatic encoding
if (queryParts.length > 0) {
url.search = '?' + queryParts.join('&');
}
return url.toString();
} catch (error) {
throw new Error(`Failed to build Things URL: ${error.message}`);
}
}
/**
* Execute a Things command by opening the URL
*
* This method opens the constructed Things URL, which causes macOS to
* launch the Things app and execute the command.
*
* @param {string} command - The Things command to execute
* @param {Object} params - Parameters for the command
* @returns {Promise<boolean>} - True if the URL was successfully opened
*/
async executeCommand(command, params = {}) {
try {
// Build the complete Things URL
const thingsUrl = this.buildUrl(command, params);
console.error(`Executing Things command: ${thingsUrl}`);
// Open the URL with the default application (Things)
// The 'await' keyword waits for the Promise to resolve
// The 'open' function returns a Promise that resolves when the URL is opened
await open(thingsUrl, {
// Specify that we want to open with the default app for this URL scheme
wait: false, // Don't wait for the app to close
});
return true;
} catch (error) {
console.error(`Failed to execute Things command: ${error.message}`);
throw new Error(`Things command execution failed: ${error.message}`);
}
}
/**
* Show a specific list or item in Things
*
* This is a convenience method for the 'show' command, which is used
* to navigate to different views within the Things app.
*
* @param {string} id - The ID of the list or item to show (e.g., 'today', 'inbox')
* @returns {Promise<boolean>} - True if successful
*/
async showList(id) {
return this.executeCommand('show', { id });
}
/**
* Show the Today list in Things
*
* This is a specific implementation for showing the Today list,
* which is commonly used and mentioned in the requirements.
*
* @returns {Promise<boolean>} - True if successful
*/
async showTodayList() {
return this.showList('today');
}
/**
* Add a new to-do item to Things
*
* This method creates a new to-do item using the Things 'add' command.
* The add command supports many parameters for setting title, notes,
* due dates, tags, and more.
*
* @param {Object} todoData - The to-do item data
* @param {string} todoData.title - The title/name of the to-do (required)
* @param {string} [todoData.notes] - Notes or description for the to-do
* @param {string} [todoData.when] - When to schedule it (today, tomorrow, evening, etc.)
* @param {string} [todoData.deadline] - Due date for the to-do
* @param {string} [todoData.tags] - Comma-separated list of tags
* @param {string} [todoData.checklist] - Checklist items (line-separated)
* @param {string} [todoData.list] - Which list to add to (inbox by default)
* @returns {Promise<boolean>} - True if successful
*/
async addTodo(todoData) {
// Validate required parameters
if (!todoData || !todoData.title) {
throw new Error('Todo title is required');
}
// Build parameters object for the URL
const params = {
title: todoData.title,
};
// Add optional parameters if provided
// Only add parameters that have values to keep URLs clean
if (todoData.notes) params.notes = todoData.notes;
if (todoData.when) params.when = todoData.when;
if (todoData.deadline) params.deadline = todoData.deadline;
if (todoData.tags) params.tags = todoData.tags;
if (todoData.checklist) params.checklist = todoData.checklist;
if (todoData.list) params.list = todoData.list;
return this.executeCommand('add', params);
}
/**
* Add a new project to Things
*
* This method creates a new project using the Things 'add-project' command.
* Projects in Things are containers for organizing related to-dos.
*
* @param {Object} projectData - The project data
* @param {string} projectData.title - The title/name of the project (required)
* @param {string} [projectData.notes] - Notes or description for the project
* @param {string} [projectData.when] - When to schedule the project
* @param {string} [projectData.deadline] - Due date for the project
* @param {string} [projectData.tags] - Comma-separated list of tags
* @param {string} [projectData.area] - Which area to add the project to
* @param {Array} [projectData.todos] - Array of to-do items to add to the project
* @returns {Promise<boolean>} - True if successful
*/
async addProject(projectData) {
// Validate required parameters
if (!projectData || !projectData.title) {
throw new Error('Project title is required');
}
// Build parameters object for the URL
const params = {
title: projectData.title,
};
// Add optional parameters if provided
if (projectData.notes) params.notes = projectData.notes;
if (projectData.when) params.when = projectData.when;
if (projectData.deadline) params.deadline = projectData.deadline;
if (projectData.tags) params.tags = projectData.tags;
if (projectData.area) params.area = projectData.area;
// Handle todos array - convert to newline-separated string
if (projectData.todos && Array.isArray(projectData.todos)) {
params.todos = projectData.todos.join('\n');
}
return this.executeCommand('add-project', params);
}
/**
* Search for content in Things
*
* This method performs a search using the Things 'search' command.
* It will open Things and display search results for the given query.
*
* @param {string} query - The search query
* @returns {Promise<boolean>} - True if successful
*/
async search(query) {
// Validate required parameters
if (!query || typeof query !== 'string' || query.trim() === '') {
throw new Error('Search query is required and must be a non-empty string');
}
return this.executeCommand('search', { query: query.trim() });
}
/**
* Update an existing to-do item in Things
*
* This method updates an existing to-do using the Things 'update' command.
* The update command allows modifying various properties of existing items.
*
* @param {Object} updateData - The to-do update data
* @param {string} updateData.id - The unique identifier of the to-do to update (required)
* @param {string} [updateData.title] - New title/name for the to-do
* @param {string} [updateData.notes] - New notes or description for the to-do
* @param {string} [updateData.when] - When to reschedule the to-do
* @param {string} [updateData.deadline] - New due date for the to-do
* @param {string} [updateData.tags] - New comma-separated list of tags
* @param {string} [updateData.checklist] - New checklist items (line-separated)
* @param {string} [updateData.list] - Move to a different list
* @returns {Promise<boolean>} - True if successful
*/
async updateTodo(updateData) {
// Validate required parameters
if (!updateData || !updateData.id) {
throw new Error('Todo ID is required for updates');
}
// Build parameters object for the URL
const params = {
id: updateData.id,
};
// Add optional parameters if provided
// For update operations, we need to be careful about undefined vs empty string
if (updateData.title !== undefined) params.title = updateData.title;
if (updateData.notes !== undefined) params.notes = updateData.notes;
if (updateData.when !== undefined) params.when = updateData.when;
if (updateData.deadline !== undefined) params.deadline = updateData.deadline;
if (updateData.tags !== undefined) params.tags = updateData.tags;
if (updateData.checklist !== undefined) params.checklist = updateData.checklist;
if (updateData.list !== undefined) params.list = updateData.list;
return this.executeCommand('update', params);
}
/**
* Update an existing project in Things
*
* This method updates an existing project using the Things 'update' command.
* Projects can have their properties modified just like to-dos.
*
* @param {Object} updateData - The project update data
* @param {string} updateData.id - The unique identifier of the project to update (required)
* @param {string} [updateData.title] - New title/name for the project
* @param {string} [updateData.notes] - New notes or description for the project
* @param {string} [updateData.when] - When to reschedule the project
* @param {string} [updateData.deadline] - New due date for the project
* @param {string} [updateData.tags] - New comma-separated list of tags
* @param {string} [updateData.area] - Move to a different area
* @returns {Promise<boolean>} - True if successful
*/
async updateProject(updateData) {
// Validate required parameters
if (!updateData || !updateData.id) {
throw new Error('Project ID is required for updates');
}
// Build parameters object for the URL
const params = {
id: updateData.id,
};
// Add optional parameters if provided
if (updateData.title !== undefined) params.title = updateData.title;
if (updateData.notes !== undefined) params.notes = updateData.notes;
if (updateData.when !== undefined) params.when = updateData.when;
if (updateData.deadline !== undefined) params.deadline = updateData.deadline;
if (updateData.tags !== undefined) params.tags = updateData.tags;
if (updateData.area !== undefined) params.area = updateData.area;
return this.executeCommand('update', params);
}
/**
* Mark a to-do item as completed in Things
*
* This method marks a to-do as completed using the Things 'update' command
* with the completed status parameter.
*
* @param {Object} completeData - The completion data
* @param {string} completeData.id - The unique identifier of the to-do to complete (required)
* @returns {Promise<boolean>} - True if successful
*/
async completeTodo(completeData) {
// Validate required parameters
if (!completeData || !completeData.id) {
throw new Error('Todo ID is required for completion');
}
// Build parameters for completing the to-do
const params = {
id: completeData.id,
completed: true, // Mark as completed
};
return this.executeCommand('update', params);
}
/**
* Perform advanced search in Things with filters
*
* This method performs a sophisticated search using multiple parameters
* and filters to narrow down results in the Things app.
*
* @param {Object} searchParams - The search parameters and filters
* @param {string} searchParams.query - The search query (required)
* @param {string} [searchParams.status] - Filter by completion status
* @param {string} [searchParams.type] - Filter by item type
* @param {string} [searchParams.area] - Filter by area
* @param {string} [searchParams.project] - Filter by project
* @param {string} [searchParams.tag] - Filter by tag
* @param {string} [searchParams.list] - Filter by list
* @param {string} [searchParams.start_date] - Filter start date
* @param {string} [searchParams.end_date] - Filter end date
* @param {number} [searchParams.limit] - Limit number of results
* @returns {Promise<boolean>} - True if successful
*/
async searchAdvanced(searchParams) {
// Validate required parameters
if (!searchParams || !searchParams.query) {
throw new Error('Search query is required for advanced search');
}
// Build parameters object for the URL
const params = {
query: searchParams.query,
};
// Add filter parameters if provided
if (searchParams.status) params.status = searchParams.status;
if (searchParams.type) params.type = searchParams.type;
if (searchParams.area) params.area = searchParams.area;
if (searchParams.project) params.project = searchParams.project;
if (searchParams.tag) params.tag = searchParams.tag;
if (searchParams.list) params.list = searchParams.list;
if (searchParams.start_date) params['start-date'] = searchParams.start_date;
if (searchParams.end_date) params['end-date'] = searchParams.end_date;
if (searchParams.limit) params.limit = searchParams.limit;
return this.executeCommand('search', params);
}
/**
* Execute JSON command for complex operations
*
* This method handles complex JSON-based commands that support
* sophisticated operations beyond simple URL parameters.
*
* @param {string} commandType - The type of JSON command
* @param {Object} payload - The JSON payload for the command
* @returns {Promise<boolean>} - True if successful
*/
async executeJsonCommand(commandType, payload) {
// Validate required parameters
if (!commandType || !payload) {
throw new Error('Command type and payload are required for JSON commands');
}
// Convert payload to JSON string for URL parameter
const jsonPayload = JSON.stringify(payload);
// Build parameters for the JSON command
const params = {
'command-type': commandType,
data: jsonPayload
};
// Use a special 'json-command' endpoint (if supported by Things)
// Note: This is a conceptual implementation as Things may not support this directly
return this.executeCommand('json-command', params);
}
/**
* Show a specific item by ID in Things
*
* This method opens a specific item (todo, project, area) in Things
* using the show command with an ID parameter.
*
* @param {string} itemId - The unique identifier of the item to show
* @returns {Promise<boolean>} - True if successful
*/
async showSpecificItem(itemId) {
// Validate required parameters
if (!itemId) {
throw new Error('Item ID is required to show specific item');
}
// Use the show command with the item ID
return this.executeCommand('show', { id: itemId });
}
/**
* Note: Things URL scheme does not support export/import operations
*
* For data export/import, users should use:
* 1. Things app's built-in export feature (File > Export)
* 2. Things app's built-in import feature (File > Import)
* 3. Third-party sync services supported by Things
*
* The URL scheme is designed for automation and integration,
* not for bulk data operations.
*/
/**
* Validate that Things app is available
*
* This method can be used to check if the Things app is installed
* and accessible via URL scheme. Note: This is a basic implementation
* that assumes if we're on macOS, Things might be available.
*
* @returns {boolean} - True if Things appears to be available
*/
isThingsAvailable() {
// Basic check - we're on macOS and have the URL scheme
// A more sophisticated check would try to open Things and see if it responds
return platform() === 'darwin';
}
}