feat: Add data export/import functionality (#22)

* feat: add data export/import functionality

- Add /export and /import API endpoints with schema-independent JSON format
- Export includes: medications, dose history, settings, share links
- Uses _exportId references for medications, remapped on import
- Images exported as base64 data URLs
- Optional sensitive data inclusion (shoutrrr URLs, etc.)
- Import replaces all existing data with confirmation warning
- Add comprehensive test coverage
- Add English and German translations
- Add frontend UI in Settings page with export/import controls

* fix: correct JSX structure and TypeScript types

- Fix modal placement outside ternary expression in Settings
- Add type assertion for request.body in import route test

* docs: translate copilot-instructions to English

- Add explicit rule that English is the primary language
- Translate all German sections to English
- User may communicate in German, but all project artifacts must be English
This commit is contained in:
Daniel Volz
2026-01-16 19:59:48 +01:00
committed by GitHub
parent ed707444a2
commit ffab9ef4da
8 changed files with 1778 additions and 88 deletions
+207
View File
@@ -351,6 +351,12 @@ function AppContent() {
const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false);
// Export/Import state
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [includeSensitiveData, setIncludeSensitiveData] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<any>(null);
// Collapsed days state (manually collapsed days are persisted)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
@@ -778,6 +784,110 @@ function AppContent() {
setSendingReminderEmail(false);
}
// Export data to JSON file
async function handleExport() {
setExporting(true);
try {
const res = await fetch(`/api/export?includeSensitive=${includeSensitiveData}`, {
credentials: "include",
});
if (!res.ok) throw new Error("Export failed");
const data = await res.json();
// Create download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const dateStr = new Date().toISOString().split("T")[0];
a.href = url;
a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export error:", err);
}
setExporting(false);
}
// Handle file selection for import
function handleImportFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (!data.version || !data.exportedAt) {
alert(t('exportImport.invalidFile'));
return;
}
setPendingImportData(data);
setShowImportConfirm(true);
} catch {
alert(t('exportImport.invalidFile'));
}
};
reader.readAsText(file);
// Reset file input
e.target.value = "";
}
// Confirm and execute import
async function handleImportConfirm() {
if (!pendingImportData) return;
setImporting(true);
setShowImportConfirm(false);
try {
const res = await fetch("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(pendingImportData),
});
if (!res.ok) {
const err = await res.json();
alert(t('exportImport.importError') + ": " + (err.error || "Unknown error"));
return;
}
const result = await res.json();
alert(t('exportImport.importSuccess') + "\n" + t('exportImport.importSuccessDetails', {
medications: result.imported.medications,
doses: result.imported.doseHistory,
shares: result.imported.shareLinks,
}));
// Reload all data
loadMeds();
loadSettings();
loadTakenDoses();
} catch (err) {
console.error("Import error:", err);
alert(t('exportImport.importError'));
}
setPendingImportData(null);
setImporting(false);
}
// Helper function to load taken doses (extracted from useEffect)
async function loadTakenDoses() {
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
if (res.ok) {
const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
}
} catch {
// Silently fail
}
}
async function deleteMed(id: number) {
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm();
@@ -2299,6 +2409,70 @@ function AppContent() {
</div>
</article>
{/* Export/Import Section */}
<article className="card">
<div className="card-head">
<h2>{t('exportImport.title')}</h2>
</div>
<div className="setting-section">
<p className="hint-text" style={{marginBottom: "16px"}}>{t('exportImport.description')}</p>
{/* Export */}
<div className="setting-group" style={{marginBottom: "24px"}}>
<div className="export-controls">
<label className="checkbox-label" style={{marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px"}}>
<input
type="checkbox"
checked={includeSensitiveData}
onChange={(e) => setIncludeSensitiveData(e.target.checked)}
/>
<span>{t('exportImport.includeSensitive')}</span>
</label>
{includeSensitiveData && (
<p className="hint-text warning-text" style={{marginBottom: "12px", color: "var(--warning)", fontSize: "0.85rem"}}>
{t('exportImport.sensitiveWarning')}
</p>
)}
<button
type="button"
className="secondary"
onClick={handleExport}
disabled={exporting}
style={{marginRight: "12px"}}
>
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
</button>
</div>
</div>
{/* Import */}
<div className="setting-group">
<div className="import-controls">
<label className="secondary" style={{
cursor: "pointer",
display: "inline-block",
padding: "0.7rem 1.25rem",
borderRadius: "var(--btn-radius)",
background: "var(--bg-tertiary)",
color: "var(--text-primary)",
border: "1px solid var(--border-secondary)",
fontWeight: 600,
fontSize: "0.9rem"
}}>
{importing ? t('exportImport.importing') : t('exportImport.import')}
<input
type="file"
accept=".json,application/json"
onChange={handleImportFileSelect}
disabled={importing}
style={{display: "none"}}
/>
</label>
</div>
</div>
</div>
</article>
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
@@ -2306,6 +2480,39 @@ function AppContent() {
</div>
</form>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<div className="modal-overlay" onClick={() => setShowImportConfirm(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
<button className="modal-close" onClick={() => { setShowImportConfirm(false); setPendingImportData(null); }}>×</button>
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.confirmImport')}</h2>
<p style={{marginBottom: "12px"}}>{t('exportImport.confirmImportMessage')}</p>
<p className="warning-text" style={{marginBottom: "24px"}}>
{t('exportImport.confirmImportWarning')}
</p>
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
<button
type="button"
className="ghost"
onClick={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
>
{t('exportImport.cancelButton')}
</button>
<button
type="button"
className="danger"
onClick={handleImportConfirm}
>
{t('exportImport.confirmButton')}
</button>
</div>
</div>
</div>
)}
</section>
} />