From 9da3a61f708b1f17131a11e362c91ad1834c5e87 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 01:42:57 +0000 Subject: [PATCH 1/2] Add a share button that puts the gzip'd CSV in the link The `#csv=` fragment was read-only: a link that carries a whole dataset could only be built by going back to the file and running the README's `gzip | base64 | tr` pipeline over it -- which does not help at all for a CSV that got into the page through the upload button. So `csvFragment.ts` gains the other half, `encodeCSVFragment`, and a Share Link button sits next to Upload CSV and copies the result. The link is built in the tab out of the CSV text as it was read, options and comments intact, so nothing about the data leaves the machine -- the fragment never reaches a server, and the compression runs in the browser. `?csv=` is dropped from the link rather than carried along: the fragment outranks it on read, so keeping it would only pad the link with a second copy of the data -- the copy that does travel to the server. Where `navigator.clipboard` is missing -- a report served over plain http, or a permissions policy that withholds it -- the link goes to the address bar via `replaceState` instead, which is the same link one ctrl-L from being copied by hand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B7uWnd8S3d9M1kAsJTHom1 --- README.md | 19 +++-- src/App.tsx | 11 ++- src/components/ShareButton.test.tsx | 20 ++++++ src/components/ShareButton.tsx | 71 ++++++++++++++++++ src/components/TimeSeriesChart.tsx | 5 ++ src/csvFragment.test.ts | 39 +++++++++- src/csvFragment.ts | 70 ++++++++++++++++-- src/share.test.ts | 107 ++++++++++++++++++++++++++++ src/share.ts | 59 +++++++++++++++ src/style.css | 32 ++++++++- 10 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 src/components/ShareButton.test.tsx create mode 100644 src/components/ShareButton.tsx create mode 100644 src/share.test.ts create mode 100644 src/share.ts diff --git a/README.md b/README.md index bd109a2..f18a737 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ Tool + app for visualizing time series data. Visit https://aaronstacy.com/plottimeseries -You can also upload a CSV file using the button in the upper right corner. The -data is updated locally, nothing ever leaves your browser. +You can also upload a CSV file using the button in the upper right corner, and +share what you are looking at with the button next to it. The data is updated +locally, nothing ever leaves your browser. ### Sharing a whole dataset in a link @@ -23,10 +24,16 @@ rendering][example_csv_hash], but you can find it in [at the bottom of the source of this README.md file](/aaronj1335/plottimeseries/blob/main/README.md?plain=1). -Encode a gzip'd + base64 encoded CSV into the URL fragment `#csv=...` to create -a link that can be shared. Plain URL encoded text in the `?csv=` query parameter -also works, but that is sent to the server as a request. To build the URL, you -can run: +The **Share Link** button in the upper right builds that link out of whatever +the page is currently plotting -- uploaded file included -- and copies it to +your clipboard. The CSV is gzip'd and encoded in the browser, so the data still +never leaves your machine. (If the clipboard is not available, the link is put +in the address bar instead.) + +The link itself is a gzip'd + base64 encoded CSV in the URL fragment +`#csv=...`. Plain URL encoded text in the `?csv=` query parameter also works, +but that is sent to the server as a request. To build the URL from a shell +instead, you can run: ```bash printf 'https://aaronstacy.com/plottimeseries#csv=%s' "$(gzip -nc your.csv | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')" diff --git a/src/App.tsx b/src/App.tsx index 596166a..73a28d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { TimeSeriesChart } from './components/TimeSeriesChart.tsx'; import { HoverDetails } from './components/HoverDetails.tsx'; import { DataTable } from './components/DataTable.tsx'; import { getCSVData } from './data.ts'; +import { shareCSV } from './share.ts'; import { cssVar } from './theme.ts'; import { type ChartOptions, getChartOptions } from './chartOptions.ts'; @@ -24,6 +25,9 @@ declare global { function App() { const [dataset, setDataset] = useState(EMPTY_CSV); const { data, formattedData, columns, columnStyles } = dataset; + // The text as it was read, not as it was parsed: a shared link should reopen + // the same CSV, comments, column options and all. + const [csv, setCSV] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -44,7 +48,8 @@ function App() { setError(null); try { - const result = processCSV(await readCSV()); + const text = await readCSV(); + const result = processCSV(text); if (result.data.length === 0) { if (result.columns.length > 0 && !result.columns.some(isDateColumn)) { throw new Error(`No "date" column found in CSV (columns: ${result.columns.join(', ')})`); @@ -53,6 +58,7 @@ function App() { } setDataset(result); + setCSV(text); setIsolatedSeries(null); setHoveredDate(null); } catch (err: unknown) { @@ -99,6 +105,8 @@ function App() { setIsolatedSeries(prev => (prev === series ? null : series)); }; + const handleShare = useCallback(() => shareCSV(window, csv), [csv]); + const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) void loadCSV(() => file.text()); @@ -129,6 +137,7 @@ function App() { onToggleSpreadDates={() => setSpreadDates(!spreadDates)} columnColors={columnColors} onFileUpload={handleFileUpload} + onShare={handleShare} columnStyles={columnStyles} chartOptions={chartOptions} /> diff --git a/src/components/ShareButton.test.tsx b/src/components/ShareButton.test.tsx new file mode 100644 index 0000000..f1bd047 --- /dev/null +++ b/src/components/ShareButton.test.tsx @@ -0,0 +1,20 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ShareButton, shareLabel } from './ShareButton.tsx'; + +describe('ShareButton', () => { + it('offers to share before it has been clicked', () => { + const markup = renderToStaticMarkup( Promise.resolve('copied')} />); + + assert.match(markup, /Share Link/); + assert.match(markup, /class="share-button"/); + assert.doesNotMatch(markup, /disabled/); + }); + + it('names every state it can land in, so none renders blank', () => { + for (const status of ['idle', 'working', 'copied', 'address-bar', 'failed'] as const) { + assert.ok(shareLabel(status).length > 0, `no label for ${status}`); + } + }); +}); diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx new file mode 100644 index 0000000..89c30ca --- /dev/null +++ b/src/components/ShareButton.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react'; +import type { ShareOutcome } from '../share.ts'; + +/** How long the result of a click stays on the button before it offers again. */ +const RESET_MS = 2500; + +type ShareStatus = 'idle' | 'working' | ShareOutcome | 'failed'; + +/** + * Short enough to keep the toolbar from reflowing on every click; the `title` + * carries the detail. + */ +const LABELS: Record = { + idle: 'Share Link', + working: 'Sharing…', + copied: 'Copied!', + 'address-bar': 'In URL bar', + failed: 'Failed', +}; + +const TITLES: Record = { + idle: 'Copy a link that carries this CSV, compressed, in its fragment', + working: 'Compressing the CSV into a link', + copied: 'The link is on your clipboard', + 'address-bar': 'The clipboard was unavailable, so the link is in the address bar', + failed: 'The link could not be built', +}; + +export function shareLabel(status: ShareStatus): string { + return LABELS[status]; +} + +interface ShareButtonProps { + onShare: () => Promise; +} + +export const ShareButton: React.FC = ({ onShare }) => { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + + useEffect(() => { + if (status === 'idle' || status === 'working') return; + + const timer = setTimeout(() => setStatus('idle'), RESET_MS); + return () => clearTimeout(timer); + }, [status]); + + const handleClick = async () => { + setStatus('working'); + setError(null); + try { + setStatus(await onShare()); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + setStatus('failed'); + } + }; + + return ( + + ); +}; diff --git a/src/components/TimeSeriesChart.tsx b/src/components/TimeSeriesChart.tsx index b6e1417..3b2cabc 100644 --- a/src/components/TimeSeriesChart.tsx +++ b/src/components/TimeSeriesChart.tsx @@ -3,6 +3,8 @@ import * as d3 from 'd3'; import { type ColumnStyles, type DataPoint, isSeriesColumn } from '../dataProcessing.ts'; import type { ChartOptions } from '../chartOptions.ts'; import { subdivideGridPositions } from '../gridLines.ts'; +import { ShareButton } from './ShareButton.tsx'; +import type { ShareOutcome } from '../share.ts'; import { cssVar, THEME } from '../theme.ts'; const CLIP_ID = 'plot-area-clip'; @@ -28,6 +30,7 @@ interface TimeSeriesChartProps { onToggleSpreadDates: () => void; columnColors: Record; onFileUpload: (event: React.ChangeEvent) => void; + onShare: () => Promise; columnStyles?: ColumnStyles; chartOptions?: ChartOptions; } @@ -44,6 +47,7 @@ export const TimeSeriesChart: React.FC = ({ onToggleSpreadDates, columnColors, onFileUpload, + onShare, columnStyles = {}, chartOptions = {}, }) => { @@ -383,6 +387,7 @@ export const TimeSeriesChart: React.FC = ({ Spread Duplicate Dates + diff --git a/src/csvFragment.test.ts b/src/csvFragment.test.ts index 7dd5124..1af86b4 100644 --- a/src/csvFragment.test.ts +++ b/src/csvFragment.test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import * as zlib from 'node:zlib'; -import { MAX_DECOMPRESSED_BYTES, decodeCSVFragment } from './csvFragment.ts'; +import { MAX_DECOMPRESSED_BYTES, decodeCSVFragment, encodeCSVFragment } from './csvFragment.ts'; const CSV = `date,value 2023-01-01,1 @@ -72,3 +72,40 @@ describe('decodeCSVFragment', () => { assert.strictEqual(MAX_DECOMPRESSED_BYTES, 32 * 1024 * 1024); }); }); + +describe('encodeCSVFragment', () => { + it('writes gzip, so the link stays short enough to paste', async () => { + const encoded = await encodeCSVFragment(CSV); + const bytes = Buffer.from(encoded, 'base64url'); + + assert.deepStrictEqual([bytes[0], bytes[1]], [0x1f, 0x8b]); + assert.strictEqual(zlib.gunzipSync(bytes).toString('utf-8'), CSV); + }); + + it('writes the alphabet a URL fragment can carry unescaped', async () => { + // Every byte value, so the encoder cannot avoid the two characters + // standard base64 has that base64url does not. + const everyByte = String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i)); + const encoded = await encodeCSVFragment(everyByte); + + assert.match(encoded, /^[A-Za-z0-9_-]+$/); + assert.strictEqual(encodeURIComponent(encoded), encoded); + }); + + it('round-trips through the decoder', async () => { + for (const text of [CSV, '', 'date,café\n2023-01-01,€\n']) { + assert.strictEqual(await decodeCSVFragment(await encodeCSVFragment(text)), text); + } + }); + + it('round-trips a CSV far past one argument per byte', async () => { + // A megabyte of rows: enough that encoding it a byte at a time, as one call + // per chunk of the compressed output, would blow the argument stack. + const rows: string[] = ['date,value']; + for (let i = 0; i < 90000; i++) rows.push(`2023-01-01,${i}`); + const wide = rows.join('\n'); + assert.ok(wide.length > 1024 * 1024); + + assert.strictEqual(await decodeCSVFragment(await encodeCSVFragment(wide)), wide); + }); +}); diff --git a/src/csvFragment.ts b/src/csvFragment.ts index 0483be7..9c36609 100644 --- a/src/csvFragment.ts +++ b/src/csvFragment.ts @@ -1,5 +1,5 @@ /** - * Decoding for the `#csv=` fragment: base64url bytes, gzipped or not. + * The `#csv=` fragment: base64url bytes, gzipped or not, encoded and decoded. * * A URL fragment is the only place a large CSV can ride in a link without a * server to store it. Browsers never put it in a request, so the data is not @@ -13,10 +13,10 @@ * defensive tidiness: gzip reaches about 1032:1, so a link small enough to * paste into a chat message expands to gigabytes and takes the tab with it. * - * There is deliberately no encoder here. Anything that can run a script in this - * repository can build a whole self-contained report instead, so the encoding - * is documented as a `gzip | base64 | tr` pipeline in the README -- which also - * works from a machine that has the CSV and nothing else. + * The encoder is the share button's half of that: it is the same `gzip | + * base64 | tr` pipeline the README documents for a shell, run in the tab that + * already holds the data, so sharing what is on screen does not mean going + * back to the file it came from. */ /** @@ -101,3 +101,63 @@ export async function decodeCSVFragment( if (!isGzip(bytes)) return new TextDecoder().decode(bytes); return inflate(bytes, limit); } + +// `String.fromCharCode(...bytes)` is one call with one argument per byte, so a +// whole megabyte-sized CSV passed at once overflows the argument stack. 32 KiB +// at a time is well under every engine's limit. +const BTOA_CHUNK_BYTES = 32 * 1024; + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i += BTOA_CHUNK_BYTES) { + binary += String.fromCharCode(...bytes.subarray(i, i + BTOA_CHUNK_BYTES)); + } + + // Padding is dropped rather than percent-encoded: `=` is legal in a fragment, + // but `decodeBase64Url` does not need it, and the link is shorter without it. + return btoa(binary).split('+').join('-').split('/').join('_').split('=').join(''); +} + +async function deflate(text: string): Promise { + if (typeof CompressionStream === 'undefined') { + throw new Error('This browser cannot build compressed links: it has no CompressionStream.'); + } + + // Same shape as `inflate` above: `BufferSource` on the writable side, and the + // chunk type named on `pipeThrough` so the `any` readable side stops there. + const plain = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + + const reader = plain.pipeThrough(new CompressionStream('gzip')).getReader(); + + const chunks: Uint8Array[] = []; + let total = 0; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.byteLength; + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** + * The inverse of `decodeCSVFragment`, always compressing: a CSV is repetitive + * enough that gzip is the difference between a link that pastes into a chat + * message and one that does not. + */ +export async function encodeCSVFragment(csv: string): Promise { + return encodeBase64Url(await deflate(csv)); +} diff --git a/src/share.test.ts b/src/share.test.ts new file mode 100644 index 0000000..545e523 --- /dev/null +++ b/src/share.test.ts @@ -0,0 +1,107 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import * as zlib from 'node:zlib'; + +import { buildShareURL, shareCSV, type ShareWindow } from './share.ts'; + +const CSV = `date,value +2023-01-01,1 +2023-01-02,2`; + +interface FakeWindow extends ShareWindow { + copied: string[]; + replaced: string[]; +} + +function fakeWindow(href: string, writeText?: (text: string) => Promise): FakeWindow { + const copied: string[] = []; + const replaced: string[] = []; + + return { + location: { href }, + navigator: { + clipboard: writeText && { + writeText: async (text: string) => { + await writeText(text); + copied.push(text); + }, + }, + }, + history: { + replaceState: (_data: unknown, _unused: string, url: string) => { + replaced.push(url); + }, + }, + copied, + replaced, + }; +} + +function csvFrom(url: string): string { + const fragment = new URLSearchParams(new URL(url).hash.slice(1)).get('csv') ?? ''; + return zlib.gunzipSync(Buffer.from(fragment, 'base64url')).toString('utf-8'); +} + +describe('buildShareURL', () => { + it('carries the CSV back to the same page', async () => { + const url = await buildShareURL(fakeWindow('https://example.com/plottimeseries'), CSV); + + assert.strictEqual( + new URL(url).origin + new URL(url).pathname, + 'https://example.com/plottimeseries', + ); + assert.strictEqual(csvFrom(url), CSV); + }); + + it('shares what is on screen, not the fragment the page was opened with', async () => { + const stale = `csv=${zlib.gzipSync(Buffer.from('date,old\n2020-01-01,1')).toString('base64url')}`; + const url = await buildShareURL(fakeWindow(`https://example.com/#${stale}`), CSV); + + assert.strictEqual(csvFrom(url), CSV); + }); + + it('drops a ?csv= that would ride along to the server', async () => { + const url = await buildShareURL( + fakeWindow('https://example.com/?csv=date%2Cold%0A2020-01-01%2C1'), + CSV, + ); + + assert.strictEqual(new URL(url).searchParams.get('csv'), null); + assert.strictEqual(csvFrom(url), CSV); + }); + + it('leaves every other query parameter alone', async () => { + const url = await buildShareURL(fakeWindow('https://example.com/?ymin=0&ymax=100'), CSV); + + assert.strictEqual(new URL(url).search, '?ymin=0&ymax=100'); + }); +}); + +describe('shareCSV', () => { + it('puts the link on the clipboard', async () => { + const win = fakeWindow('https://example.com/', () => Promise.resolve()); + + assert.strictEqual(await shareCSV(win, CSV), 'copied'); + assert.strictEqual(win.copied.length, 1); + assert.strictEqual(csvFrom(win.copied[0]!), CSV); + assert.deepStrictEqual(win.replaced, []); + }); + + it('falls back to the address bar where there is no clipboard', async () => { + const win = fakeWindow('https://example.com/'); + + assert.strictEqual(await shareCSV(win, CSV), 'address-bar'); + assert.strictEqual(win.replaced.length, 1); + assert.strictEqual(csvFrom(win.replaced[0]!), CSV); + }); + + it('falls back to the address bar when the clipboard write is refused', async () => { + const win = fakeWindow('https://example.com/', () => + Promise.reject(new Error('Document is not focused')), + ); + + assert.strictEqual(await shareCSV(win, CSV), 'address-bar'); + assert.deepStrictEqual(win.copied, []); + assert.strictEqual(csvFrom(win.replaced[0]!), CSV); + }); +}); diff --git a/src/share.ts b/src/share.ts new file mode 100644 index 0000000..2c88c20 --- /dev/null +++ b/src/share.ts @@ -0,0 +1,59 @@ +/** + * The share button's half of `data.ts`: the CSV this tab is showing, turned + * back into a link that reopens it. + * + * `getCSVData` reads `#csv=` and `?csv=`; this writes the fragment form, which + * is the only one that holds a whole dataset without sending it anywhere. The + * link is delivered to the clipboard, and to the address bar when that is not + * available -- either way it never leaves the machine. + */ +import { encodeCSVFragment } from './csvFragment.ts'; + +/** Where the link ended up, so the button can say which happened. */ +export type ShareOutcome = 'copied' | 'address-bar'; + +/** The parts of `window` sharing touches, spelled out so tests can stand in. */ +export interface ShareWindow { + readonly location: { readonly href: string }; + // `| undefined` rather than just optional: outside a secure context the + // property is there and holds nothing, which is not the same as absent. + readonly navigator: { + readonly clipboard?: { writeText(text: string): Promise } | undefined; + }; + readonly history: { replaceState(data: unknown, unused: string, url: string): void }; +} + +/** + * This page, carrying the whole dataset in its fragment. + * + * Any `?csv=` is dropped rather than carried along: the fragment outranks it + * when the link is opened, so keeping it would only pad the link out with a + * second copy of the data -- the copy that does travel to the server. + */ +export async function buildShareURL(win: ShareWindow, csv: string): Promise { + const url = new URL(win.location.href); + url.searchParams.delete('csv'); + url.hash = `csv=${await encodeCSVFragment(csv)}`; + return url.toString(); +} + +export async function shareCSV(win: ShareWindow, csv: string): Promise { + const url = await buildShareURL(win, csv); + + try { + // `navigator.clipboard` is typed as always being there, but it is missing + // outside a secure context -- a report opened from a plain http:// host, or + // through a permissions policy that withholds it. + const { clipboard } = win.navigator; + if (!clipboard) throw new Error('This browser did not offer a clipboard.'); + await clipboard.writeText(url); + return 'copied'; + } catch { + // Second best is the address bar: the same link, one ctrl-L from being + // copied by hand. `replaceState` rather than assigning `location.hash`, + // which would leave a history entry to back out of -- and, since the app + // reads the fragment once at load, would not reload the page either way. + win.history.replaceState(null, '', url); + return 'address-bar'; + } +} diff --git a/src/style.css b/src/style.css index d38b7c3..4e076a4 100644 --- a/src/style.css +++ b/src/style.css @@ -104,8 +104,8 @@ table.data-table td { text-overflow: ellipsis; } -.upload-button { - background-color: var(--accent); +.upload-button, +.share-button { color: var(--text); border: none; padding: 0.4rem 0.8rem; @@ -118,10 +118,36 @@ table.data-table td { font-weight: 500; } +.upload-button { + background-color: var(--accent); +} + .upload-button:hover { background-color: var(--accent-hover); } -.upload-button:active { +/* Outlined rather than filled: uploading is what the toolbar is mostly for, + and two solid buttons side by side would not say which. */ +.share-button { + background-color: transparent; + border: 1px solid var(--rule-strong); + /* Wide enough for every label the button swaps in, so the toolbar does not + shift under the cursor between "Share Link" and "Copied!". */ + min-width: 6.5rem; +} + +.share-button:hover:not(:disabled) { + background-color: var(--panel); + border-color: var(--rule-hover); +} + +.share-button:disabled { + color: var(--text-muted); + opacity: 0.5; + cursor: default; +} + +.upload-button:active, +.share-button:active:not(:disabled) { transform: translateY(1px); } From 31645c147d33bf85f22d0c56b09228c04a8fd988 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 02:03:37 +0000 Subject: [PATCH 2/2] Hide the share button where the link would not open for anyone A report built by the CLI and opened off disk can still build a link -- it just builds `file:///home/you/report.html#csv=...`, which carries the whole dataset to someone who, to use it, already has the file. The test is the protocol, not whether the page is a report. The published site is a report too: `pages-public/index.html` is `renderReport` output with the same inlined `__INITIAL_CSV__`, and sharing from it is the point. So `http:` and `https:` keep the button and everything else -- `file:`, `blob:`, `data:` -- does not get one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B7uWnd8S3d9M1kAsJTHom1 --- README.md | 3 ++- src/App.tsx | 6 ++++-- src/components/TimeSeriesChart.tsx | 5 +++-- src/share.test.ts | 26 +++++++++++++++++++++++++- src/share.ts | 16 ++++++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f18a737..8ede5c5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ The **Share Link** button in the upper right builds that link out of whatever the page is currently plotting -- uploaded file included -- and copies it to your clipboard. The CSV is gzip'd and encoded in the browser, so the data still never leaves your machine. (If the clipboard is not available, the link is put -in the address bar instead.) +in the address bar instead.) A report opened from disk has no button: its +`file://` link would only open for someone who already has that file. The link itself is a gzip'd + base64 encoded CSV in the URL fragment `#csv=...`. Plain URL encoded text in the `?csv=` query parameter also works, diff --git a/src/App.tsx b/src/App.tsx index 73a28d5..731e64d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { TimeSeriesChart } from './components/TimeSeriesChart.tsx'; import { HoverDetails } from './components/HoverDetails.tsx'; import { DataTable } from './components/DataTable.tsx'; import { getCSVData } from './data.ts'; -import { shareCSV } from './share.ts'; +import { canShare, shareCSV } from './share.ts'; import { cssVar } from './theme.ts'; import { type ChartOptions, getChartOptions } from './chartOptions.ts'; @@ -32,6 +32,8 @@ function App() { const [error, setError] = useState(null); const chartOptions = useMemo(() => getChartOptions(window), []); + // A report opened off disk has nobody to share with, so it gets no button. + const shareable = useMemo(() => canShare(window), []); const [columnWidths, setColumnWidths] = useState(null); @@ -137,7 +139,7 @@ function App() { onToggleSpreadDates={() => setSpreadDates(!spreadDates)} columnColors={columnColors} onFileUpload={handleFileUpload} - onShare={handleShare} + onShare={shareable ? handleShare : null} columnStyles={columnStyles} chartOptions={chartOptions} /> diff --git a/src/components/TimeSeriesChart.tsx b/src/components/TimeSeriesChart.tsx index 3b2cabc..ab7ed56 100644 --- a/src/components/TimeSeriesChart.tsx +++ b/src/components/TimeSeriesChart.tsx @@ -30,7 +30,8 @@ interface TimeSeriesChartProps { onToggleSpreadDates: () => void; columnColors: Record; onFileUpload: (event: React.ChangeEvent) => void; - onShare: () => Promise; + /** `null` where a link to this page would not open for anyone else. */ + onShare: (() => Promise) | null; columnStyles?: ColumnStyles; chartOptions?: ChartOptions; } @@ -387,7 +388,7 @@ export const TimeSeriesChart: React.FC = ({ Spread Duplicate Dates - + {onShare && } diff --git a/src/share.test.ts b/src/share.test.ts index 545e523..d5484c1 100644 --- a/src/share.test.ts +++ b/src/share.test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import * as zlib from 'node:zlib'; -import { buildShareURL, shareCSV, type ShareWindow } from './share.ts'; +import { buildShareURL, canShare, shareCSV, type ShareWindow } from './share.ts'; const CSV = `date,value 2023-01-01,1 @@ -42,6 +42,30 @@ function csvFrom(url: string): string { return zlib.gunzipSync(Buffer.from(fragment, 'base64url')).toString('utf-8'); } +describe('canShare', () => { + it('shares from a page someone else can open', () => { + for (const href of [ + 'https://aaronstacy.com/plottimeseries', + 'http://127.0.0.1:3000/', + // The published site is a generated report too, so being one cannot be + // what disqualifies a page. + 'https://aaronstacy.com/plottimeseries/index.html', + ]) { + assert.ok(canShare(fakeWindow(href)), href); + } + }); + + it('does not offer a link that only opens on this machine', () => { + for (const href of [ + 'file:///home/aaron/report.html', + 'blob:null/8f0d1f6e-0d3a-4c1e-9d3a-0d3a4c1e9d3a', + 'data:text/html,

