Skip to content

Instantly share code, notes, and snippets.

@WillsonSmith
Last active August 17, 2024 05:52
Show Gist options
  • Save WillsonSmith/f9f49f7f6751fd3d1bdc5265fe19fc01 to your computer and use it in GitHub Desktop.
Save WillsonSmith/f9f49f7f6751fd3d1bdc5265fe19fc01 to your computer and use it in GitHub Desktop.
iOS widget built with scriptable.app to display Letterboxd diary entries
// jsx/Widget/types.ts
var stringTypes = /* @__PURE__ */ new Set([
"string",
"number",
"bigint",
"symbol",
true
]);
var invalidChildTypes = /* @__PURE__ */ new Set([null, void 0, false, "function"]);
// jsx/Widget/processChildren.ts
function processChildren(widget, children) {
for (const child of children) {
if (invalidChildTypes.has(typeof child) || child === void 0) {
continue;
}
if (stringTypes.has(typeof child)) {
widget.addText(child);
}
if (Array.isArray(child)) {
processChildren(widget, child);
}
if (typeof child === "object") {
if ("type" in child) {
processChild(widget, child);
}
}
}
}
function processChild(widget, child) {
switch (child.type) {
case "text": {
textProps(widget.addText(child.text), child.props || {});
break;
}
case "stack": {
const stack = widget.addStack();
stackProps(stack, child.props);
processChildren(stack, child.children);
break;
}
case "image": {
const image = widget.addImage(child.props.data);
imageProps(image, child.props);
break;
}
case "spacer": {
widget.addSpacer(child.props?.size || null);
break;
}
}
}
function imageProps(image, props) {
if (props.size) {
image.imageSize = props.size;
}
if (props.borderColor) {
let borderColor = props.borderColor;
if (typeof borderColor === "string") {
borderColor = new Color(borderColor);
}
image.borderColor = borderColor;
}
if (props.borderWidth) {
image.borderWidth = props.borderWidth;
}
if (props.cornerRadius) {
image.cornerRadius = props.cornerRadius;
}
if (props.url) {
image.url = props.url;
}
}
function textProps(text, props) {
if (props.color) {
text.textColor = props.color;
}
if (props.font) {
text.font = props.font;
}
}
function stackProps(stack, props) {
if (props?.layout === "vertical") {
stack.layoutVertically();
} else {
stack.layoutHorizontally();
}
if (props?.align) {
switch (props.align) {
case "top":
stack.topAlignContent();
break;
case "center":
stack.centerAlignContent();
break;
case "bottom":
stack.bottomAlignContent();
break;
}
}
if (props?.url) {
stack.url = props.url;
}
}
// jsx/Widget/stringElement.ts
function stringElement(name, props, ...children) {
switch (name) {
case "widget": {
const widget = new ListWidget();
processChildren(widget, children);
return widget;
}
case "stack": {
return { type: name, props, children };
}
case "text": {
let text = "";
for (const child of children) {
if (stringTypes.has(typeof child)) {
text += String(child);
}
}
return { type: name, props, text };
}
case "image": {
return { type: name, props };
}
case "spacer": {
return { type: name, props };
}
}
}
// jsx/Widget/h.ts
function h(element, props, ...children) {
switch (typeof element) {
case "function": {
return element({ ...props }, children);
}
case "string": {
return stringElement(element, props, ...children);
}
}
return { type: element, props: props || {}, children };
}
// jsx/Fragment.ts
function Fragment(_, ...children) {
return children;
}
// lib/networking.ts
function fetchLetterboxdUser(user2) {
const request = new Request(`https://letterboxd.com/${user2}/rss/`);
return request.loadString();
}
async function fetchImage(url) {
const request = new Request(url);
const imageData = await request.load();
return Image.fromData(imageData);
}
// lib/parseFeed.ts
async function parseFeed(xml) {
const parser = new XMLParser(xml);
const result = {
title: "",
userLink: "",
items: []
};
let currentElementText = "";
let isBuildingItem = false;
let constructableItem = {};
parser.didStartElement = (name) => {
if (name === "item") {
constructableItem = {};
isBuildingItem = true;
}
};
parser.foundCharacters = (character) => {
currentElementText += character;
};
return new Promise((resolve, reject) => {
parser.didEndElement = (name) => {
currentElementText = currentElementText.trim();
if (name === "item") {
isBuildingItem = true;
if (result.items.length < 4) {
result.items.push(constructableItem);
} else {
resolve(result);
}
}
if (isBuildingItem) {
updateItemField(name, {
item: constructableItem,
content: currentElementText
});
} else {
updateMetadata(name, {
result,
content: currentElementText
});
}
currentElementText = "";
};
parser.parseErrorOccurred = () => {
reject(new Error("Parse error occurred"));
};
if (!parser.parse()) {
reject(new Error("Parser failed to start"));
}
});
}
function updateItemField(name, { item, content }) {
switch (name) {
case "link":
item.link = content;
break;
case "guid":
item.isReviewed = content.includes("review");
break;
case "description":
addImage(item, content);
break;
case "letterboxd:filmTitle":
item.title = content;
break;
case "letterboxd:memberRating":
item.rating = Number(content);
break;
case "letterboxd:watchedDate":
item.watchedDate = content;
break;
}
}
function addImage(item, content) {
const regex = /<img\s+[^>]*src="([^"]*)"/;
const match = content.match(regex);
if (match) {
item.image = match[1];
}
}
function updateMetadata(name, { result, content }) {
switch (name) {
case "title":
result.title = content;
break;
case "link":
result.userLink = content;
break;
}
}
// UI/Widget/ListItem/Rating.tsx
function Rating({ rating }) {
const starCount = Math.floor(rating);
const hasHalfStar = rating % 1 === 0.5;
let stars = Array.from({ length: starCount }, () => "\u2605").join("");
if (hasHalfStar) {
stars += `\xBD`;
}
return /* @__PURE__ */ h("text", { font: Font.body() }, stars);
}
// UI/Widget/ListItem/ListItem.tsx
function ListItem(props) {
if (props.size === "medium") {
return /* @__PURE__ */ h("image", { data: props.image, cornerRadius: 3, url: props.link });
}
const reviewed = props.reviewed ? /* @__PURE__ */ h(Fragment, null, /* @__PURE__ */ h("text", { font: Font.body() }, "\u270E"), /* @__PURE__ */ h("spacer", { size: 2 })) : void 0;
if (props.size === "large") {
const { link, image, title, rating, watchedDate } = props;
return /* @__PURE__ */ h("stack", { url: link }, /* @__PURE__ */ h("image", { data: image, cornerRadius: 3 }), /* @__PURE__ */ h("spacer", { size: 8 }), /* @__PURE__ */ h("stack", { layout: "vertical" }, /* @__PURE__ */ h("stack", null, reviewed, /* @__PURE__ */ h("text", { font: Font.headline() }, title), /* @__PURE__ */ h("spacer", null), /* @__PURE__ */ h("text", { font: Font.caption1() }, watchedDate)), /* @__PURE__ */ h("spacer", { size: 2 }), /* @__PURE__ */ h(Rating, { rating }), /* @__PURE__ */ h("spacer", null)), /* @__PURE__ */ h("spacer", null));
}
}
// UI/Widget/UIWidget.tsx
var user = args.widgetParameter || "willsonsmith";
var userFeedXML = await fetchLetterboxdUser(user);
var parsedFeed = await parseFeed(userFeedXML);
for (const item of parsedFeed.items) {
item.image = await fetchImage(item.image);
}
function UIWidget(props) {
const size = props.size;
const { title, items } = parsedFeed;
let contents;
if (size === "small") {
contents = /* @__PURE__ */ h("stack", null, /* @__PURE__ */ h("text", null, "No small widget defined"));
}
if (size === "medium") {
contents = /* @__PURE__ */ h("stack", { layout: "vertical" }, /* @__PURE__ */ h("text", { font: Font.headline() }, title), /* @__PURE__ */ h("spacer", { size: 4 }), /* @__PURE__ */ h("stack", null, items.map((item) => {
return /* @__PURE__ */ h(Fragment, null, /* @__PURE__ */ h(
ListItem,
{
size: "medium",
image: item.image,
link: item.link
}
), item !== items.at(-1) ? /* @__PURE__ */ h("spacer", { size: 8 }) : void 0);
})));
}
if (size === "large") {
const { title: title2, items: items2 } = parsedFeed;
contents = /* @__PURE__ */ h("stack", { layout: "vertical" }, /* @__PURE__ */ h("text", { font: Font.headline() }, title2), /* @__PURE__ */ h("spacer", { size: 8 }), items2.map((item) => {
const { title: title3, watchedDate, image, rating, link } = item;
return /* @__PURE__ */ h(Fragment, null, /* @__PURE__ */ h(
ListItem,
{
size: "large",
title: title3,
watchedDate,
rating,
image,
link,
reviewed: item.isReviewed
}
), item !== items2.at(-1) ? /* @__PURE__ */ h("spacer", { size: 8 }) : void 0);
}));
}
return /* @__PURE__ */ h("widget", null, contents);
}
// index.tsx
await main();
async function main() {
const widget = UIWidget({ size: config.widgetFamily || "large" });
if (config.runsInApp) {
widget.presentLarge();
}
Script.setWidget(widget);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment