├── .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}
${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, "