Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/DRIVE_BACKUP_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ nufftabs supports optional manual backup and restore to Google Drive. This featu

## Data model
Drive folder layout:
- `nufftabs_backups/<install_id>/backup-<timestamp>-g<groupCount>.json`
- `nufftabs_backups/<install_id>/nufftabs-backup-<timestamp>-g<groupCount>.json`

Local storage keys:
- `driveInstallId`: stable install folder identifier
Expand Down
2 changes: 1 addition & 1 deletion docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ driveRetentionCount: 10
driveBackupIndex: {
"installId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"backups": [
{ "fileId": "1AbC...", "fileName": "backup-2026-02-19T20-10-45-120Z-g2.json", "timestamp": 1771531845120, "size": 1824, "tabGroupCount": 2 }
{ "fileId": "1AbC...", "fileName": "nufftabs-backup-2026-02-19T20-10-45-120Z-g2.json", "timestamp": 1771531845120, "size": 1824, "tabGroupCount": 2 }
]
}
```
Expand Down
13 changes: 6 additions & 7 deletions entrypoints/drive/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
* Drive API adapters, and options/auth UI code.
*/
import type { SavedTabGroups, Settings } from '../shared/storage';
import {
createNufftabsBackupFileName,
extractTabGroupCountFromBackupFileName,
} from '../shared/backup_filename';

/** The top-level folder name used in the user's Google Drive. */
export const DRIVE_FOLDER_NAME = 'nufftabs_backups';
Expand Down Expand Up @@ -79,20 +83,15 @@ export function normalizeRetentionCount(
* a rough tab-group count for listing without downloading each file.
*/
export function createBackupFileName(timestamp: number, tabGroupCount: number): string {
const iso = new Date(timestamp).toISOString().replace(/[:.]/g, '-');
const groupCount = Math.max(0, Math.floor(tabGroupCount));
return `backup-${iso}-g${groupCount}.json`;
return createNufftabsBackupFileName(timestamp, tabGroupCount);
}

/**
* Extracts an embedded tab-group count from backup file names produced by
* `createBackupFileName`. Returns 0 if the file name does not match.
*/
export function extractTabGroupCountFromFileName(fileName: string): number {
const match = /-g(\d+)\.json$/i.exec(fileName);
if (!match) return 0;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
return extractTabGroupCountFromBackupFileName(fileName);
}

/**
Expand Down
76 changes: 61 additions & 15 deletions entrypoints/nufftabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ import {
type SavedTabGroups,
} from '../shared/storage';
import { appendTabsByDuplicatePolicy, collectSavedTabUrls } from '../shared/duplicates';
import { logExtensionError, runWithConcurrency, type ExtensionErrorOperation } from '../shared/utils';
import {
isIgnorableExtensionError,
logExtensionError,
runWithConcurrency,
type ExtensionErrorOperation,
} from '../shared/utils';
import { createNufftabsBackupFileName } from '../shared/backup_filename';
import { createSnackbarNotifier } from '../ui/notifications';

// ── DOM element references (queried once at module load) ──
Expand Down Expand Up @@ -171,6 +177,28 @@ function runAsyncTask(
});
}

/**
* Runs an IO (import/export) task and surfaces a friendly user message if it throws.
*
* This is intentionally only used by the Import/Export controls to keep UI changes local:
* other list-page actions keep their existing (silent) error handling behavior.
*/
function runIoTask(
task: Promise<unknown>,
options: {
logContext: string;
userMessage: string;
operation?: ExtensionErrorOperation;
},
): void {
void task.catch((error) => {
const operation = options.operation ?? 'runtime_context';
if (isIgnorableExtensionError(error, operation)) return;
logExtensionError(options.logContext, error, { operation });
setStatus(options.userMessage);
});
}

/** Holds the DOM nodes and render state for a single group card. */
type GroupView = {
card: HTMLElement;
Expand Down Expand Up @@ -1422,6 +1450,7 @@ async function handleDrop(targetGroupKey: string): Promise<void> {
async function exportJson(): Promise<void> {
if (!jsonAreaEl) return;
const savedGroups = areAllIndexedGroupsLoaded() ? cloneGroups(state.currentGroups) : await readSavedGroups();
const groupCount = Object.keys(savedGroups).length;
const total = countTotalTabs(savedGroups);
jsonAreaEl.value = JSON.stringify({ savedTabs: savedGroups }, null, 2);

Expand All @@ -1440,7 +1469,7 @@ async function exportJson(): Promise<void> {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nufftabs-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
link.download = createNufftabsBackupFileName(Date.now(), groupCount);
document.body.appendChild(link);
link.click();
link.remove();
Expand All @@ -1452,7 +1481,9 @@ async function exportJson(): Promise<void> {
}

if (!copied && !downloaded) {
setStatus('Export failed: Could not copy to clipboard or download file.');
setStatus(
"Export failed. Couldn't copy to clipboard or download a file. The JSON is still in the text area—copy it manually and try again.",
);
return;
}

Expand All @@ -1469,7 +1500,7 @@ async function importJsonText(text: string, mode: 'append' | 'replace'): Promise
const fallbackKey = createCondenseGroupKey(windowId);
const normalized = normalizeImportedGroups(parsed, fallbackKey);
if (!normalized) {
setStatus('Import failed: JSON structure not recognized.');
setStatus('Import failed. JSON structure not recognized. Paste a NuffTabs export and try again.');
return;
}
const existing = mode === 'replace' ? {} : await readSavedGroups();
Expand All @@ -1480,7 +1511,7 @@ async function importJsonText(text: string, mode: 'append' | 'replace'): Promise
const importedCount = countTotalTabs(nextGroups) - countTotalTabs(existing);
const saved = await writeSavedGroups(nextGroups);
if (!saved) {
setStatus('Import failed: Could not save tabs to storage.');
setStatus("Import failed. Couldn't save tabs to extension storage.");
await refreshList();
return;
}
Expand All @@ -1489,7 +1520,7 @@ async function importJsonText(text: string, mode: 'append' | 'replace'): Promise
setStatus(`Successfully imported ${importedCount} tab${importedCount === 1 ? '' : 's'}.`);
} catch (error) {
logExtensionError('Failed to import JSON payload', error, { operation: 'runtime_context' });
setStatus('Import failed: Invalid JSON format.');
setStatus('Import failed. Invalid JSON.');
}
Comment thread
viseshrp marked this conversation as resolved.
Resolved
}

Expand All @@ -1513,7 +1544,7 @@ async function importJsonFile(file: File): Promise<void> {
await importJsonText(text, 'append');
} catch (error) {
logExtensionError('Failed to import JSON file', error, { operation: 'runtime_context' });
setStatus('Import failed: Could not read the selected file.');
setStatus("Import failed. Couldn't read the selected file.");
}
}

Expand All @@ -1539,7 +1570,7 @@ async function importOneTab(): Promise<void> {
const { tabs: imported, totalLines } = parseOneTabExport(text);
const skipped = Math.max(0, totalLines - imported.length);
if (imported.length === 0) {
setStatus('No valid OneTab links found to import.');
setStatus('OneTab import: no valid links found.');
return;
}
const windowId = await getCurrentWindowId();
Expand All @@ -1554,12 +1585,12 @@ async function importOneTab(): Promise<void> {
}
const { tabs: updatedGroup, addedCount } = mergeResult;
if (addedCount === 0) {
setStatus('No new OneTab links found to import.');
setStatus('OneTab import: no new links to add.');
return;
}
const saved = await writeSavedGroup(groupKey, updatedGroup);
if (!saved) {
setStatus('Import failed: Could not save tabs to storage.');
setStatus("Import failed. Couldn't save tabs to extension storage.");
await refreshList();
return;
}
Expand Down Expand Up @@ -1603,15 +1634,24 @@ async function init(): Promise<void> {
groupsEl?.addEventListener('click', handleGroupAction);

exportJsonEl?.addEventListener('click', () => {
runAsyncTask(exportJson(), 'Failed to export JSON');
runIoTask(exportJson(), {
logContext: 'Failed to export JSON',
userMessage: 'Export failed. Something went wrong while exporting your backup. Try again.',
});
});

importJsonEl?.addEventListener('click', () => {
runAsyncTask(importJson(), 'Failed to import JSON (append)');
runIoTask(importJson(), {
logContext: 'Failed to import JSON (append)',
userMessage: 'Import failed. Something went wrong while importing. Check the JSON and try again.',
});
});

importJsonReplaceEl?.addEventListener('click', () => {
runAsyncTask(importJsonReplace(), 'Failed to import JSON (replace)');
runIoTask(importJsonReplace(), {
logContext: 'Failed to import JSON (replace)',
userMessage: 'Import failed. Something went wrong while replacing your tabs. Check the JSON and try again.',
});
});

importFileEl?.addEventListener('click', () => {
Expand All @@ -1621,13 +1661,19 @@ async function init(): Promise<void> {
importFileInputEl?.addEventListener('change', () => {
const file = importFileInputEl.files?.[0];
if (file) {
runAsyncTask(importJsonFile(file), 'Failed to import JSON file');
runIoTask(importJsonFile(file), {
logContext: 'Failed to import JSON file',
userMessage: "Import failed. Couldn't import that file. Make sure it's a NuffTabs JSON backup and try again.",
});
importFileInputEl.value = '';
}
});

importOneTabEl?.addEventListener('click', () => {
runAsyncTask(importOneTab(), 'Failed to import OneTab export');
runIoTask(importOneTab(), {
logContext: 'Failed to import OneTab export',
userMessage: 'OneTab import failed. Something went wrong while parsing the export. Try again.',
});
});

clearJsonEl?.addEventListener('click', () => {
Expand Down
55 changes: 55 additions & 0 deletions entrypoints/shared/backup_filename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Shared backup filename helpers used by:
* - the list-page "Export" download (local JSON backup), and
* - the Google Drive manual backup upload.
*
* Why this exists:
* - Users should see one consistent naming convention everywhere.
* - The timestamp should be readable and lexicographically sortable.
* - The `-g<groupCount>` suffix helps identify the approximate backup size without opening it.
*
* Canonical format:
* `nufftabs-backup-<timestamp>-g<groupCount>.json`
*
* Example:
* `nufftabs-backup-2026-02-23T02-20-49-747Z-g22.json`
*/

/** File prefix used for all NuffTabs backup downloads/uploads. */
export const NUFFTABS_BACKUP_FILE_PREFIX = 'nufftabs-backup';

/**
* Converts an epoch-ms timestamp into the filename-friendly ISO segment used by backup files.
*
* Notes:
* - Uses UTC via `toISOString()` so backups are comparable across machines/timezones.
* - Replaces `:` and `.` with `-` so the filename is Windows-friendly and matches prior behavior.
*/
export function formatBackupTimestampSegment(timestampMs: number): string {
const safeTimestamp = Number.isFinite(timestampMs) ? timestampMs : Date.now();
return new Date(safeTimestamp).toISOString().replace(/[:.]/g, '-');
}

/**
* Creates a standardized backup filename embedding the timestamp and tab-group count.
* The returned string is safe to use as a download name and as a Drive file name.
*/
export function createNufftabsBackupFileName(timestampMs: number, tabGroupCount: number): string {
const timestampSegment = formatBackupTimestampSegment(timestampMs);
const normalizedGroupCount = Math.max(0, Math.floor(tabGroupCount));
return `${NUFFTABS_BACKUP_FILE_PREFIX}-${timestampSegment}-g${normalizedGroupCount}.json`;
}

/**
* Extracts the `-g<groupCount>` suffix from a backup filename.
*
* This intentionally does not require the canonical prefix so older/foreign names
* can still be surfaced in the UI with a best-effort group count.
*/
export function extractTabGroupCountFromBackupFileName(fileName: string): number {
const match = /-g(\d+)\.json$/i.exec(fileName);
if (!match) return 0;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
}

4 changes: 2 additions & 2 deletions tests/integration/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ describe('list page actions', () => {
await new Promise((resolve) => setTimeout(resolve, 0));

const status = document.querySelector<HTMLDivElement>('#snackbar');
expect(status?.textContent).toContain('No valid OneTab links found to import.');
expect(status?.textContent).toContain('OneTab import: no valid links found.');

const restoreGroup = document.querySelector<HTMLButtonElement>('button[data-action="restore-group"]');
restoreGroup?.click();
Expand Down Expand Up @@ -516,6 +516,6 @@ describe('list page actions', () => {

await new Promise((resolve) => setTimeout(resolve, 0));
const status = document.querySelector<HTMLDivElement>('#snackbar');
expect(status?.textContent).toContain('Import failed: Could not read the selected file.');
expect(status?.textContent).toContain("Import failed. Couldn't read the selected file.");
});
});
27 changes: 27 additions & 0 deletions tests/unit/backup_filename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
NUFFTABS_BACKUP_FILE_PREFIX,
createNufftabsBackupFileName,
extractTabGroupCountFromBackupFileName,
formatBackupTimestampSegment,
} from '../../entrypoints/shared/backup_filename';

describe('backup filename helpers', () => {
it('formats timestamps as filename-safe ISO segments', () => {
expect(formatBackupTimestampSegment(0)).toBe('1970-01-01T00-00-00-000Z');
});

it('creates canonical backup filenames with group counts', () => {
const name = createNufftabsBackupFileName(Date.parse('2026-02-23T02:20:49.747Z'), 22);
expect(name).toBe('nufftabs-backup-2026-02-23T02-20-49-747Z-g22.json');
expect(name.startsWith(`${NUFFTABS_BACKUP_FILE_PREFIX}-`)).toBe(true);
expect(extractTabGroupCountFromBackupFileName(name)).toBe(22);
});

it('returns 0 group count when missing or invalid', () => {
expect(extractTabGroupCountFromBackupFileName('nufftabs-backup-2026-02-23T02-20-49-747Z.json')).toBe(0);
expect(extractTabGroupCountFromBackupFileName('nufftabs-backup-2026-02-23T02-20-49-747Z-g0.json')).toBe(0);
expect(extractTabGroupCountFromBackupFileName('nope.json')).toBe(0);
});
});

4 changes: 2 additions & 2 deletions tests/unit/drive_backup_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('drive backup utilities', () => {
it('normalizes retention and backup filename helpers', () => {
const fileName = createBackupFileName(1700000000000, 12);

expect(fileName).toContain('backup-');
expect(fileName).toContain('nufftabs-backup-');
expect(fileName).toContain('-g12.json');
expect(extractTabGroupCountFromFileName(fileName)).toBe(12);
expect(extractTabGroupCountFromFileName('nope.json')).toBe(0);
Expand Down Expand Up @@ -264,7 +264,7 @@ describe('drive backup utilities', () => {
});

expect(uploads).toHaveLength(1);
expect(uploads[0]?.name).toContain('backup-');
expect(uploads[0]?.name).toContain('nufftabs-backup-');
expect(uploads[0]?.content).toContain('"installId": "install-1"');
expect(backups).toHaveLength(1);
expect(deleted).toEqual(['old-file']);
Expand Down