Last active
August 17, 2024 05:52
-
-
Save WillsonSmith/f9f49f7f6751fd3d1bdc5265fe19fc01 to your computer and use it in GitHub Desktop.
iOS widget built with scriptable.app to display Letterboxd diary entries
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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