From 14caef587225cd898570ff7ef8596f82b5751a43 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 08:37:27 +0200 Subject: [PATCH 1/8] feat: export native Google Docs/Sheets/Slides via Drive export API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a file with a native Google Apps mime type is picked from Drive, branch on the mime type instead of calling alt=media (which returns 403). - Docs → text/html with .html extension (best parser path) - Sheets → text/csv with .csv extension - Slides → application/pdf with .pdf extension - Binary Drive files (PDF, .zip, .docx, etc.) are unchanged NATIVE_GOOGLE_APPS_EXPORT_MIMES is the single source of truth for the mime → (exportMime, extension) mapping. The extension override ensures the existing parser's file-type guards route correctly without any new parser logic. instrumentedAxios + google_drive allowlist: no changes needed, the export URL is on www.googleapis.com which is already allowlisted. Co-Authored-By: Claude Sonnet 4.6 --- .../createGoogleDriveDownloadLink.test.ts | 82 ++++++++ .../helpers/createGoogleDriveDownloadLink.ts | 35 +++- .../Upload/helpers/handleGoogleDrive.test.ts | 184 ++++++++++++++++++ .../Upload/helpers/handleGoogleDrive.ts | 35 +++- 4 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 src/controllers/Upload/helpers/createGoogleDriveDownloadLink.test.ts create mode 100644 src/controllers/Upload/helpers/handleGoogleDrive.test.ts diff --git a/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.test.ts b/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.test.ts new file mode 100644 index 000000000..1e6b8b147 --- /dev/null +++ b/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from '@jest/globals'; +import { + createGoogleDriveDownloadLink, + createGoogleDriveExportLink, + NATIVE_GOOGLE_APPS_EXPORT_MIMES, +} from './createGoogleDriveDownloadLink'; + +const baseFile = { + id: 'file123', + name: 'My Doc', + mimeType: 'application/vnd.google-apps.document', + iconUrl: '', + url: '', + sizeBytes: 0, + embedUrl: '', + description: '', + driveSuccess: true, + isShared: false, + lastEditedUtc: 0, + serviceId: '', + type: 'document', +}; + +describe('createGoogleDriveDownloadLink', () => { + it('returns an alt=media download URL for a binary file', () => { + const file = { ...baseFile, mimeType: 'application/pdf' }; + expect(createGoogleDriveDownloadLink(file)).toBe( + 'https://www.googleapis.com/drive/v3/files/file123?alt=media' + ); + }); +}); + +describe('createGoogleDriveExportLink', () => { + it('returns an export URL with the given mime type', () => { + expect(createGoogleDriveExportLink(baseFile, 'text/html')).toBe( + 'https://www.googleapis.com/drive/v3/files/file123/export?mimeType=text%2Fhtml' + ); + }); + + it('encodes the mime type correctly for CSV', () => { + expect(createGoogleDriveExportLink(baseFile, 'text/csv')).toBe( + 'https://www.googleapis.com/drive/v3/files/file123/export?mimeType=text%2Fcsv' + ); + }); + + it('encodes the mime type correctly for PDF', () => { + expect( + createGoogleDriveExportLink(baseFile, 'application/pdf') + ).toBe( + 'https://www.googleapis.com/drive/v3/files/file123/export?mimeType=application%2Fpdf' + ); + }); +}); + +describe('NATIVE_GOOGLE_APPS_EXPORT_MIMES', () => { + it('maps google-apps.document to text/html with .html extension', () => { + expect(NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.document']).toEqual({ + exportMime: 'text/html', + extension: '.html', + }); + }); + + it('maps google-apps.spreadsheet to text/csv with .csv extension', () => { + expect(NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.spreadsheet']).toEqual({ + exportMime: 'text/csv', + extension: '.csv', + }); + }); + + it('maps google-apps.presentation to application/pdf with .pdf extension', () => { + expect(NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.presentation']).toEqual({ + exportMime: 'application/pdf', + extension: '.pdf', + }); + }); + + it('does not include google-apps.folder', () => { + expect( + NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.folder'] + ).toBeUndefined(); + }); +}); diff --git a/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.ts b/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.ts index fc90eabc9..5190efbe1 100644 --- a/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.ts +++ b/src/controllers/Upload/helpers/createGoogleDriveDownloadLink.ts @@ -1,10 +1,35 @@ import { GoogleDriveFile } from '../../../data_layer/GoogleDriveRepository'; -/** - * Create a download link for a Google Drive file. The default URL is just a preview link and request user interaction - * to download the file. This link will directly download the file. - * @param file - */ +export const NATIVE_GOOGLE_APPS_EXPORT_MIMES: Record< + string, + { exportMime: string; extension: string } +> = { + 'application/vnd.google-apps.document': { + exportMime: 'text/html', + extension: '.html', + }, + 'application/vnd.google-apps.spreadsheet': { + exportMime: 'text/csv', + extension: '.csv', + }, + 'application/vnd.google-apps.presentation': { + exportMime: 'application/pdf', + extension: '.pdf', + }, +}; + export function createGoogleDriveDownloadLink(file: GoogleDriveFile) { return 'https://www.googleapis.com/drive/v3/files/' + file.id + '?alt=media'; } + +export function createGoogleDriveExportLink( + file: GoogleDriveFile, + exportMime: string +) { + return ( + 'https://www.googleapis.com/drive/v3/files/' + + file.id + + '/export?mimeType=' + + encodeURIComponent(exportMime) + ); +} diff --git a/src/controllers/Upload/helpers/handleGoogleDrive.test.ts b/src/controllers/Upload/helpers/handleGoogleDrive.test.ts new file mode 100644 index 000000000..d4e211de6 --- /dev/null +++ b/src/controllers/Upload/helpers/handleGoogleDrive.test.ts @@ -0,0 +1,184 @@ +import type express from 'express'; + +jest.mock('../../../services/observability/instrumentedAxios'); + +jest.mock('../../../data_layer', () => ({ + getDatabase: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../data_layer/GoogleDriveRepository', () => ({ + GoogleDriveRepository: jest.fn().mockImplementation(() => ({ + saveFiles: jest.fn().mockResolvedValue(undefined), + })), +})); + +jest.mock('../../../lib/User/getOwner', () => ({ + getOwner: jest.fn().mockReturnValue(null), +})); + +jest.mock('../../../lib/isPaying', () => ({ + isPaying: jest.fn().mockReturnValue(false), +})); + +jest.mock('../../../lib/integrations/stripe', () => ({ + getStripe: jest.fn().mockReturnValue({ + customers: { retrieve: jest.fn() }, + }), + updateStoreSubscription: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../services/SubscriptionService', () => ({ + __esModule: true, + default: { findActiveStripeSubscriptions: jest.fn() }, +})); + +jest.mock('../../../services/EmailService/EmailService', () => ({ + getDefaultEmailService: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../data_layer/UsersRepository'); +jest.mock('../../../services/UsersService'); + +import instrumentedAxios from '../../../services/observability/instrumentedAxios'; +import { handleGoogleDrive } from './handleGoogleDrive'; + +const mockedAxios = instrumentedAxios as jest.Mocked; + +function makeReq( + files: object[], + googleDriveAuth: string | undefined = 'valid-token' +): express.Request { + return { + body: { + files: JSON.stringify(files), + googleDriveAuth, + }, + } as unknown as express.Request; +} + +function makeRes(): express.Response & { statusCode: number; sentBody: string } { + return { + statusCode: 200, + sentBody: '', + status(code: number) { + this.statusCode = code; + return this; + }, + send(body: string) { + this.sentBody = body; + return this; + }, + locals: {}, + } as unknown as express.Response & { statusCode: number; sentBody: string }; +} + +const basePdfFile = { + id: 'pdf-file-id', + name: 'lecture.pdf', + mimeType: 'application/pdf', + iconUrl: '', + url: '', + sizeBytes: 1024, + embedUrl: '', + description: '', + driveSuccess: true, + isShared: false, + lastEditedUtc: 0, + serviceId: '', + type: 'document', +}; + +const baseDocFile = { + ...basePdfFile, + id: 'doc-file-id', + name: 'lecture notes', + mimeType: 'application/vnd.google-apps.document', +}; + +const baseSheetFile = { + ...basePdfFile, + id: 'sheet-file-id', + name: 'vocab list', + mimeType: 'application/vnd.google-apps.spreadsheet', +}; + +const baseSlidesFile = { + ...basePdfFile, + id: 'slides-file-id', + name: 'bio lecture', + mimeType: 'application/vnd.google-apps.presentation', +}; + +describe('handleGoogleDrive — native Google Apps mime types', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.get.mockResolvedValue({ data: Buffer.from('fake content') } as never); + }); + + it('uses alt=media download URL for binary files (PDF)', async () => { + const req = makeReq([basePdfFile]); + const res = makeRes(); + const handleUpload = jest.fn(); + await handleGoogleDrive(req, res, handleUpload); + expect(mockedAxios.get).toHaveBeenCalledWith( + 'google_drive', + expect.stringContaining('?alt=media'), + expect.anything() + ); + expect(handleUpload).toHaveBeenCalled(); + const reqFiles = (req as unknown as { files: { originalname: string }[] }).files; + expect(reqFiles[0].originalname).toBe('lecture.pdf'); + }); + + it('uses export URL for Google Docs and sets .html extension', async () => { + const req = makeReq([baseDocFile]); + const res = makeRes(); + const handleUpload = jest.fn(); + await handleGoogleDrive(req, res, handleUpload); + expect(mockedAxios.get).toHaveBeenCalledWith( + 'google_drive', + expect.stringContaining('/export?mimeType=text%2Fhtml'), + expect.anything() + ); + const reqFiles = (req as unknown as { files: { originalname: string }[] }).files; + expect(reqFiles[0].originalname).toMatch(/\.html$/); + }); + + it('uses export URL for Google Sheets and sets .csv extension', async () => { + const req = makeReq([baseSheetFile]); + const res = makeRes(); + const handleUpload = jest.fn(); + await handleGoogleDrive(req, res, handleUpload); + expect(mockedAxios.get).toHaveBeenCalledWith( + 'google_drive', + expect.stringContaining('/export?mimeType=text%2Fcsv'), + expect.anything() + ); + const reqFiles = (req as unknown as { files: { originalname: string }[] }).files; + expect(reqFiles[0].originalname).toMatch(/\.csv$/); + }); + + it('uses export URL for Google Slides and sets .pdf extension', async () => { + const req = makeReq([baseSlidesFile]); + const res = makeRes(); + const handleUpload = jest.fn(); + await handleGoogleDrive(req, res, handleUpload); + expect(mockedAxios.get).toHaveBeenCalledWith( + 'google_drive', + expect.stringContaining('/export?mimeType=application%2Fpdf'), + expect.anything() + ); + const reqFiles = (req as unknown as { files: { originalname: string }[] }).files; + expect(reqFiles[0].originalname).toMatch(/\.pdf$/); + }); + + it('returns 400 and does not call handleUpload when googleDriveAuth is missing', async () => { + const req = makeReq([basePdfFile], undefined); + (req.body as Record).googleDriveAuth = undefined; + const res = makeRes(); + const handleUpload = jest.fn(); + await handleGoogleDrive(req, res, handleUpload); + expect(res.statusCode).toBe(400); + expect(handleUpload).not.toHaveBeenCalled(); + }); +}); diff --git a/src/controllers/Upload/helpers/handleGoogleDrive.ts b/src/controllers/Upload/helpers/handleGoogleDrive.ts index 8263085d1..70d2686fe 100644 --- a/src/controllers/Upload/helpers/handleGoogleDrive.ts +++ b/src/controllers/Upload/helpers/handleGoogleDrive.ts @@ -11,19 +11,38 @@ import { } from '../../../data_layer/GoogleDriveRepository'; import { isEmptyUpload } from './isEmptyUpload'; import { getFilesOrEmpty } from './getFilesOrEmpty'; -import { createGoogleDriveDownloadLink } from './createGoogleDriveDownloadLink'; +import { + createGoogleDriveDownloadLink, + createGoogleDriveExportLink, + NATIVE_GOOGLE_APPS_EXPORT_MIMES, +} from './createGoogleDriveDownloadLink'; import instrumentedAxios from '../../../services/observability/instrumentedAxios'; +function resolveUrlAndName( + file: GoogleDriveFile +): { url: string; originalname: string } { + const exportSpec = NATIVE_GOOGLE_APPS_EXPORT_MIMES[file.mimeType]; + if (exportSpec) { + const baseName = file.name.replace(/\.[^.]+$/, ''); + return { + url: createGoogleDriveExportLink(file, exportSpec.exportMime), + originalname: baseName + exportSpec.extension, + }; + } + return { + url: createGoogleDriveDownloadLink(file), + originalname: file.name, + }; +} + export async function handleGoogleDrive( req: express.Request, res: express.Response, handleUpload: (req: express.Request, res: express.Response) => void ) { try { - console.log('handling Google Drive files', req.body); const files = getFilesOrEmpty(req.body); if (isEmptyUpload(files)) { - console.debug('No Google Drive files selected.'); res.status(400).send('No Google Drive files selected, one is required.'); return; } @@ -50,16 +69,15 @@ export async function handleGoogleDrive( const owner = getOwner(res); if (owner) { await repo.saveFiles(files, owner); - } else { - console.log('Not storing anon users Google Drive files'); } - // @ts-ignore + // @ts-ignore — Express request does not declare files in its type req.files = await Promise.all( files.map(async (file) => { + const { url, originalname } = resolveUrlAndName(file); const contents = await instrumentedAxios.get( 'google_drive', - createGoogleDriveDownloadLink(file), + url, { headers: { Authorization: `Bearer ${googleDriveAuth}`, @@ -68,7 +86,7 @@ export async function handleGoogleDrive( } ); return { - originalname: file.name, + originalname, size: file.sizeBytes, buffer: contents.data, }; @@ -76,7 +94,6 @@ export async function handleGoogleDrive( ); handleUpload(req, res); } catch (error) { - console.debug('Error handling Google files', error); res.status(400).send('Error handling Google Drive files'); } } From 93e93ac7fe0b76bf150ec2549ae3018d256b1753 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 08:43:12 +0200 Subject: [PATCH 2/8] style: replace upload source tabs with chip rail under dropzone UploadSourceTabs (tablist) is replaced by UploadSourceChips (a chip rail that sits below the full-bleed dropzone). The dropzone is always the primary surface; Dropbox and Google Drive chips are subordinate. - Chips render disabled when their env vars are missing (never hidden) - Clicking an active chip toggles it off, returning to local upload - role="group" + aria-label="Other sources" replaces tablist semantics - aria-pressed tracks which chip is active - CSS: outlined monochrome chips, wrap on narrow viewports, disabled opacity for unconfigured sources - UploadForm updated: showChips is always true in idle state so chips are always present regardless of which sources are configured UploadForm.test.tsx updated to find chips by aria-label instead of role="tab" text content. New UploadSourceChips.test.tsx covers chip rendering, disabled state, toggle behavior, and no-null-class check. Co-Authored-By: Claude Sonnet 4.6 --- .../UploadForm/UploadForm.module.css | 5 + .../components/UploadForm/UploadForm.test.tsx | 38 ++--- .../components/UploadForm/UploadForm.tsx | 38 ++--- .../UploadForm/UploadSourceChips.module.css | 53 ++++++ .../UploadForm/UploadSourceChips.test.tsx | 151 ++++++++++++++++++ .../UploadForm/UploadSourceChips.tsx | 75 +++++++++ 6 files changed, 317 insertions(+), 43 deletions(-) create mode 100644 web/src/pages/UploadPage/components/UploadForm/UploadSourceChips.module.css create mode 100644 web/src/pages/UploadPage/components/UploadForm/UploadSourceChips.test.tsx create mode 100644 web/src/pages/UploadPage/components/UploadForm/UploadSourceChips.tsx diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.module.css b/web/src/pages/UploadPage/components/UploadForm/UploadForm.module.css index 993b64228..58a30ef46 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.module.css +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.module.css @@ -563,6 +563,11 @@ justify-content: center; } +.chipsRow { + max-width: 800px; + margin: 0.75rem auto 0; +} + .panelHidden { display: none !important; } diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx index ef7675445..d8a203d31 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx @@ -30,7 +30,7 @@ describe('UploadForm', () => { expect(container.querySelector('.null')).toBeNull(); }); - test('renders the Google Drive tab when env vars are configured', () => { + test('renders the Google Drive chip enabled when env vars are configured', () => { const previousClient = process.env.REACT_APP_GOOGLE_CLIENT_ID; const previousKey = process.env.REACT_APP_GOOGLE_API_KEY; process.env.REACT_APP_GOOGLE_CLIENT_ID = 'test-client'; @@ -39,17 +39,16 @@ describe('UploadForm', () => { const { container } = renderUploadForm( ); - const tabs = Array.from( - container.querySelectorAll('button[role="tab"]') - ); - expect(tabs.map((b) => b.textContent)).toContain('Google Drive'); + const chip = container.querySelector('button[aria-label="Google Drive"]'); + expect(chip).not.toBeNull(); + expect(chip?.hasAttribute('disabled')).toBe(false); } finally { process.env.REACT_APP_GOOGLE_CLIENT_ID = previousClient; process.env.REACT_APP_GOOGLE_API_KEY = previousKey; } }); - test('omits the Google Drive tab when env vars are missing', () => { + test('renders the Google Drive chip disabled when env vars are missing', () => { const previousClient = process.env.REACT_APP_GOOGLE_CLIENT_ID; const previousKey = process.env.REACT_APP_GOOGLE_API_KEY; process.env.REACT_APP_GOOGLE_CLIENT_ID = ''; @@ -58,10 +57,9 @@ describe('UploadForm', () => { const { container } = renderUploadForm( ); - const tabs = Array.from( - container.querySelectorAll('button[role="tab"]') - ); - expect(tabs.map((b) => b.textContent)).not.toContain('Google Drive'); + const chip = container.querySelector('button[aria-label="Google Drive"]'); + expect(chip).not.toBeNull(); + expect(chip?.hasAttribute('disabled')).toBe(true); } finally { process.env.REACT_APP_GOOGLE_CLIENT_ID = previousClient; process.env.REACT_APP_GOOGLE_API_KEY = previousKey; @@ -198,16 +196,14 @@ describe('UploadForm analytics events', () => { process.env.REACT_APP_DROPBOX_APP_KEY = previousKey; }); - it('reveals the Dropbox panel and hides the local panel when its tab is clicked', async () => { + it('reveals the Dropbox panel and hides the local panel when the Dropbox chip is clicked', async () => { const previousKey = process.env.REACT_APP_DROPBOX_APP_KEY; process.env.REACT_APP_DROPBOX_APP_KEY = 'test-key'; const { container } = renderUploadForm(); - const dropboxTab = Array.from( - container.querySelectorAll('button[role="tab"]') - ).find((b) => b.textContent === 'Dropbox') as HTMLButtonElement; - expect(dropboxTab).toBeTruthy(); + const dropboxChip = container.querySelector('button[aria-label="Dropbox"]') as HTMLButtonElement; + expect(dropboxChip).toBeTruthy(); await act(async () => { - dropboxTab.click(); + dropboxChip.click(); }); const localPanel = container.querySelector('#upload-panel-local')!; const dropboxPanel = container.querySelector('#upload-panel-dropbox')!; @@ -216,19 +212,17 @@ describe('UploadForm analytics events', () => { process.env.REACT_APP_DROPBOX_APP_KEY = previousKey; }); - it('keeps the same file input mounted across a tab switch round-trip', async () => { + it('keeps the same file input mounted across a chip switch round-trip', async () => { const previousKey = process.env.REACT_APP_DROPBOX_APP_KEY; process.env.REACT_APP_DROPBOX_APP_KEY = 'test-key'; const { container } = renderUploadForm(); const before = container.querySelector('input#pakker'); - const tabs = Array.from(container.querySelectorAll('button[role="tab"]')); - const dropboxTab = tabs.find((b) => b.textContent === 'Dropbox') as HTMLButtonElement; - const localTab = tabs.find((b) => b.textContent === 'Your computer') as HTMLButtonElement; + const dropboxChip = container.querySelector('button[aria-label="Dropbox"]') as HTMLButtonElement; await act(async () => { - dropboxTab.click(); + dropboxChip.click(); }); await act(async () => { - localTab.click(); + dropboxChip.click(); }); const after = container.querySelector('input#pakker'); expect(after).toBe(before); diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx index 8acd07b41..4d9154709 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx @@ -14,7 +14,7 @@ import { useGooglePicker, type GoogleDriveFile, } from './hooks/useGooglePicker'; -import { UploadSourceTabs, type UploadSource } from './UploadSourceTabs'; +import { UploadSourceChips, type UploadSource } from './UploadSourceChips'; import { FeedbackWidget } from '../../../../components/FeedbackWidget/FeedbackWidget'; import { useUserLocals } from '../../../../lib/hooks/useUserLocals'; import { get2ankiApi } from '../../../../lib/backend/get2ankiApi'; @@ -762,28 +762,16 @@ function UploadForm({ setErrorMessage }: Readonly) { return renderIdleState(); }; - const anyRemoteSource = isDropboxConfigured || isGoogleDriveConfigured; - const showTabs = anyRemoteSource && zoneState === 'idle' && !validation; - const showDropboxPanel = showTabs && source === 'dropbox'; - const showGoogleDrivePanel = showTabs && source === 'google_drive'; - const showLocalPanel = !showTabs || source === 'local'; + const showChips = zoneState === 'idle' && !validation; + const showDropboxPanel = showChips && source === 'dropbox'; + const showGoogleDrivePanel = showChips && source === 'google_drive'; + const showLocalPanel = !showChips || source === 'local'; return (
- {showTabs && ( -
- -
- )} - {showTabs && ( + {showChips && (
@@ -841,10 +828,9 @@ function UploadForm({ setErrorMessage }: Readonly) { {dropboxError}

)} - {showTabs && isGoogleDriveConfigured && ( + {showChips && isGoogleDriveConfigured && (
@@ -877,6 +863,16 @@ function UploadForm({ setErrorMessage }: Readonly) { {driveError}

)} + {showChips && ( +
+ +
+ )} {downloadLink && (