@single-studio/plugin-sheets 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Shaun Delaney
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @single-studio/plugin-sheets
2
+
3
+ A Google Sheet as a data source for
4
+ [Single Studio](https://fourcourtjester.github.io/Single-Studio/) — a standings table,
5
+ a roster, a running order — with an API key and no backend.
6
+
7
+ ```bash
8
+ npm i @single-studio/plugin-sheets
9
+ ```
10
+
11
+ ```js
12
+ import { sheets, GoogleSheetsHandler } from '@single-studio/plugin-sheets'
13
+
14
+ // Which sheet, and which cells, are the *studio's* — see below.
15
+ const SHEET = { id: '1AbC…', range: 'Standings!A1:D20' }
16
+
17
+ class MyShow extends GoogleSheetsHandler {
18
+ onRows({ rows }) {
19
+ this.mutate('replace', { prefix: 'standings', members: rows })
20
+ }
21
+ }
22
+
23
+ createVelcroHost({ name: STUDIO_ID, mutations, plugins: [sheets(MyShow, SHEET)] })
24
+ ```
25
+
26
+ The first row becomes column names by default, so a heading of `Team Name` reaches
27
+ your graphics as `teamName`.
28
+
29
+ ## What the operator sets, and what you set
30
+
31
+ The operator's board asks for **an API key** and **how often to read**. That is
32
+ deliberately all of it.
33
+
34
+ The **spreadsheet id and range are yours**, passed to the factory. A range is not a
35
+ preference: your graphics have code expecting columns in an order, and a range that
36
+ does not match does not show a different view of the same data — it shows the wrong
37
+ column, confidently, with no error anywhere. Both are also already decided by the time
38
+ anyone opens the panel, so offering them is offering a choice that has been made.
39
+
40
+ ## The key
41
+
42
+ Share the sheet as **anyone with the link can view**, then create an API key with the
43
+ Sheets API enabled.
44
+
45
+ `type: 'secret'` masks the field on the board. It does not hide the key at runtime —
46
+ the request is made from the browser, so it is in the network tab of the machine
47
+ running the board. The protection is **restriction, not concealment**: limit the key
48
+ to the Sheets API in the Cloud console, and add an HTTP referrer restriction where the
49
+ studio is served from a known origin.
50
+
51
+ What it does solve is the failure that actually happens — a key compiled into a bundle
52
+ and sitting on a public `gh-pages` branch for a year.
53
+
54
+ ## Reading
55
+
56
+ Only one machine in a room polls, and an unchanged answer says nothing at all — so a
57
+ read that finds no change costs one request and no document write. Five seconds is the
58
+ floor, whatever is typed; Google allows sixty reads a minute.
59
+
60
+ A refusal stops rather than retrying, because a private sheet stays private however
61
+ many times it is asked. A dropped network retries, because that is what backoff is
62
+ for.
63
+
64
+ ## Licence
65
+
66
+ MIT.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@single-studio/plugin-sheets",
3
+ "version": "0.6.0",
4
+ "description": "Google Sheets for Single Studio: a shared spreadsheet as a data source, with an API key and no backend.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FourCourtJester/Single-Studio.git",
9
+ "directory": "packages/plugin-sheets"
10
+ },
11
+ "homepage": "https://github.com/FourCourtJester/Single-Studio#readme",
12
+ "bugs": "https://github.com/FourCourtJester/Single-Studio/issues",
13
+ "type": "module",
14
+ "keywords": [
15
+ "single-studio",
16
+ "single-studio-plugin",
17
+ "obs",
18
+ "broadcast"
19
+ ],
20
+ "exports": {
21
+ ".": "./src/index.js"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "vitest run"
30
+ },
31
+ "peerDependencies": {
32
+ "@single-studio/core": "^0.6.0"
33
+ },
34
+ "devDependencies": {
35
+ "@single-studio/core": "workspace:*",
36
+ "vitest": "^3.2.6"
37
+ }
38
+ }
package/src/index.js ADDED
@@ -0,0 +1,140 @@
1
+ import { definePlugin, PluginHandler, PollingService } from '@single-studio/core/worker'
2
+
3
+ import { explain, parse, urlFor } from './sheet.js'
4
+
5
+ export { explain, keyOf, parse, same, urlFor } from './sheet.js'
6
+
7
+ /**
8
+ * A shared spreadsheet as a data source.
9
+ *
10
+ * The one people actually ask for. A spreadsheet is the tool a production team
11
+ * already has and already knows: the roster, the running order, the standings, the
12
+ * lower-third copy for the night. Somebody who will never open a code editor will
13
+ * happily keep a sheet up to date, and it is collaborative for free.
14
+ *
15
+ * Unlike the others this does not push. There is no socket and no notification, so
16
+ * it asks on a timer -- and everything about the design follows from wanting to ask
17
+ * as rarely as possible and to say nothing when the answer has not changed.
18
+ *
19
+ * **The ownership predicate matters most here.** Five operators each polling the
20
+ * same sheet is five times the quota and five writers racing on the same paths, for
21
+ * one sheet's worth of information. `Service` already answers that: exactly one
22
+ * machine asks, and everybody else reads the replicated result.
23
+ */
24
+ class GoogleSheets extends PollingService {
25
+ static serviceName = 'google-sheets'
26
+
27
+ /** Google allows sixty reads a minute per user. One a second is a limit waiting. */
28
+ get floorSeconds() {
29
+ return 5
30
+ }
31
+
32
+ async read(signal) {
33
+ // Handed to `fetch` so the deadline actually stops the request. The base class
34
+ // enforces it either way, but without this the abandoned request keeps running
35
+ // and the next tick's poll queues up behind somebody else's stalled socket.
36
+ const response = await fetch(urlFor({ id: this.config.id, range: this.config.range, key: this.config.key }), { signal })
37
+ const body = await response.json().catch(() => ({}))
38
+
39
+ if (!response.ok) {
40
+ const problem = new Error(explain(response.status, body))
41
+
42
+ // Marked so `fatal` can tell a refusal from a dropped network without
43
+ // matching on the message text.
44
+ problem.refused = true
45
+ problem.status = response.status
46
+
47
+ throw problem
48
+ }
49
+
50
+ return parse(body, { header: this.config.header !== false })
51
+ }
52
+
53
+ /**
54
+ * A refusal is not something a retry fixes: a private sheet stays private however
55
+ * many times it is asked, and retrying only spends quota to be refused again. A
56
+ * dropped network is the opposite.
57
+ */
58
+ fatal(error) {
59
+ return Boolean(error?.refused)
60
+ }
61
+
62
+ /** The status is what a handler wants; the sentence is what a board shows. */
63
+ problemOf(error) {
64
+ return { status: error?.status ?? null, message: error?.message ?? String(error) }
65
+ }
66
+
67
+ /** `rows` rather than the base class's `changed`, because that is what it is. */
68
+ publish(next) {
69
+ this.emit('rows', { rows: next.rows, header: next.header, count: next.count })
70
+ }
71
+ }
72
+
73
+ /** The skeleton a studio fills in. */
74
+ export class GoogleSheetsHandler extends PluginHandler {
75
+ static handles = { rows: 'onRows', problem: 'onProblem' }
76
+
77
+ onRows() {}
78
+
79
+ onProblem() {}
80
+ }
81
+
82
+ /**
83
+ * @param {typeof GoogleSheetsHandler} [Handler]
84
+ * @param {{ id?: string, range?: string, header?: boolean }} [sheet]
85
+ * Which spreadsheet, and which cells. **Given by the studio, not by the operator.**
86
+ *
87
+ * A range is not a preference. The studio reading it has code that expects columns
88
+ * in an order, and a range that does not match is not a different view of the same
89
+ * data -- it is a graphic quietly showing the wrong column, which is the worst way
90
+ * for this to fail. The same goes for the id: a studio ships knowing the shape of
91
+ * the sheet it was written against.
92
+ *
93
+ * So the operator is asked for the one thing that is genuinely theirs and cannot
94
+ * be shipped -- an API key, which is a credential.
95
+ */
96
+ export const sheets = (Handler = GoogleSheetsHandler, sheet = {}) =>
97
+ definePlugin({
98
+ name: 'sheets',
99
+ label: 'Google Sheet',
100
+ summary: 'Reads a spreadsheet, so anybody on the team can update the show from a browser.',
101
+ help: [
102
+ { type: 'text', text: 'Two things are needed: the sheet has to be readable by anyone with its link, and you need a Google API key.' },
103
+ {
104
+ type: 'steps',
105
+ items: [
106
+ 'Open the sheet, press Share, and set General access to "Anyone with the link" as a Viewer.',
107
+ 'Go to console.cloud.google.com, make a project if you have none, and enable the Google Sheets API for it.',
108
+ 'Under APIs & Services → Credentials, create an API key and paste it above.',
109
+ ],
110
+ },
111
+ {
112
+ type: 'note',
113
+ text: 'Which sheet this reads, and which cells, come from the studio rather than from here. They have to match the graphics that read them, so they are not something to change during a show.',
114
+ },
115
+ { type: 'link', href: 'https://console.cloud.google.com/apis/credentials', label: 'Google Cloud credentials' },
116
+ {
117
+ type: 'note',
118
+ text: 'The key only ever reads, and only what the sheet’s own sharing already allows. It is worth restricting it to the Sheets API in the Cloud console.',
119
+ },
120
+ { type: 'text', text: 'The first row is used as column names by default, so a heading of "Team Name" becomes teamName in your graphics.' },
121
+ ],
122
+ config: [
123
+ {
124
+ key: 'key',
125
+ label: 'API key',
126
+ type: 'secret',
127
+ help: 'A Google API key with the Sheets API enabled. The sheet must be shared as “anyone with the link can view”.',
128
+ },
129
+ { key: 'every', label: 'Read every (seconds)', type: 'number', default: 10, help: 'Five is the floor. Google allows sixty reads a minute.' },
130
+ ],
131
+ create: (context) => {
132
+ // The studio's sheet wins over anything stored, because there is nowhere to
133
+ // store it from -- these are not fields on the panel.
134
+ const plugin = new GoogleSheets({ ...context, config: { ...context.config, ...sheet } })
135
+
136
+ new Handler({ ...context, plugin }).attach(plugin.events)
137
+
138
+ return plugin
139
+ },
140
+ })
package/src/sheet.js ADDED
@@ -0,0 +1,124 @@
1
+ // A Google Sheets response, turned into rows a studio can use.
2
+ //
3
+ // Pure, so the whole shape of the thing is testable without a network, an API key,
4
+ // or a spreadsheet. What is left in the plugin is the polling.
5
+ //
6
+ // Sheets is unlike the other feeds in one way that decides the design: it does not
7
+ // push. There is no socket and no notification, so the plugin asks on a timer, and
8
+ // every design choice here follows from wanting to ask as rarely as possible and
9
+ // say nothing when the answer has not changed.
10
+
11
+ /**
12
+ * The API omits trailing empty cells rather than padding them, so a row whose last
13
+ * two columns are blank comes back short. Reading by index then silently shifts
14
+ * every value left of a gap.
15
+ *
16
+ * @param {string[][]} values
17
+ * @param {number} width
18
+ */
19
+ const rectangular = (values, width) => values.map((row) => Array.from({ length: width }, (_, index) => row?.[index] ?? ''))
20
+
21
+ /** The width of the widest row, which is the only honest column count. */
22
+ const widthOf = (values) => values.reduce((widest, row) => Math.max(widest, row?.length ?? 0), 0)
23
+
24
+ /**
25
+ * A header cell as a key somebody would type.
26
+ *
27
+ * `Team Name` becomes `teamName`, because a graphic writing `row['Team Name']` is a
28
+ * graphic that breaks when somebody tidies the capitalisation of a spreadsheet.
29
+ */
30
+ export const keyOf = (label, index) => {
31
+ const cleaned = String(label ?? '')
32
+ .trim()
33
+ .replace(/[^a-zA-Z0-9]+(.)?/g, (_match, next) => (next ? next.toUpperCase() : ''))
34
+
35
+ const key = cleaned.charAt(0).toLowerCase() + cleaned.slice(1)
36
+
37
+ // An unnamed column still needs a name, or two of them collide on ''.
38
+ return key || `column${index + 1}`
39
+ }
40
+
41
+ /**
42
+ * Turn `{ values: [...] }` into rows.
43
+ *
44
+ * @param {object} body The API response.
45
+ * @param {object} [options]
46
+ * @param {boolean} [options.header] Treat the first row as column names.
47
+ * @returns {{ rows: Array<object|string[]>, header: string[]|null, count: number }}
48
+ */
49
+ export function parse(body, { header = true } = {}) {
50
+ const values = Array.isArray(body?.values) ? body.values : []
51
+
52
+ if (!values.length) return { rows: [], header: header ? [] : null, count: 0 }
53
+
54
+ const width = widthOf(values)
55
+ const grid = rectangular(values, width)
56
+
57
+ if (!header) return { rows: grid, header: null, count: grid.length }
58
+
59
+ const [labels, ...rest] = grid
60
+ const keys = labels.map(keyOf)
61
+
62
+ // A row that is entirely blank is a spacer somebody left in the sheet, not a
63
+ // record. Emitting it puts an empty name on air.
64
+ const rows = rest
65
+ .filter((row) => row.some((cell) => String(cell).trim() !== ''))
66
+ .map((row) => Object.fromEntries(keys.map((key, index) => [key, row[index] ?? ''])))
67
+
68
+ return { rows, header: keys, count: rows.length }
69
+ }
70
+
71
+ /**
72
+ * Whether two parses differ.
73
+ *
74
+ * The reason the plugin can poll often without costing anything: an unchanged sheet
75
+ * emits nothing, so a studio's handler -- and every mutation it would make -- runs
76
+ * only when somebody has actually edited something.
77
+ *
78
+ * A string compare rather than a deep walk, because the values are strings from a
79
+ * spreadsheet and the arrays are small. Anything cleverer would be slower.
80
+ */
81
+ export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
82
+
83
+ /**
84
+ * The URL to ask.
85
+ *
86
+ * An API key rather than OAuth, which is what makes this work with no backend: a
87
+ * sheet shared as "anyone with the link can view" is readable with a key, and a key
88
+ * is restrictable by referrer rather than being a secret to keep.
89
+ *
90
+ * @param {object} options
91
+ * @param {string} options.id The spreadsheet id, from its URL.
92
+ * @param {string} options.range A1 notation, e.g. `Standings!A1:D20`.
93
+ * @param {string} options.key
94
+ */
95
+ export function urlFor({ id, range, key }) {
96
+ if (!id) throw new Error('A spreadsheet id is needed.')
97
+ if (!key) throw new Error('A Google API key is needed.')
98
+
99
+ const where = encodeURIComponent(range || 'A:Z')
100
+
101
+ // `UNFORMATTED_VALUE` would hand back numbers and serial dates, which is worse
102
+ // for a broadcast: what the operator typed in the cell is what they expect to see
103
+ // on air, currency symbols and all.
104
+ return `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(id)}/values/${where}?key=${encodeURIComponent(key)}&valueRenderOption=FORMATTED_VALUE`
105
+ }
106
+
107
+ /**
108
+ * What a failed request means, in words an operator can act on.
109
+ *
110
+ * Google's messages are accurate and unhelpful at the moment something is wrong on
111
+ * a show -- "The caller does not have permission" does not say that the fix is one
112
+ * sharing setting.
113
+ */
114
+ export function explain(status, body) {
115
+ const detail = body?.error?.message ?? ''
116
+
117
+ if (status === 403 && /API key not valid/i.test(detail)) return 'That API key was refused. Check it, and that the Sheets API is enabled for its project.'
118
+ if (status === 403) return 'Google refused the request. The sheet has to be shared as "anyone with the link can view".'
119
+ if (status === 404) return 'No spreadsheet with that id. Check the id in the sheet’s URL.'
120
+ if (status === 400 && /Unable to parse range/i.test(detail)) return 'That range was not understood. Use A1 notation, like Standings!A1:D20.'
121
+ if (status === 429) return 'Google is rate limiting this key. Poll less often.'
122
+
123
+ return detail || `Google answered ${status}.`
124
+ }