├── .github └── workflows │ └── upload-tapestry-plugins-to-release.yml ├── README.md ├── dev.simonbs.apple.apps.today ├── README.md ├── icon.png ├── plugin-config.json ├── plugin.js └── ui-config.json ├── dev.simonbs.apple.newsroom ├── README.md ├── icon.png ├── plugin-config.json ├── plugin.js └── ui-config.json ├── dev.simonbs.slack ├── README.md ├── icon.png ├── plugin-config.json ├── plugin.js └── ui-config.json └── dev.simonbs.youtube ├── README.md ├── icon.png ├── plugin-config.json ├── plugin.js └── ui-config.json /.github/workflows/upload-tapestry-plugins-to-release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Tapestry Plugins 2 | on: 3 | release: 4 | types: [created] 5 | permissions: 6 | contents: write 7 | jobs: 8 | upload-plugins: 9 | name: Upload Tapestry Plugins to Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repository 13 | uses: actions/checkout@v4 14 | - name: Create Archives 15 | run: | 16 | for dir in */; do 17 | if [[ -f "$dir/plugin-config.json" ]]; then 18 | dir_name="${dir%/}" 19 | output_file="${dir_name}.tapestry" 20 | (cd "$dir" && zip -qr "../$output_file" .) 21 | fi 22 | done 23 | - name: Upload Release Assets 24 | uses: softprops/action-gh-release@v2 25 | if: startsWith(github.ref, 'refs/tags/') 26 | with: 27 | files: '*.tapestry' 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tapestry Plugins 2 | 3 | Plugins for the [Tapestry](https://usetapestry.com) by the [Iconfactory](https://iconfactory.com). 4 | 5 | |Name|Description| 6 | |-|-| 7 | |[dev.simonbs.apple.apps.today](https://github.com/simonbs/tapestry-plugins/tree/main/dev.simonbs.apple.apps.today)|Shows stories from the App Store's Today tab.| 8 | |[dev.simonbs.apple.newsroom](https://github.com/simonbs/tapestry-plugins/tree/main/dev.simonbs.apple.newsroom)|Adds news from [apple.com/newsroom](https://www.apple.com/newsroom/).| 9 | |[dev.simonbs.slack](https://github.com/simonbs/tapestry-plugins/tree/main/dev.simonbs.slack)|Adds the most recent posts from a specific Slack channel.| 10 | |[dev.simonbs.youtube](https://github.com/simonbs/tapestry-plugins/tree/main/dev.simonbs.youtube)|Adds recent videos published by YouTube channels you are subscribed to.| 11 | 12 | ## 🚀 Installation 13 | 14 | To install a plugin in the Tapestry app: 15 | 16 | 1. [Download the latest release](https://github.com/simonbs/tapestry-plugins/releases/latest) of the desired plugin. 17 | 2. Save the plugin file in the Files app on your iPhone. 18 | 3. Open Tapestry and add the plugin as a connector through the in-app settings. 19 | 20 | For configuration details, refer to the specific plugin's README. 21 | 22 | ## 📖 Documentation 23 | 24 | The official documentation for the JavaScript-based API used to build plugins for Tapestry can be found at [TheIconfactory/Tapestry](https://github.com/theiconfactory/tapestry). 25 | -------------------------------------------------------------------------------- /dev.simonbs.apple.apps.today/README.md: -------------------------------------------------------------------------------- 1 | **Displays stories from the App Store's Today tab.** 2 | 3 | Note that the plugin shows generic daily stories instead of personalized ones. It adds a touch of app inspiration to your feed. 4 | -------------------------------------------------------------------------------- /dev.simonbs.apple.apps.today/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonbs/tapestry-plugins/bd54c9eddc539625d66e03943511dd969069dcb9/dev.simonbs.apple.apps.today/icon.png -------------------------------------------------------------------------------- /dev.simonbs.apple.apps.today/plugin-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dev.simonbs.apple.apps.today", 3 | "display_name": "App Store Today", 4 | "default_color": "blue", 5 | "icon": "https://raw.githubusercontent.com/simonbs/tapestry-plugins/refs/heads/main/dev.simonbs.apple.apps.today/icon.png", 6 | "site": "https://apple.com/app-store/", 7 | "site_placeholder": "https://apple.com/app-store/", 8 | "provides_attachments": true, 9 | "needs_verification": true, 10 | "check_interval": 3600 11 | } 12 | -------------------------------------------------------------------------------- /dev.simonbs.apple.apps.today/plugin.js: -------------------------------------------------------------------------------- 1 | function verify() { 2 | getBearer().then(bearer => { 3 | return validateStorefrontAndLanguage(bearer, varStorefront, varLanguage) 4 | }).then(result => { 5 | if (result.success) { 6 | processVerification() 7 | } else { 8 | processError(result.error) 9 | } 10 | }) 11 | .catch(processError) 12 | } 13 | 14 | function load() { 15 | loadAsync() 16 | .then(processResults) 17 | .catch(processError) 18 | } 19 | 20 | async function loadAsync() { 21 | const bearer = await getBearer() 22 | if (!bearer) { 23 | return [] 24 | } 25 | const { success, error, storefront, language } = await validateStorefrontAndLanguage( 26 | bearer, varStorefront, varLanguage 27 | ) 28 | if (!success) { 29 | throw new Error(error) 30 | } 31 | let i = 0 32 | const stories = await getStoriesToday(bearer, storefront, language) 33 | return stories 34 | .filter(story => story.date) 35 | .map(e => { 36 | // Stabilize order. 37 | const date = new Date(e.date.getTime() + i * 1000) 38 | i += 1 39 | return {...e, date } 40 | }) 41 | .map(story => { 42 | const item = Item.createWithUriDate(story.url, story.date) 43 | item.title = story.title 44 | if (story.subtitle) { 45 | item.body = story.subtitle 46 | } 47 | const creator = Identity.createWithName("App Store Editorial") 48 | creator.uri = "https://apple.com/app-store/" 49 | creator.avatar = "https://apple.com/v/app-store/b/images/overview/icon_appstore__ev0z770zyxoy_large_2x.png" 50 | item.creator = creator 51 | let attachment = null 52 | if (story.video && story.video.endsWith(".m3u8")) { 53 | attachment = MediaAttachment.createWithUrl(story.video) 54 | attachment.mimeType = "video" 55 | } else if (story.image) { 56 | attachment = MediaAttachment.createWithUrl(story.image) 57 | attachment.mimeType = "image" 58 | } 59 | if (attachment) { 60 | attachment.aspectSize = {width: 353, height: 435} 61 | attachment.focalPoint = {x: 0, y: 0} 62 | item.attachments = [attachment] 63 | } 64 | return item 65 | }) 66 | } 67 | 68 | async function getBearer() { 69 | const html = await sendRequest(`https://apple.com/app-store/`) 70 | const regex = //g 71 | const matches = regex.exec(html) 72 | if (!matches || matches.length < 2) { 73 | return null 74 | } 75 | return matches[1] 76 | } 77 | 78 | async function validateStorefrontAndLanguage(bearer, storefront, language) { 79 | const storefronts = await getStorefronts(bearer) 80 | const storefrontMatch = storefronts.find(e => { 81 | return e.attributes.name.toLowerCase() == storefront.toLowerCase() 82 | }) 83 | if (!storefrontMatch) { 84 | const error = `Storefront not supported: ${storefront}` 85 | return { success: false, error } 86 | } 87 | const defaultLanguage = storefrontMatch.attributes.defaultLanguageTag 88 | if (!language || language.length == 0) { 89 | return { 90 | success: true, 91 | storefront: storefrontMatch.id, 92 | storefrontDisplayName: storefrontMatch.attributes.name, 93 | language: defaultLanguage 94 | } 95 | } 96 | const supportedLanguages = storefrontMatch.attributes.supportedLanguageTags 97 | const languageMatch = supportedLanguages.find(e => { 98 | return e.toLowerCase() == language.toLowerCase() 99 | }) 100 | if (!languageMatch) { 101 | const error = `${language} not supported for ${storefrontMatch.attributes.name} storefront. ` 102 | + `Supported languages: ${supportedLanguages.join(", ")}` 103 | return { success: false, error } 104 | } 105 | return { 106 | success: true, 107 | storefrontDisplayName: storefrontMatch.attributes.name, 108 | storefront: storefrontMatch.id, 109 | language: languageMatch 110 | } 111 | } 112 | 113 | async function getStoriesToday(bearer, storefront, language) { 114 | const url = `https://amp-api.apps.apple.com/v1/editorial/${storefront}/today` 115 | + `?l=${language}` 116 | + "&platform=iphone" 117 | + "&additionalPlatforms=ipad" 118 | + "&sparseLimit=42" 119 | const text = await sendRequest(url, "GET", null, { 120 | "Origin": "https://apple.com", 121 | "Authorization": `Bearer ${bearer}` 122 | }) 123 | const json = JSON.parse(text) 124 | if (json.errors && json.errors.length > 0) { 125 | const error = json.errors[0] 126 | if (error.title && error.detail) { 127 | throw new Error(`${error.title}: ${error.detail}`) 128 | } else if (error.title) { 129 | throw new Error(error.title) 130 | } else { 131 | throw new Error("Unknown error occurred") 132 | } 133 | } 134 | return json.results.data.flatMap(day => { 135 | if (!day.contents) { 136 | return [] 137 | } 138 | return day.contents.map(item => { 139 | const image = getImage(item) 140 | const video = getVideo(item) 141 | let mappedItem = { 142 | id: item.id, 143 | href: `https://amp-api.apps.apple.com${item.href}`, 144 | url: item.attributes.url, 145 | date: new Date(day.date), 146 | title: item.attributes.editorialNotes.name, 147 | kind: item.attributes.kind, 148 | label: item.attributes.label 149 | } 150 | if (item.attributes.editorialNotes.short) { 151 | mappedItem.subtitle = item.attributes.editorialNotes.short 152 | } 153 | if (image) { 154 | mappedItem.image = image 155 | } 156 | if (video) { 157 | mappedItem.video = video 158 | } 159 | return mappedItem 160 | }) 161 | }) 162 | } 163 | 164 | async function getStorefronts(bearer) { 165 | const url = "https://amp-api.apps.apple.com/v1/storefronts" 166 | + "?platform=iphone" 167 | + "&additionalPlatforms=ipad,appletv,mac,watch" 168 | const text = await sendRequest(url, "GET", null, { 169 | "Origin": "https://apple.com", 170 | "Authorization": `Bearer ${bearer}` 171 | }) 172 | const obj = JSON.parse(text) 173 | return obj.data 174 | } 175 | 176 | function getImage(item) { 177 | if (!item.attributes || !item.attributes.editorialArtwork) { 178 | return null 179 | } 180 | const editorialArtwork = item.attributes.editorialArtwork 181 | let url = null 182 | if (editorialArtwork.generalCard) { 183 | url = editorialArtwork.generalCard.url 184 | } else if (editorialArtwork.dayCard) { 185 | url = editorialArtwork.dayCard.url 186 | } else if (editorialArtwork.storyCenteredStatic16x9) { 187 | url = editorialArtwork.storyCenteredStatic16x9.url 188 | } 189 | if (!url) { 190 | return null 191 | } 192 | return url 193 | .replace("{w}", "960") 194 | .replace("{h}", "1266") 195 | .replace("{c}", "fn") 196 | .replace("{f}", "png") 197 | } 198 | 199 | function getVideo(item) { 200 | if (!item.attributes || !item.attributes.editorialVideo) { 201 | return null 202 | } 203 | const editorialVideo = item.attributes.editorialVideo 204 | if (!editorialVideo.storeFrontVideo) { 205 | return null 206 | } 207 | return editorialVideo.storeFrontVideo.video 208 | } 209 | -------------------------------------------------------------------------------- /dev.simonbs.apple.apps.today/ui-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": [{ 3 | "name": "varStorefront", 4 | "type": "choices", 5 | "prompt": "Storefront", 6 | "value": "United States", 7 | "choices": "Afghanistan, Albania, Algeria, Angola, Anguilla, Antigua and Barbuda, Argentina, Armenia, Australia, Austria, Azerbaijan, Bahamas, Bahrain, Barbados, Belarus, Belgium, Belize, Benin, Bermuda, Bhutan, Bolivia, Bosnia and Herzegovina, Botswana, Brazil, British Virgin Islands, Brunei Darussalam, Bulgaria, Burkina Faso, Cambodia, Cameroon, Canada, Cape Verde, Cayman Islands, Chad, Chile, China mainland, Colombia, Costa Rica, Croatia, Cyprus, Czech Republic, Côte d'Ivoire, Democratic Republic of the Congo, Denmark, Dominica, Dominican Republic, Ecuador, Egypt, El Salvador, Estonia, Eswatini, Fiji, Finland, France, Gabon, Gambia, Georgia, Germany, Ghana, Greece, Grenada, Guatemala, Guinea-Bissau, Guyana, Honduras, Hong Kong, Hungary, Iceland, India, Indonesia, Iraq, Ireland, Israel, Italy, Jamaica, Japan, Jordan, Kazakhstan, Kenya, Korea, Republic of, Kosovo, Kuwait, Kyrgyzstan, Lao People's Democratic Republic, Latvia, Lebanon, Liberia, Libya, Lithuania, Luxembourg, Macao, Madagascar, Malawi, Malaysia, Maldives, Mali, Malta, Mauritania, Mauritius, Mexico, Micronesia, Federated States of, Moldova, Mongolia, Montenegro, Montserrat, Morocco, Mozambique, Myanmar, Namibia, Nauru, Nepal, Netherlands, New Zealand, Nicaragua, Niger, Nigeria, North Macedonia, Norway, Oman, Pakistan, Palau, Panama, Papua New Guinea, Paraguay, Peru, Philippines, Poland, Portugal, Qatar, Republic of the Congo, Romania, Russia, Rwanda, Saudi Arabia, Senegal, Serbia, Seychelles, Sierra Leone, Singapore, Slovakia, Slovenia, Solomon Islands, South Africa, Spain, Sri Lanka, St. Kitts and Nevis, St. Lucia, St. Vincent and The Grenadines, Suriname, Sweden, Switzerland, São Tomé and Príncipe, Taiwan, Tajikistan, Tanzania, Thailand, Tonga, Trinidad and Tobago, Tunisia, Turkmenistan, Turks and Caicos, Türkiye, UAE, Uganda, Ukraine, United Kingdom, United States, Uruguay, Uzbekistan, Vanuatu, Venezuela, Vietnam, Yemen, Zambia, Zimbabwe, " 8 | }, { 9 | "name": "varLanguage", 10 | "type": "choices", 11 | "prompt": "Language", 12 | "value": "en-US", 13 | "choices": "ar, ca, cs, da, de-CH, de-DE, el, en-AU, en-CA, en-GB, en-US, es-ES, es-MX, fi, fr-CA, fr-FR, he, hi, hr, hu, id, it, ja, ko, ms, nb, nl, pl, pt-BR, pt-PT, ro, ru, sk, sv, th, tr, uk, vi, zh-Hans-CN, zh-Hant-HK, zh-Hant-TW" 14 | }] 15 | } 16 | -------------------------------------------------------------------------------- /dev.simonbs.apple.newsroom/README.md: -------------------------------------------------------------------------------- 1 | Shows news from [apple.com/newsroom](https://www.apple.com/newsroom/). 2 | -------------------------------------------------------------------------------- /dev.simonbs.apple.newsroom/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonbs/tapestry-plugins/bd54c9eddc539625d66e03943511dd969069dcb9/dev.simonbs.apple.newsroom/icon.png -------------------------------------------------------------------------------- /dev.simonbs.apple.newsroom/plugin-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dev.simonbs.apple.newsroom", 3 | "display_name": "Apple Newsroom", 4 | "default_color": "gray", 5 | "icon": "https://github.com/simonbs/tapestry-plugins/blob/main/dev.simonbs.apple.newsroom/icon.png?raw=true", 6 | "site": "https://apple.com/newsroom/rss-feed.rss", 7 | "provides_attachments": true, 8 | "check_interval": 300 9 | } 10 | -------------------------------------------------------------------------------- /dev.simonbs.apple.newsroom/plugin.js: -------------------------------------------------------------------------------- 1 | function identify() { 2 | setIdentifier(null) 3 | } 4 | 5 | function load() { 6 | loadAsync() 7 | .then(processResults) 8 | .catch(processError) 9 | } 10 | 11 | async function loadAsync() { 12 | const text = await sendRequest("https://apple.com/newsroom/rss-feed.rss") 13 | const obj = xmlParse(text) 14 | return obj.feed.entry.map(entry => { 15 | const date = new Date(entry.updated) 16 | const creator = Identity.createWithName(entry.author.name) 17 | creator.uri = obj.feed.id 18 | creator.avatar = "https://www.apple.com/newsroom/images/default/apple-logo-og.jpg" 19 | const post = Item.createWithUriDate(entry.id, date) 20 | post.title = entry.title 21 | post.body = entry.content 22 | post.creator = creator 23 | const imageLink = findImageLink(entry) 24 | if (imageLink) { 25 | const attachment = MediaAttachment.createWithUrl(imageLink.href) 26 | attachment.text = imageLink.title 27 | post.attachments = [attachment] 28 | } 29 | return post 30 | }) 31 | } 32 | 33 | function findImageLink(entry) { 34 | if (!entry["link$attrs"]) { 35 | return null 36 | } 37 | return entry["link$attrs"].find(e => e.type === "image/jpeg") 38 | } 39 | -------------------------------------------------------------------------------- /dev.simonbs.apple.newsroom/ui-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": [] 3 | } 4 | -------------------------------------------------------------------------------- /dev.simonbs.slack/README.md: -------------------------------------------------------------------------------- 1 | **Shows the most recent posts from a specific Slack channel.** 2 | 3 | **Important:** Images in messages do not currently load because authentication is required, which Tapestry does not support at this time. 4 | 5 | This plugin requires authentication with your Slack account using an OAuth flow. To perform this flow, you must have a Slack app installed in your workspace. Follow the steps below to create a Slack app and obtain the client ID and client secret needed for authentication: 6 | 7 | 1. Open [api.slack.com/apps](https://api.slack.com/apps). 8 | 2. Click "Create New App" and select "From scratch". 9 | 3. Enter a name for your app, such as "Tapestry". 10 | 4. Select the workspace where you want to install the app and click "Create App". 11 | 5. Go to "OAuth & Permissions" in the sidebar. 12 | 6. Click "Add New Redirect URL" and set the URL to [https://iconfactory.com/tapestry-oauth](https://iconfactory.com/tapestry-oauth). 13 | 7. Save the changes. 14 | 8. Scroll down to "Scopes". 15 | 9. Under User Token Scopes, add the following scopes: `channels:history`, `channels:read`, and `users.profile:read`. 16 | 10. Go to "Basic Information" in the sidebar. 17 | 11. Expand the "Install your app" section. 18 | 12. Click "Install to Workspace". 19 | 13. Copy the client ID and client secret from "Basic Information". 20 | 14. Enter the client ID and client secret when configuring the connector in Tapestry. 21 | 22 | When configuring the connector, specify a comma-separated list of channels to fetch posts from (e.g., lounge, random, photos). 23 | -------------------------------------------------------------------------------- /dev.simonbs.slack/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonbs/tapestry-plugins/bd54c9eddc539625d66e03943511dd969069dcb9/dev.simonbs.slack/icon.png -------------------------------------------------------------------------------- /dev.simonbs.slack/plugin-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dev.simonbs.slack", 3 | "display_name": "Slack", 4 | "default_color": "orange", 5 | "icon": "https://github.com/simonbs/tapestry-plugins/blob/main/dev.simonbs.slack/icon.png?raw=true", 6 | "site": "https://slack.com", 7 | "site_placeholder": "https://slack.com", 8 | "item_style": "post", 9 | "oauth_authorize": "https://slack.com/oauth/authorize", 10 | "oauth_token": "https://slack.com/api/oauth.access", 11 | "oauth_type": "code", 12 | "oauth_code_key": "code", 13 | "oauth_scope": "channels:history+channels:read+users.profile:read+files:read+team:read", 14 | "oauth_grant_type": "authorization_code", 15 | "oauth_http_redirect": true, 16 | "needs_api_keys": true, 17 | "provides_attachments": true, 18 | "needs_verification": true, 19 | "verify_variables": true, 20 | "check_interval": 300 21 | } 22 | -------------------------------------------------------------------------------- /dev.simonbs.slack/plugin.js: -------------------------------------------------------------------------------- 1 | function verify() { 2 | getTeamInfo().then(team => { 3 | processVerification({ 4 | displayName: team.name, 5 | icon: team.icon.image_230 6 | }) 7 | }) 8 | .catch(processError) 9 | } 10 | 11 | function load() { 12 | loadAsync() 13 | .then(processResults) 14 | .catch(processError) 15 | } 16 | 17 | async function loadAsync() { 18 | const channelIds = await getConfiguredChannelIds() 19 | const messages = await getMessageInChannels(channelIds) 20 | return await Promise.all( 21 | messages 22 | .filter(e => e.type === "message" && e.subtype === undefined) 23 | .map(async message => { 24 | const date = new Date(parseInt(message.ts) * 1000) 25 | const body = await stringFromBlocks(message.blocks) 26 | const post = Item.createWithUriDate(message.permalink, date) 27 | post.body = body 28 | const host = message.permalink.split("/")[2] 29 | const author = Identity.createWithName(message.user.display_name) 30 | author.uri = `https://${host}/team/${message.user_id}` 31 | author.avatar = message.user.image_192 32 | post.author = author 33 | if (message.files) { 34 | post.attachments = message.files.slice(0, 4).map(file => { 35 | const attachment = MediaAttachment.createWithUrl(file.url_private_download) 36 | attachment.text = file.title 37 | return attachment 38 | }) 39 | } 40 | return post 41 | }) 42 | ) 43 | } 44 | 45 | /** 46 | * Fetch data 47 | */ 48 | 49 | let cachedTeam = null 50 | let cachedUserProfiles = {} 51 | let cachedChannels = {} 52 | 53 | async function getConfiguredChannelIds() { 54 | const channelNames = channels 55 | .split(/,| /) 56 | .map(e => e.replace(/^#/, "").trim().toLowerCase()) 57 | const theChannels = await getChannels() 58 | return theChannels 59 | .filter(e => channelNames.includes(e.name)) 60 | .map(e => e.id) 61 | } 62 | 63 | async function getMessageInChannels(channelIds) { 64 | return (await Promise.all(channelIds.map(getMessageInChannel))) 65 | .flat() 66 | .sort((a, b) => a.ts < b.ts) 67 | } 68 | 69 | async function getMessageInChannel(channelId) { 70 | const text = await sendRequest( 71 | `${site}/api/conversations.history?channel=${channelId}&limit=20&include_all_metadata=1` 72 | ) 73 | const obj = JSON.parse(text) 74 | return await Promise.all(obj.messages.map(message => { 75 | return Promise.all([ 76 | getPermalink(channelId, message.ts), 77 | getUserProfile(message.user), 78 | ]).then(values => { 79 | return { 80 | ...message, 81 | permalink: values[0], 82 | user_id: message.user, 83 | user: values[1] 84 | } 85 | }) 86 | })) 87 | } 88 | 89 | async function getPermalink(channelId, ts) { 90 | const url = `${site}/api/chat.getPermalink?channel=${channelId}&message_ts=${ts}` 91 | const text = await sendRequest(url) 92 | const obj = JSON.parse(text) 93 | return obj.permalink 94 | } 95 | 96 | async function getUserProfile(userId) { 97 | if (cachedUserProfiles[userId]) { 98 | return cachedUserProfiles[userId] 99 | } 100 | const url = `${site}/api/users.profile.get?user=${userId}` 101 | const text = await sendRequest(url) 102 | const obj = JSON.parse(text) 103 | cachedUserProfiles[userId] = obj.profile 104 | return obj.profile 105 | } 106 | 107 | async function getTeamInfo() { 108 | if (cachedTeam) { 109 | return cachedTeam 110 | } 111 | const url = `${site}/api/team.info` 112 | const text = await sendRequest(url) 113 | const obj = JSON.parse(text) 114 | cachedTeam = obj.team 115 | return obj.team 116 | } 117 | 118 | async function getChannels() { 119 | const text = await sendRequest(`${site}/api/conversations.list`) 120 | const obj = JSON.parse(text) 121 | if (obj.channels) { 122 | for (const channel of obj.channels) { 123 | cachedChannels[channel.id] = channel 124 | } 125 | } 126 | return obj.channels 127 | } 128 | 129 | async function getChannel(channelId) { 130 | if (cachedChannels[channelId]) { 131 | return cachedChannels[channelId] 132 | } 133 | const url = `${site}/api/conversations.info` 134 | const text = await sendRequest(url) 135 | const obj = JSON.parse(text) 136 | cachedChannels[channelId] = obj.channel 137 | return obj.channel 138 | } 139 | 140 | /** 141 | * Construct messages 142 | */ 143 | 144 | async function stringFromBlocks(blocks) { 145 | if (!blocks) { 146 | return "" 147 | } 148 | const texts = await Promise.all( 149 | blocks.map(async block => { 150 | if (block.type == "rich_text") { 151 | return await stringFromRichTextBlock(block) 152 | } else { 153 | console.log(`Ignored block type: ${block.type}"`) 154 | return "" 155 | } 156 | }) 157 | ) 158 | return texts.join("").replace(/\n/g, "
") 159 | } 160 | 161 | async function stringFromRichTextBlock(block) { 162 | const texts = await Promise.all( 163 | block.elements.map(async element => { 164 | if (element.type == "rich_text_section") { 165 | return `

${await stringFromTextElements(element.elements)}

` 166 | } else if (element.type == "rich_text_list") { 167 | const items = await Promise.all( 168 | element.elements.map(section => { 169 | return stringFromTextElements(section.elements) 170 | }) 171 | ) 172 | if (element.style == "ordered") { 173 | return `

${items.map((e, idx) => `${idx + 1}. ${e}`).join("\n")}

` 174 | } else { 175 | return `

${items.map(e => `• ${e}`).join("\n")}

` 176 | } 177 | } else if (element.type == "rich_text_preformatted") { 178 | const text = await stringFromTextElements(element.elements) 179 | return `

${text}

` 180 | } else if (element.type == "rich_text_quote") { 181 | return `

${await stringFromTextElements(element.elements)}

` 182 | } else { 183 | console.log(`Ignored element type: ${element.type}`) 184 | return "" 185 | } 186 | }) 187 | ) 188 | return texts.join("") 189 | } 190 | 191 | async function stringFromTextElements(elements) { 192 | const texts = await Promise.all(elements.map(stringFromTextElement)) 193 | return texts.join("") 194 | } 195 | 196 | async function stringFromTextElement(element) { 197 | if (element.type == "text") { 198 | if (element.style && element.style.bold) { 199 | return `${element.text}` 200 | } else if (element.style && element.style.italic) { 201 | return `${element.text}` 202 | } else if (element.style && element.style.code) { 203 | return `${element.text}` 204 | } else { 205 | return element.text 206 | } 207 | } else if (element.type == "emoji") { 208 | if (element.unicode) { 209 | return String.fromCodePoint(parseInt(element.unicode, 16)) 210 | } else { 211 | return "" 212 | } 213 | } else if (element.type == "link") { 214 | return `${element.url}` 215 | } else if (element.type == "user") { 216 | const team = await getTeamInfo() 217 | const user = await getUserProfile(element.user_id) 218 | const userName = [user.display_name, user.real_name, element.user_id] 219 | .filter(e => e != null && e.length > 0)[0] 220 | return `@${userName}` 221 | } else if (element.type == "channel") { 222 | const team = await getTeamInfo() 223 | const channel = await getChannel(element.channel_id) 224 | return `@${channel.name}` 225 | } else { 226 | console.log(`Ignored element type: ${element.type}`) 227 | return "" 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /dev.simonbs.slack/ui-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": [{ 3 | "name": "channels", 4 | "type": "text", 5 | "prompt": "Channels", 6 | "placeholder": "lounge, random, photos" 7 | }] 8 | } 9 | -------------------------------------------------------------------------------- /dev.simonbs.youtube/README.md: -------------------------------------------------------------------------------- 1 | **Shows recent videos published by YouTube channels you are subscribed to.** 2 | 3 | This plugin requires authentication with your YouTube account using an OAuth flow. 4 | To perform an OAuth flow, you must set up a Google Cloud project. Follow these steps to create a Google Cloud app and obtain the client ID and client secret needed for authentication: 5 | 6 | 1. Open [console.cloud.google.com/apis](https://console.cloud.google.com/apis) and create a new project. 7 | 2. Go to "OAuth consent screen" in the sidebar and configure the consent screen for your app. 8 | 3. Select "External" as the user type. 9 | 4. When prompted to add test users, enter your own email. 10 | 5. Go to [console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) and enable the YouTube API for your project. 11 | 6. Select "YouTube Data API v3" and enable it. 12 | 7. Go to [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) and create an OAuth client. 13 | 8. Click "Create credentials" and select "OAuth client ID". 14 | 9. Select "Web application" as the application type. 15 | 10. Add [https://iconfactory.com/tapestry-oauth](https://iconfactory.com/tapestry-oauth) as a redirect URI. 16 | 11. Copy the client ID and client secret, then add them to the connector in Tapestry. 17 | -------------------------------------------------------------------------------- /dev.simonbs.youtube/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonbs/tapestry-plugins/bd54c9eddc539625d66e03943511dd969069dcb9/dev.simonbs.youtube/icon.png -------------------------------------------------------------------------------- /dev.simonbs.youtube/plugin-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dev.simonbs.youtube", 3 | "display_name": "YouTube", 4 | "icon": "https://github.com/simonbs/tapestry-plugins/blob/main/dev.simonbs.youtube/icon.png?raw=true", 5 | "default_color": "coral", 6 | "site": "https://www.googleapis.com", 7 | "site_placeholder": "https://www.googleapis.com", 8 | "item_style": "post", 9 | "oauth_authorize": "https://accounts.google.com/o/oauth2/v2/auth", 10 | "oauth_token": "https://oauth2.googleapis.com/token", 11 | "oauth_type": "code", 12 | "oauth_code_key": "code", 13 | "oauth_scope": "https://www.googleapis.com/auth/youtube&prompt=consent&access_type=offline", 14 | "oauth_grant_type": "authorization_code", 15 | "oauth_authorize_omit_secret": true, 16 | "oauth_http_redirect": true, 17 | "needs_api_keys": true, 18 | "provides_attachments": true, 19 | "needs_verification": true, 20 | "verify_variables": true, 21 | "check_interval": 1800 22 | } 23 | -------------------------------------------------------------------------------- /dev.simonbs.youtube/plugin.js: -------------------------------------------------------------------------------- 1 | function verify() { 2 | sendRequest( 3 | "https://www.googleapis.com/youtube/v3/channels" 4 | + "?part=snippet" 5 | + "&mine=true" 6 | ).then(_text => { 7 | processVerification({ displayName: "YouTube" }) 8 | }) 9 | .catch(processError) 10 | } 11 | 12 | function load() { 13 | loadAsync() 14 | .then(processResults) 15 | .catch(processError) 16 | } 17 | 18 | async function loadAsync() { 19 | const channels = await getSubscribedChannels() 20 | const videos = await getVideosInChannels(channels) 21 | return videos.slice(0, varVideoCount).map(video => { 22 | const author = Identity.createWithName(video.channel.title) 23 | author.uri = video.channel.permalink 24 | author.avatar = video.channel.thumbnail 25 | const attachment = MediaAttachment.createWithUrl(video.image) 26 | attachment.thumbnail = video.thumbnail 27 | const post = Item.createWithUriDate(video.permalink, video.publishedAt) 28 | post.title = video.title 29 | if (video.description && video.description.length > 0) { 30 | post.body = video.description.replace(/\n/g, "
") 31 | } 32 | post.author = author 33 | post.attachments = [attachment] 34 | return post 35 | }) 36 | } 37 | 38 | async function getSubscribedChannels(nextPageToken) { 39 | let url = "https://www.googleapis.com/youtube/v3/subscriptions" 40 | + "?part=snippet" 41 | + "&mine=true" 42 | + "&maxResults=50" 43 | if (nextPageToken) { 44 | url += `&pageToken=${nextPageToken}` 45 | } 46 | const text = await sendRequest(url) 47 | const obj = JSON.parse(text) 48 | const items = obj.items.filter(item => { 49 | return item.snippet.resourceId.kind === "youtube#channel" 50 | }).map(item => { 51 | return { 52 | id: item.snippet.resourceId.channelId, 53 | title: item.snippet.title, 54 | thumbnail: getHighResolutionThumbnail(item.snippet.thumbnails), 55 | permalink: `https://www.youtube.com/channel/${item.snippet.resourceId.channelId}` 56 | } 57 | }) 58 | if (obj.nextPageToken) { 59 | return items.concat(await getSubscribedChannels(obj.nextPageToken)) 60 | } else { 61 | return items 62 | } 63 | } 64 | 65 | async function getVideosInChannels(channels) { 66 | const unflatted = await Promise.all( 67 | channels.map(async channel => { 68 | const videos = await getVideosInChannelWithId(channel.id) 69 | return videos.map(video => { 70 | return { ...video, channel } 71 | }) 72 | }) 73 | ) 74 | return unflatted.flat().sort((a, b) => a.publishedAt < b.publishedAt) 75 | } 76 | 77 | async function getVideosInChannelWithId(channelId) { 78 | // HACK: The ID of the playlist containing a channel's uploads can be 79 | // constructed from the channel ID. This isn't really a good idea but 80 | // it saves us from doing an API call for each channel, and as such, 81 | // reduces the impact each of Tapestry's loads has on our API quota. 82 | const playlistId = channelId.replace(/^UC/g, "UU") 83 | const text = await sendRequest( 84 | `https://www.googleapis.com/youtube/v3/playlistItems` 85 | + `?part=snippet` 86 | + `&playlistId=${playlistId}` 87 | ) 88 | const obj = JSON.parse(text) 89 | return obj.items.filter(item => { 90 | return item.snippet.resourceId.kind === "youtube#video" 91 | }).map(item => { 92 | return { 93 | title: item.snippet.title, 94 | description: item.snippet.description, 95 | image: getHighResolutionThumbnail(item.snippet.thumbnails), 96 | thumbnail: getLowResolutionThumbnail(item.snippet.thumbnails), 97 | publishedAt: new Date(item.snippet.publishedAt), 98 | permalink: `https://www.youtube.com/watch?v=${item.snippet.resourceId.videoId}` 99 | } 100 | }) 101 | } 102 | 103 | function getHighResolutionThumbnail(thumbnails) { 104 | if (thumbnails.maxres) { 105 | return thumbnails.maxres.url 106 | } else if (thumbnails.standard) { 107 | return thumbnails.standard.url 108 | } else if (thumbnails.high) { 109 | return thumbnails.high.url 110 | } else if (thumbnails.medium) { 111 | return thumbnails.medium.url 112 | } else if (thumbnails.default) { 113 | return thumbnails.default.url 114 | } else { 115 | return null 116 | } 117 | } 118 | 119 | function getLowResolutionThumbnail(thumbnails) { 120 | if (thumbnails.standard) { 121 | return thumbnails.standard.url 122 | } else if (thumbnails.high) { 123 | return thumbnails.high.url 124 | } else if (thumbnails.medium) { 125 | return thumbnails.medium.url 126 | } else if (thumbnails.default) { 127 | return thumbnails.default.url 128 | } else if (thumbnails.maxres) { 129 | return thumbnails.maxres.url 130 | } else { 131 | return null 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /dev.simonbs.youtube/ui-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": [{ 3 | "name": "varVideoCount", 4 | "type": "choices", 5 | "prompt": "Number of Videos", 6 | "value": "25", 7 | "choices": "5, 10, 25, 50, 100" 8 | }] 9 | } 10 | --------------------------------------------------------------------------------