Skip to content
Open
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
22 changes: 22 additions & 0 deletions public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,29 @@
"developer": {
"title": "Developer Settings",
"description": "View technical information about authentication tokens, SSE connections, and debug data."
},
"importBetidy": {
"title": "Import from BeTidy",
"description": "Migrate your tasks from the BeTidy app"
}
}
},
"betidyImport": {
"title": "Import from BeTidy",
"description": "Upload a data bundle exported from the BeTidy app with the community tool",
"fileLabel": "Export bundle (betidy_export.json)",
"chooseFile": "Choose file",
"taskCount": "{{count}} tasks",
"timezone": "Timezone",
"includeInactive": "Also import inactive / finished tasks",
"import": "Import",
"invalidFile": "Invalid file",
"invalidFileHint": "Please select a valid betidy_export.json file.",
"successTitle": "Import complete",
"successMessage": "Imported {{count}} chores.",
"errorTitle": "Import failed",
"resultTitle": "Import complete",
"resultSummary": "Imported {{imported}}, skipped {{skipped}}, labels created {{labels}}.",
"errorsSuffix": "task(s) could not be imported (see server logs)."
}
}
5 changes: 5 additions & 0 deletions src/contexts/RouterContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import Error from '@/views/Error'
import AccountSettings from '@/views/Settings/AccountSettings'
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
import BeTidyImportSettings from '@/views/Settings/BeTidyImportSettings'
import ChildUserSettings from '@/views/Settings/ChildUserSettings'
import CircleSettings from '@/views/Settings/CircleSettings'
import DeveloperSettings from '@/views/Settings/DeveloperSettings'
Expand Down Expand Up @@ -128,6 +129,10 @@ const Router = createBrowserRouter([
path: 'developer',
element: <DeveloperSettings />,
},
{
path: 'import-betidy',
element: <BeTidyImportSettings />,
},
],
},
{
Expand Down
10 changes: 10 additions & 0 deletions src/utils/Fetcher.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -975,8 +975,18 @@ const TrackFilterUsage = id => {
})
}

// Import a BeTidy export bundle (see github.com/mschabhuettl/betidy-export).
const ImportBeTidy = bundle => {
return Fetch(`/chores/import/betidy`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(bundle),
})
}

export {
AcceptCircleMemberRequest,
ImportBeTidy,
DeleteChoreAttachment,
DeleteDraftAttachment,
GetChoreAttachments,
Expand Down
202 changes: 202 additions & 0 deletions src/views/Settings/BeTidyImportSettings.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { CloudUpload } from '@mui/icons-material'
import {
Alert,
Box,
Button,
Card,
Checkbox,
Chip,
Divider,
FormControl,
FormLabel,
Input,
Link,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNotification } from '../../service/NotificationProvider'
import { ImportBeTidy } from '../../utils/Fetcher'
import SettingsLayout from './SettingsLayout'

const guessTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}

const BeTidyImportSettings = () => {
const { t } = useTranslation('settings')
const { showNotification } = useNotification()

const [bundle, setBundle] = useState(null)
const [fileName, setFileName] = useState('')
const [taskCount, setTaskCount] = useState(0)
const [timezone, setTimezone] = useState(guessTimezone())
const [includeInactive, setIncludeInactive] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)

const handleFile = event => {
const file = event.target.files?.[0]
if (!file) return
setResult(null)
const reader = new FileReader()
reader.onload = e => {
try {
const parsed = JSON.parse(e.target.result)
if (!parsed || !Array.isArray(parsed.tasks)) {
throw new Error('missing tasks')
}
setBundle(parsed)
setFileName(file.name)
setTaskCount(parsed.tasks.length)
} catch {
setBundle(null)
setFileName('')
setTaskCount(0)
showNotification({
type: 'error',
title: t('betidyImport.invalidFile'),
message: t('betidyImport.invalidFileHint'),
})
}
}
reader.readAsText(file)
}

const handleImport = async () => {
if (!bundle) return
setLoading(true)
setResult(null)
try {
const response = await ImportBeTidy({
...bundle,
timezone,
includeInactive,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
const res = data.res || data
setResult(res)
showNotification({
type: 'success',
title: t('betidyImport.successTitle'),
message: t('betidyImport.successMessage', { count: res.imported }),
})
} catch (error) {
showNotification({
type: 'error',
title: t('betidyImport.errorTitle'),
message: String(error?.message || error),
})
} finally {
setLoading(false)
}
}

return (
<SettingsLayout title={t('betidyImport.title')}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('betidyImport.description')}{' '}
<Link
href='https://github.com/mschabhuettl/betidy-export'
target='_blank'
rel='noreferrer'
>
betidy-export
</Link>
.
</Typography>

<Card variant='outlined' sx={{ gap: 2 }}>
<FormControl>
<FormLabel>{t('betidyImport.fileLabel')}</FormLabel>
<Button
component='label'
variant='outlined'
color='neutral'
startDecorator={<CloudUpload />}
sx={{ alignSelf: 'flex-start' }}
>
{t('betidyImport.chooseFile')}
<input
type='file'
accept='application/json,.json'
hidden
onChange={handleFile}
/>
</Button>
{fileName && (
<Typography level='body-sm' sx={{ mt: 1 }}>
{fileName}{' '}
<Chip size='sm' color='primary' variant='soft'>
{t('betidyImport.taskCount', { count: taskCount })}
</Chip>
</Typography>
)}
</FormControl>

<Divider />

<FormControl>
<FormLabel>{t('betidyImport.timezone')}</FormLabel>
<Input
value={timezone}
onChange={e => setTimezone(e.target.value)}
placeholder='UTC'
sx={{ maxWidth: 320 }}
/>
</FormControl>

<Checkbox
label={t('betidyImport.includeInactive')}
checked={includeInactive}
onChange={e => setIncludeInactive(e.target.checked)}
/>

<Button
onClick={handleImport}
loading={loading}
disabled={!bundle || loading}
sx={{ alignSelf: 'flex-start' }}
>
{t('betidyImport.import')}
</Button>
</Card>

{result && (
<Alert color='success' variant='soft'>
<Box>
<Typography level='title-sm'>
{t('betidyImport.resultTitle')}
</Typography>
<Typography level='body-sm'>
{t('betidyImport.resultSummary', {
imported: result.imported,
skipped: result.skipped,
labels: result.labelsCreated,
})}
</Typography>
{Array.isArray(result.errors) && result.errors.length > 0 && (
<Typography
level='body-xs'
sx={{ mt: 1, color: 'warning.plainColor' }}
>
{result.errors.length} {t('betidyImport.errorsSuffix')}
</Typography>
)}
</Box>
</Alert>
)}
</Box>
</SettingsLayout>
)
}

export default BeTidyImportSettings
7 changes: 7 additions & 0 deletions src/views/Settings/SettingsOverview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ChevronRight,
Circle,
Code,
Download,
FamilyRestroom,
Language,
Notifications,
Expand Down Expand Up @@ -122,6 +123,12 @@ const SettingsOverview = () => {
description: t('overview.sections.developer.description'),
icon: <Code />,
},
{
id: 'import-betidy',
title: t('overview.sections.importBetidy.title'),
description: t('overview.sections.importBetidy.description'),
icon: <Download />,
},
]

const handleCardClick = settingId => {
Expand Down