diff --git a/README.md b/README.md index bd109a2..8ede5c5 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,17 @@ 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.) 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, +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..731e64d 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 { canShare, shareCSV } from './share.ts'; import { cssVar } from './theme.ts'; import { type ChartOptions, getChartOptions } from './chartOptions.ts'; @@ -24,10 +25,15 @@ 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); 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); @@ -44,7 +50,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 +60,7 @@ function App() { } setDataset(result); + setCSV(text); setIsolatedSeries(null); setHoveredDate(null); } catch (err: unknown) { @@ -99,6 +107,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 +139,7 @@ function App() { onToggleSpreadDates={() => setSpreadDates(!spreadDates)} columnColors={columnColors} onFileUpload={handleFileUpload} + onShare={shareable ? handleShare : null} 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..ab7ed56 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,8 @@ interface TimeSeriesChartProps { onToggleSpreadDates: () => void; columnColors: Record; onFileUpload: (event: React.ChangeEvent) => void; + /** `null` where a link to this page would not open for anyone else. */ + onShare: (() => Promise) | null; columnStyles?: ColumnStyles; chartOptions?: ChartOptions; } @@ -44,6 +48,7 @@ export const TimeSeriesChart: React.FC = ({ onToggleSpreadDates, columnColors, onFileUpload, + onShare, columnStyles = {}, chartOptions = {}, }) => { @@ -383,6 +388,7 @@ export const TimeSeriesChart: React.FC = ({ Spread Duplicate Dates + {onShare && } 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..d5484c1 --- /dev/null +++ b/src/share.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import * as zlib from 'node:zlib'; + +import { buildShareURL, canShare, 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('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); + + 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..3055f08 --- /dev/null +++ b/src/share.ts @@ -0,0 +1,75 @@ +/** + * 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 }; +} + +/** + * 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. + * + * 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); }