hi

', + ]) { + assert.ok(!canShare(fakeWindow(href)), href); + } + }); +}); + describe('buildShareURL', () => { it('carries the CSV back to the same page', async () => { const url = await buildShareURL(fakeWindow('https://example.com/plottimeseries'), CSV); diff --git a/src/share.ts b/src/share.ts index 2c88c20..3055f08 100644 --- a/src/share.ts +++ b/src/share.ts @@ -23,6 +23,22 @@ export interface ShareWindow { readonly history: { replaceState(data: unknown, unused: string, url: string): void }; } +/** + * Protocols where a link to this page is worth anything to the person you send + * it to. A report built by the CLI and opened off disk is the case this rules + * out: its `file:///home/you/report.html#csv=...` link carries the whole + * dataset and still only opens for whoever already has that file at that path. + * + * Being a report is deliberately not the test. The published site is itself a + * report -- same inlined `__INITIAL_CSV__` -- and sharing from it is the point. + */ +const SHAREABLE_PROTOCOLS = ['http:', 'https:']; + +/** Whether a link to this page would reach anybody, so the button should show. */ +export function canShare(win: ShareWindow): boolean { + return SHAREABLE_PROTOCOLS.includes(new URL(win.location.href).protocol); +} + /** * This page, carrying the whole dataset in its fragment. *