Hi everyone,
I’ve been working on a script to send items to Calendar for events and time blocking. It takes items you’ve selected (highlighted) and creates events. It is quite simple in that it doesn’t handle duplicates or sync back updates.
Tags:
@start(DATE TIME)
(required): The event start time. Accepts formats likeYYYY-MM-DD HH:MM
,YYYY-MM-DD
, orHH:MM
(today assumed).@duration(Xd Yh Zm)
or@allday
(required): Event length (e.g.,@duration(1h 30m)
) or all-day.@calendar(NAME)
(optional): Name of your macOS calendar; defaults to your specified default calendar if omitted.
For example,
Today:
- Morning Review @start(08:30) @duration(30m) @calendar(Personal)
- Project Y @start(09:00) @duration(15m) @calendar(Work)
- Focused Work: Feature X @start(09:15) @duration(2h45m) @calendar(Work)
- Lunch Break @start(12:00) @duration(1h)
- Admin & Emails @start(15:00) @duration(45m)
Tomorrow:
- Dentist Appointment @start(2025-04-06 11:00) @duration(45m)
Note that you need to make sure the calendar exists otherwise it won’t create the event. The default is “TaskPaper”
Here is the full script
// JXA Script - Sync SELECTED TaskPaper Items to Calendar
// Saves to Script Editor (Language: JavaScript)
// --- Configuration ---
const DEFAULT_CALENDAR_NAME = "TaskPaper"; // Default calendar if item doesn't specify @calendar
// ---
// Function to run inside TaskPaper's context to GET data for SELECTED items
function TaskPaperContext_GetSelectedData(editor, options) {
// Get only the items that are part of the current selection
const selectedItems = editor.selection.selectedItems;
const events = [];
let errorCount = 0;
if (!selectedItems || selectedItems.length === 0) {
// Return empty data if nothing is selected
return JSON.stringify({ data: [], errors: 0 });
}
selectedItems.forEach(item => {
try {
// Check if the item is valid and not deleted during processing
if (!item || !item.isInOutline) {
return; // Skip if item is somehow invalid
}
const startStr = item.getAttribute('data-start');
const durationStr = item.getAttribute('data-duration');
const isAllDay = item.hasAttribute('data-allday');
// Only proceed if we have a start and either duration or all-day flag
if (startStr && (durationStr || isAllDay)) {
const eventData = {
title: item.bodyContentString.trim(),
start: startStr,
duration: durationStr || null,
calendar: item.getAttribute('data-calendar') || null, // Allow specifying calendar
isAllDay: isAllDay
};
// Basic validation
if (eventData.title) {
events.push(eventData);
} else {
console.log(`Skipping item with ID ${item.id}: Empty title after trimming.`);
errorCount++; // Consider empty title an error/skip
}
}
// else: Item is selected but lacks necessary tags - silently skip
} catch (e) {
// Log error within TaskPaper if possible (might not show in Script Editor)
// console.log(`Error processing selected TaskPaper item ID ${item ? item.id : 'unknown'}: ${e}`);
errorCount++;
}
});
// Return JSON string containing data ONLY for selected, valid items
return JSON.stringify({ data: events, errors: errorCount });
}
// --- JXA Main Logic ---
ObjC.import('stdlib'); // For potential exit codes
// --- Helper Functions ---
function parseDuration(durationStr) {
if (!durationStr) return 0;
let totalMinutes = 0;
const hourMatch = durationStr.match(/(\d+(\.\d+)?)\s*h/);
const minMatch = durationStr.match(/(\d+)\s*m/);
if (hourMatch) totalMinutes += parseFloat(hourMatch[1]) * 60;
if (minMatch) totalMinutes += parseInt(minMatch[1], 10);
return Math.round(totalMinutes);
}
function parseDateTime(dateTimeStr) {
if (!dateTimeStr) return null;
dateTimeStr = dateTimeStr.trim();
const timeOnlyRegex = /^(\d{1,2}):(\d{2})$/;
const timeMatch = dateTimeStr.match(timeOnlyRegex);
let parsedDate = null;
if (timeMatch) {
const now = new Date(); // Base on today
now.setHours(parseInt(timeMatch[1], 10), parseInt(timeMatch[2], 10), 0, 0);
parsedDate = now;
} else {
try {
let d = new Date(dateTimeStr.replace(' ', 'T')); // Try ISO-like first
if (!isNaN(d.getTime())) {
parsedDate = d;
} else {
d = new Date(dateTimeStr); // Fallback
if (!isNaN(d.getTime())) parsedDate = d;
}
} catch (e) { /* ignore parse error */ }
}
// Ensure valid date object
return (parsedDate && !isNaN(parsedDate.getTime())) ? parsedDate : null;
}
// --- Main Execution ---
(() => { // Wrap main logic in a function scope
const TaskPaper = Application("TaskPaper");
if (!TaskPaper.running() || TaskPaper.documents.length === 0) {
console.log("Error: TaskPaper not running or no documents open.");
Application.currentApplication().includeStandardAdditions = true;
Application.currentApplication().displayAlert("Script Error", { message: "TaskPaper must be running with an open document." });
return "Error: TaskPaper not running or no documents open.";
}
const CalendarApp = Application("Calendar");
try { CalendarApp.name(); } catch (e) {
console.log("Error accessing Calendar App.");
Application.currentApplication().includeStandardAdditions = true;
Application.currentApplication().displayAlert("Script Error", { message: "Cannot access Calendar application. Check permissions (System Settings > Privacy & Security > Automation)." });
return "Error: Cannot access Calendar application.";
}
const doc = TaskPaper.documents[0];
let eventDataFromTP = [];
let errorsInTPContext = 0;
// 1. Get Data for SELECTED items from TaskPaper
try {
console.log("Getting data for SELECTED items from TaskPaper...");
// Call the specific function to get only selected items' data
const rawResultFromTP = doc.evaluate({ script: TaskPaperContext_GetSelectedData.toString() });
if (typeof rawResultFromTP === 'string' && rawResultFromTP.trim().startsWith('{')) {
const parsedResult = JSON.parse(rawResultFromTP);
eventDataFromTP = parsedResult.data || [];
errorsInTPContext = parsedResult.errors || 0;
console.log(`Received data for ${eventDataFromTP.length} selected items from TaskPaper. ${errorsInTPContext} errors occurred in TP context.`);
} else {
throw new Error("Invalid data received from TaskPaper.");
}
} catch (e) {
console.log(`Error getting data from TaskPaper: ${e}`);
return `Error: Failed to get data from TaskPaper. ${e.message}`;
}
if (eventDataFromTP.length === 0) {
console.log("No selected items with valid date tags found in TaskPaper.");
Application.currentApplication().includeStandardAdditions = true;
Application.currentApplication().displayAlert("Sync Info", { message: "No selected TaskPaper items with valid date tags (@start and @duration/@allday) were found to sync." });
return "Sync Complete: No suitable items selected.";
}
// 2. Find Target Calendars
console.log("Finding calendars...");
const targetCalendarNames = new Set([DEFAULT_CALENDAR_NAME]);
eventDataFromTP.forEach(item => { if (item.calendar) targetCalendarNames.add(item.calendar.trim()); });
const calendarMap = new Map(); // Map<calendarName, calendarObject>
let defaultCalendar = null;
try {
CalendarApp.calendars().forEach(cal => {
const calName = cal.name();
if (targetCalendarNames.has(calName)) {
calendarMap.set(calName, cal);
if (calName === DEFAULT_CALENDAR_NAME) defaultCalendar = cal;
}
});
// Determine effective default if primary wasn't found or specified items need others
if (!defaultCalendar) {
if (calendarMap.size > 0) defaultCalendar = calendarMap.values().next().value;
else if (CalendarApp.calendars.length > 0) {
defaultCalendar = CalendarApp.calendars[0]; // Absolute fallback
if (defaultCalendar) calendarMap.set(defaultCalendar.name(), defaultCalendar); // Add fallback to map if used
}
}
if (!defaultCalendar && eventDataFromTP.some(item => !item.calendar)) { // Check if default is actually needed
throw new Error("Default calendar needed but none found/accessible.");
}
console.log(`Using default calendar: '${defaultCalendar ? defaultCalendar.name() : 'None (all items must specify @calendar)'}'`);
calendarMap.forEach((cal, name) => console.log(` Found target calendar: '${name}'`));
} catch (e) {
console.log(`Error finding calendars: ${e}`);
return `Error: Failed to find calendars. ${e.message}`;
}
// 3. Create Events (No Checking for Duplicates)
console.log("Creating calendar events for selected items...");
let createdCount = 0;
let skippedCount = 0; // For items failing JXA-side parsing/validation
let errorCount = errorsInTPContext; // Start with errors from TP context
for (const item of eventDataFromTP) {
const targetCalName = item.calendar ? item.calendar.trim() : (defaultCalendar ? defaultCalendar.name() : null);
if (!targetCalName) {
console.log(`Skipping item '${item.title}': No target calendar specified and no default calendar available.`);
skippedCount++;
continue;
}
const targetCalendar = calendarMap.get(targetCalName);
if (!targetCalendar) {
console.log(`Skipping item '${item.title}': Target calendar '${targetCalName}' not found or accessible.`);
skippedCount++;
continue;
}
// Parse Dates/Times
const startDate = parseDateTime(item.start);
if (!startDate) {
console.log(`Skipping item '${item.title}': Invalid start date '${item.start}'.`);
skippedCount++;
continue;
}
let endDate = null;
if (!item.isAllDay) {
const durationMinutes = parseDuration(item.duration);
if (durationMinutes <= 0) {
console.log(`Skipping item '${item.title}': Invalid duration '${item.duration}'.`);
skippedCount++;
continue;
}
endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
} else {
startDate.setHours(0, 0, 0, 0); // Normalize all-day start
endDate = new Date(startDate); // End date for all-day often same as start
}
// Create the event (unconditionally)
console.log(`Creating event: '${item.title}' in calendar '${targetCalName}'...`);
try {
const newEventProperties = {
summary: item.title,
startDate: startDate,
endDate: endDate,
allday: item.isAllDay
// No description needed unless you want to add something specific
};
const newEvent = CalendarApp.Event(newEventProperties);
targetCalendar.events.push(newEvent);
createdCount++;
} catch (createError) {
console.log(` Error creating event for '${item.title}': ${createError}`);
errorCount++;
}
}
// 4. Final Summary
console.log("------------------------------");
const summary = `Sync Complete (Selected Items Only).
Selected Items with Date Tags: ${eventDataFromTP.length}
Calendar Events Created: ${createdCount}
Items Skipped (Invalid Data/Calendar): ${skippedCount}
Total Errors: ${errorCount}`;
console.log(summary);
console.log("------------------------------");
// Display summary dialog
Application.currentApplication().includeStandardAdditions = true;
Application.currentApplication().displayAlert("Sync Summary", { message: summary });
return summary; // Return the summary string
})(); // Immediately invoke the wrapping function