From 5b4b186ddfc185307bf72952af6a49f3c3514570 Mon Sep 17 00:00:00 2001 From: Anton Karmanov Date: Wed, 31 Dec 2025 08:32:04 +0400 Subject: [PATCH] add import & export --- CLAUDE.md | 15 ++ src/options/App.css | 1 + src/options/App.tsx | 134 ++++++++++++- src/options/components/ImportConfirmModal.tsx | 84 +++++++++ src/types/index.ts | 9 + src/utils/exportImport.ts | 176 ++++++++++++++++++ 6 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 src/options/components/ImportConfirmModal.tsx create mode 100644 src/utils/exportImport.ts diff --git a/CLAUDE.md b/CLAUDE.md index 94503b8..231ab38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,3 +236,18 @@ To load the extension in Chrome: } } ``` + +### Package Execution + +**IMPORTANT: Do not use `npx` in this project.** + +- Always use local npm scripts defined in `package.json` (`npm run ...`) +- Never use `npx` to run packages directly + +### Workflow + +**After completing code changes, always run in this order:** + +1. `npm run lint` — check for type and linting errors +2. `npm run build` — build the project +3. `npm run test:e2e` — run tests diff --git a/src/options/App.css b/src/options/App.css index 8270411..0b1aec8 100644 --- a/src/options/App.css +++ b/src/options/App.css @@ -22,6 +22,7 @@ .controls { @apply bg-white px-[30px] py-5 rounded-lg mb-5 shadow; @apply dark:bg-[#2d2d2d]; + @apply flex items-center; } /* Buttons */ diff --git a/src/options/App.tsx b/src/options/App.tsx index fc527b4..52d4c54 100644 --- a/src/options/App.tsx +++ b/src/options/App.tsx @@ -1,12 +1,21 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Toaster } from 'react-hot-toast'; -import type { ModificationRule, Variable } from '../types'; +import type { ModificationRule, Variable, ExportedSettings } from '../types'; import { getSettings, saveSettings } from '../utils/storage'; +import { + prepareSettingsForExport, + generateExportFilename, + downloadAsJson, + parseImportedFile, + mergeSettings, + replaceSettings, +} from '../utils/exportImport'; import { RuleCard } from './components/RuleCard'; import { RuleEditor } from './components/RuleEditor'; import { VariablesManager } from './components/VariablesManager'; import { ThemeToggle } from './components/ThemeToggle'; -import { showConfirm } from '../utils/toast'; +import { ImportConfirmModal } from './components/ImportConfirmModal'; +import { showConfirm, showSuccess, showError } from '../utils/toast'; import './index.css'; function App() { @@ -14,6 +23,8 @@ function App() { const [variables, setVariables] = useState([]); const [editingRule, setEditingRule] = useState(null); const [isCreating, setIsCreating] = useState(false); + const [importData, setImportData] = useState(null); + const fileInputRef = useRef(null); const loadRules = useCallback(async () => { const settings = await getSettings(); @@ -67,6 +78,60 @@ function App() { await loadRules(); }; + const handleExport = async () => { + const settings = await getSettings(); + const exportData = prepareSettingsForExport(settings); + const filename = generateExportFilename(); + downloadAsJson(exportData, filename); + showSuccess('Settings exported successfully'); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + event.target.value = ''; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + const parsed = parseImportedFile(content); + if (parsed) { + setImportData(parsed); + } else { + showError('Invalid settings file'); + } + }; + reader.onerror = () => { + showError('Failed to read file'); + }; + reader.readAsText(file); + }; + + const handleImportMerge = async () => { + if (!importData) return; + const existing = await getSettings(); + const merged = mergeSettings(existing, importData.settings); + await saveSettings(merged); + await loadRules(); + setImportData(null); + showSuccess('Settings merged successfully'); + }; + + const handleImportReplace = async () => { + if (!importData) return; + const existing = await getSettings(); + const replaced = replaceSettings(existing, importData.settings); + await saveSettings(replaced); + await loadRules(); + setImportData(null); + showSuccess('Settings replaced successfully'); + }; + return ( <> +
@@ -105,13 +178,29 @@ function App() {

No Rules

Create your first rule to modify HTTP headers

- +
+ + + +
) : ( <> @@ -123,6 +212,22 @@ function App() { > + Create Rule +
+ + +
{rules.map((rule) => ( @@ -148,6 +253,15 @@ function App() { }} /> )} + + {importData && ( + setImportData(null)} + /> + )}
); diff --git a/src/options/components/ImportConfirmModal.tsx b/src/options/components/ImportConfirmModal.tsx new file mode 100644 index 0000000..3c7a9ee --- /dev/null +++ b/src/options/components/ImportConfirmModal.tsx @@ -0,0 +1,84 @@ +import type { ExportedSettings } from '../../types'; + +interface ImportConfirmModalProps { + importedData: ExportedSettings; + onMerge: () => void; + onReplace: () => void; + onCancel: () => void; +} + +export function ImportConfirmModal({ + importedData, + onMerge, + onReplace, + onCancel, +}: ImportConfirmModalProps) { + const { settings } = importedData; + const ruleCount = settings.rules.length; + const variableCount = settings.variables.length; + const sensitiveVarsWithEmptyValue = settings.variables.filter( + v => v.isSensitive && !v.value + ).length; + + return ( +
+
+

Import Settings

+ +
+

+ The file contains: +

+
    +
  • {ruleCount} rule{ruleCount !== 1 ? 's' : ''}
  • +
  • {variableCount} variable{variableCount !== 1 ? 's' : ''}
  • +
+
+ + {sensitiveVarsWithEmptyValue > 0 && ( +
+

+ Note: Sensitive variable values are not exported for security. + You will need to fill in values for {sensitiveVarsWithEmptyValue} + {' '}variable{sensitiveVarsWithEmptyValue !== 1 ? 's' : ''}. +

+
+ )} + +
+

+ Merge: + {' '}Add new items while keeping existing settings. +

+

+ Replace: + {' '}Remove all existing settings and use imported ones. +

+
+ +
+ + + +
+
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 6f96f33..19d3170 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,3 +47,12 @@ export interface AppSettings { variables: Variable[]; theme?: 'light' | 'dark' | 'auto'; } + +export interface ExportedSettings { + version: 1; + exportedAt: string; + settings: { + rules: ModificationRule[]; + variables: Variable[]; + }; +} diff --git a/src/utils/exportImport.ts b/src/utils/exportImport.ts new file mode 100644 index 0000000..c85ca6d --- /dev/null +++ b/src/utils/exportImport.ts @@ -0,0 +1,176 @@ +import type { AppSettings, ExportedSettings, Variable, ModificationRule } from '../types'; + +/** + * Prepare settings for export by stripping sensitive variable values + */ +export function prepareSettingsForExport(settings: AppSettings): ExportedSettings { + const exportedVariables = settings.variables.map(v => ({ + ...v, + value: v.isSensitive ? '' : v.value, + })); + + return { + version: 1, + exportedAt: new Date().toISOString(), + settings: { + rules: settings.rules, + variables: exportedVariables, + }, + }; +} + +/** + * Generate filename for export + */ +export function generateExportFilename(): string { + const date = new Date().toISOString().split('T')[0]; + return `modhead-settings-${date}.json`; +} + +/** + * Trigger file download in browser + */ +export function downloadAsJson(data: ExportedSettings, filename: string): void { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Validate imported JSON structure + */ +export function validateImportedSettings(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') { + return { valid: false, error: 'Invalid JSON structure' }; + } + + const obj = data as Record; + + if (obj.version !== undefined && obj.version !== 1) { + return { valid: false, error: `Unsupported version: ${obj.version}` }; + } + + if (!obj.settings || typeof obj.settings !== 'object') { + return { valid: false, error: 'Missing settings object' }; + } + + const settings = obj.settings as Record; + + if (!Array.isArray(settings.rules)) { + return { valid: false, error: 'Missing or invalid rules array' }; + } + + if (!Array.isArray(settings.variables)) { + return { valid: false, error: 'Missing or invalid variables array' }; + } + + for (const rule of settings.rules) { + if (!rule.id || !rule.name || !Array.isArray(rule.targetDomains) || !Array.isArray(rule.headers)) { + return { valid: false, error: 'Invalid rule structure' }; + } + } + + for (const variable of settings.variables) { + if (!variable.id || !variable.name) { + return { valid: false, error: 'Invalid variable structure' }; + } + } + + return { valid: true }; +} + +/** + * Parse imported JSON file content + */ +export function parseImportedFile(content: string): ExportedSettings | null { + try { + const parsed = JSON.parse(content); + const validation = validateImportedSettings(parsed); + if (!validation.valid) { + throw new Error(validation.error); + } + return parsed as ExportedSettings; + } catch { + return null; + } +} + +/** + * Merge imported settings with existing settings + * - Variables: add new ones, for sensitive variables with same name preserve existing value + * - Rules: add new rules, skip if rule with same ID already exists + */ +export function mergeSettings( + existing: AppSettings, + imported: ExportedSettings['settings'] +): AppSettings { + const existingVarsByName = new Map(); + existing.variables.forEach(v => existingVarsByName.set(v.name, v)); + + const mergedVariables: Variable[] = [...existing.variables]; + for (const importedVar of imported.variables) { + const existingVar = existingVarsByName.get(importedVar.name); + if (existingVar) { + const idx = mergedVariables.findIndex(v => v.id === existingVar.id); + mergedVariables[idx] = { + ...importedVar, + id: existingVar.id, + value: importedVar.isSensitive ? existingVar.value : importedVar.value, + }; + } else { + mergedVariables.push(importedVar); + } + } + + const existingRuleIds = new Set(existing.rules.map(r => r.id)); + + const mergedRules: ModificationRule[] = [...existing.rules]; + for (const importedRule of imported.rules) { + if (!existingRuleIds.has(importedRule.id)) { + mergedRules.push(importedRule); + } + } + + return { + rules: mergedRules, + variables: mergedVariables, + theme: existing.theme, + }; +} + +/** + * Replace all settings with imported settings + * - Variables: use imported variable configs but preserve values for sensitive variables with matching names + */ +export function replaceSettings( + existing: AppSettings, + imported: ExportedSettings['settings'] +): AppSettings { + const existingVarsByName = new Map(); + existing.variables.forEach(v => { + if (v.isSensitive) { + existingVarsByName.set(v.name, v.value); + } + }); + + const replacedVariables: Variable[] = imported.variables.map(importedVar => ({ + ...importedVar, + value: importedVar.isSensitive + ? (existingVarsByName.get(importedVar.name) || '') + : importedVar.value, + })); + + return { + rules: imported.rules, + variables: replacedVariables, + theme: existing.theme, + }; +}