Scriptable/iTermWidget.js
2024-09-18 14:27:22 +01:00

365 lines
12 KiB
JavaScript

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: deep-blue; icon-glyph: terminal;
/******************************************************************************
* Constants and Configurations
*****************************************************************************/
// Cache keys and default location
const CACHE_KEY_LAST_UPDATED = 'last_updated';
const CACHE_KEY_LOCATION = 'location';
const DEFAULT_LOCATION = { latitude: 0, longitude: 0 };
// Font name and size
const FONT_NAME = 'Menlo';
const FONT_SIZE = 10;
// Colors
const COLORS = {
bg0: '#29323c',
bg1: '#1c1c1c',
personalCalendar: '#5BD2F0',
workCalendar: '#9D90FF',
weather: '#FDFD97',
location: '#FEB144',
period: '#FF6663',
deviceStats: '#7AE7B9',
};
// TODO: PLEASE SET THESE VALUES
const NAME = 'TODO';
const WEATHER_API_KEY = 'TODO';
const WORK_CALENDAR_NAME = 'TODO';
const PERSONAL_CALENDAR_NAME = 'TODO';
const PERIOD_CALENDAR_NAME = 'TODO';
const PERIOD_EVENT_NAME = 'TODO';
/******************************************************************************
* Initial Setups
*****************************************************************************/
/**
* Convenience function to add days to a Date.
*
* @param {*} days The number of days to add
*/
Date.prototype.addDays = function(days) {
var date = new Date(this.valueOf());
date.setDate(date.getDate() + days);
return date;
};
// Import and setup Cache
const Cache = importModule('Cache');
const cache = new Cache('terminalWidget');
// Fetch data and create widget
const data = await fetchData();
const widget = createWidget(data);
Script.setWidget(widget);
Script.complete();
/******************************************************************************
* Main Functions (Widget and Data-Fetching)
*****************************************************************************/
/**
* Main widget function.
*
* @param {} data The data for the widget to display
*/
function createWidget(data) {
console.log(`Creating widget with data: ${JSON.stringify(data)}`);
const widget = new ListWidget();
const bgColor = new LinearGradient();
bgColor.colors = [new Color(COLORS.bg0), new Color(COLORS.bg1)];
bgColor.locations = [0.0, 1.0];
widget.backgroundGradient = bgColor;
widget.setPadding(10, 15, 15, 10);
const stack = widget.addStack();
stack.layoutVertically();
stack.spacing = 4;
stack.size = new Size(320, 0);
// Line 0 - Last Login
const timeFormatter = new DateFormatter();
timeFormatter.locale = "en";
timeFormatter.useNoDateStyle();
timeFormatter.useShortTimeStyle();
const lastLoginLine = stack.addText(`Last login: ${timeFormatter.string(new Date())} on ttys001`);
lastLoginLine.textColor = Color.white();
lastLoginLine.textOpacity = 0.7;
lastLoginLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 1 - Input
const inputLine = stack.addText(`iPhone:~ ${NAME}$ info`);
inputLine.textColor = Color.white();
inputLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 2 - Next Personal Calendar Event
const nextPersonalCalendarEventLine = stack.addText(`🗓 | ${getCalendarEventTitle(data.nextPersonalEvent, false)}`);
nextPersonalCalendarEventLine.textColor = new Color(COLORS.personalCalendar);
nextPersonalCalendarEventLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 3 - Next Work Calendar Event
const nextWorkCalendarEventLine = stack.addText(`🗓 | ${getCalendarEventTitle(data.nextWorkEvent, true)}`);
nextWorkCalendarEventLine.textColor = new Color(COLORS.workCalendar);
nextWorkCalendarEventLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 4 - Weather
const weatherLine = stack.addText(`${data.weather.icon} | ${data.weather.temperature}° (${data.weather.high}°-${data.weather.low}°), ${data.weather.description}, feels like ${data.weather.feelsLike}°`);
weatherLine.textColor = new Color(COLORS.weather);
weatherLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 5 - Location
const locationLine = stack.addText(`📍 | ${data.weather.location}`);
locationLine.textColor = new Color(COLORS.location);
locationLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 6 - Period
const periodLine = stack.addText(`🩸 | ${data.period}`);
periodLine.textColor = new Color(COLORS.period);
periodLine.font = new Font(FONT_NAME, FONT_SIZE);
// Line 7 - Various Device Stats
const deviceStatsLine = stack.addText(`📊 | ⚡︎ ${data.device.battery}%, ☀ ${data.device.brightness}%`);
deviceStatsLine.textColor = new Color(COLORS.deviceStats);
deviceStatsLine.font = new Font(FONT_NAME, FONT_SIZE);
return widget;
}
/**
* Fetch pieces of data for the widget.
*/
async function fetchData() {
// Get the weather data
const weather = await fetchWeather();
// Get next work/personal calendar events
const nextWorkEvent = await fetchNextCalendarEvent(WORK_CALENDAR_NAME);
const nextPersonalEvent = await fetchNextCalendarEvent(PERSONAL_CALENDAR_NAME);
// Get period data
const period = await fetchPeriodData();
// Get last data update time (and set)
const lastUpdated = await getLastUpdated();
cache.write(CACHE_KEY_LAST_UPDATED, new Date().getTime());
return {
weather,
nextWorkEvent,
nextPersonalEvent,
period,
device: {
battery: Math.round(Device.batteryLevel() * 100),
brightness: Math.round(Device.screenBrightness() * 100),
},
lastUpdated,
};
}
/******************************************************************************
* Helper Functions
*****************************************************************************/
//-------------------------------------
// Weather Helper Functions
//-------------------------------------
/**
* Fetch the weather data from Open Weather Map
*/
async function fetchWeather() {
let location = await cache.read(CACHE_KEY_LOCATION);
if (!location) {
try {
Location.setAccuracyToThreeKilometers();
location = await Location.current();
} catch(error) {
location = await cache.read(CACHE_KEY_LOCATION);
}
}
if (!location) {
location = DEFAULT_LOCATION;
}
const url = "https://api.openweathermap.org/data/2.5/onecall?lat=" + location.latitude + "&lon=" + location.longitude + "&exclude=minutely,hourly,alerts&units=imperial&lang=en&appid=" + WEATHER_API_KEY;
const address = await Location.reverseGeocode(location.latitude, location.longitude);
const data = await fetchJson(`weather_${address[0].locality}`, url);
const currentTime = new Date().getTime() / 1000;
const isNight = currentTime >= data.current.sunset || currentTime <= data.current.sunrise
return {
location: `${address[0].postalAddress.city}, ${address[0].postalAddress.state}`,
icon: getWeatherEmoji(data.current.weather[0].id, isNight),
description: data.current.weather[0].main,
temperature: Math.round(data.current.temp),
wind: Math.round(data.current.wind_speed),
high: Math.round(data.daily[0].temp.max),
low: Math.round(data.daily[0].temp.min),
feelsLike: Math.round(data.current.feels_like),
}
}
/**
* Given a weather code from Open Weather Map, determine the best emoji to show.
*
* @param {*} code Weather code from Open Weather Map
* @param {*} isNight Is `true` if it is after sunset and before sunrise
*/
function getWeatherEmoji(code, isNight) {
if (code >= 200 && code < 300 || code == 960 || code == 961) {
return "⛈"
} else if ((code >= 300 && code < 600) || code == 701) {
return "🌧"
} else if (code >= 600 && code < 700) {
return "❄️"
} else if (code == 711) {
return "🔥"
} else if (code == 800) {
return isNight ? "🌕" : "☀️"
} else if (code == 801) {
return isNight ? "☁️" : "🌤"
} else if (code == 802) {
return isNight ? "☁️" : "⛅️"
} else if (code == 803) {
return isNight ? "☁️" : "🌥"
} else if (code == 804) {
return "☁️"
} else if (code == 900 || code == 962 || code == 781) {
return "🌪"
} else if (code >= 700 && code < 800) {
return "🌫"
} else if (code == 903) {
return "🥶"
} else if (code == 904) {
return "🥵"
} else if (code == 905 || code == 957) {
return "💨"
} else if (code == 906 || code == 958 || code == 959) {
return "🧊"
} else {
return "❓"
}
}
//-------------------------------------
// Calendar Helper Functions
//-------------------------------------
/**
* Fetch the next calendar event from the given calendar
*
* @param {*} calendarName The calendar to get events from
*/
async function fetchNextCalendarEvent(calendarName) {
const calendar = await Calendar.forEventsByTitle(calendarName);
const events = await CalendarEvent.today([calendar]);
const tomorrow = await CalendarEvent.tomorrow([calendar]);
console.log(`Got ${events.length} events for ${calendarName}`);
console.log(`Got ${tomorrow.length} events for ${calendarName} tomorrow`);
const upcomingEvents = events.concat(tomorrow).filter(e => (new Date(e.endDate)).getTime() >= (new Date()).getTime());
return upcomingEvents ? upcomingEvents[0] : null;
}
/**
* Given a calendar event, return the display text with title and time.
*
* @param {*} calendarEvent The calendar event
* @param {*} isWorkEvent Is this a work event?
*/
function getCalendarEventTitle(calendarEvent, isWorkEvent) {
if (!calendarEvent) {
return `No upcoming ${isWorkEvent ? 'work ' : ''}events`;
}
const timeFormatter = new DateFormatter();
timeFormatter.locale = 'en';
timeFormatter.useNoDateStyle();
timeFormatter.useShortTimeStyle();
const eventTime = new Date(calendarEvent.startDate);
return `[${timeFormatter.string(eventTime)}] ${calendarEvent.title}`;
}
/**
* Fetch data from the Period calendar and determine number of days until period start/end.
*/
async function fetchPeriodData() {
const periodCalendar = await Calendar.forEventsByTitle(PERIOD_CALENDAR_NAME);
const events = await CalendarEvent.between(new Date(), new Date().addDays(30), [periodCalendar]);
console.log(`Got ${events.length} period events`);
const periodEvent = events.filter(e => e.title === PERIOD_EVENT_NAME)[0];
if (periodEvent) {
const current = new Date().getTime();
if (new Date(periodEvent.startDate).getTime() <= current && new Date(periodEvent.endDate).getTime() >= current) {
const timeUntilPeriodEndMs = new Date(periodEvent.endDate).getTime() - current;
return `${Math.round(timeUntilPeriodEndMs / 86400000)} days until period ends`; ;
} else {
const timeUntilPeriodStartMs = new Date(periodEvent.startDate).getTime() - current;
return `${Math.round(timeUntilPeriodStartMs / 86400000)} days until period starts`;
}
} else {
return 'Unknown period data';
}
}
//-------------------------------------
// Misc. Helper Functions
//-------------------------------------
/**
* Make a REST request and return the response
*
* @param {*} key Cache key
* @param {*} url URL to make the request to
* @param {*} headers Headers for the request
*/
async function fetchJson(key, url, headers) {
const cached = await cache.read(key, 5);
if (cached) {
return cached;
}
try {
console.log(`Fetching url: ${url}`);
const req = new Request(url);
req.headers = headers;
const resp = await req.loadJSON();
cache.write(key, resp);
return resp;
} catch (error) {
try {
return cache.read(key, 5);
} catch (error) {
console.log(`Couldn't fetch ${url}`);
}
}
}
/**
* Get the last updated timestamp from the Cache.
*/
async function getLastUpdated() {
let cachedLastUpdated = await cache.read(CACHE_KEY_LAST_UPDATED);
if (!cachedLastUpdated) {
cachedLastUpdated = new Date().getTime();
cache.write(CACHE_KEY_LAST_UPDATED, cachedLastUpdated);
}
return cachedLastUpdated;
}