feat(ui): add configuration tab in About dialog (#4142)
* Flatten config endpoint and improve About dialog * add config resource Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): replace `==` with `===` Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add environment variables Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add sensitive value redaction Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): more translations Signed-off-by: Deluan <deluan@navidrome.org> * address PR comments Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add configuration export feature in About dialog Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): translate development flags section header Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * feat(api): refactor routes for keepalive and insights endpoints Signed-off-by: Deluan <deluan@navidrome.org> * lint Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): enhance string escaping in formatTomlValue function Updated the formatTomlValue function to properly escape backslashes in addition to quotes. Added new test cases to ensure correct handling of strings containing both backslashes and quotes. Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): adjust dialog size Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -137,6 +137,9 @@ const Admin = (props) => {
|
||||
<Resource name="playlistTrack" />,
|
||||
<Resource name="keepalive" />,
|
||||
<Resource name="insights" />,
|
||||
permissions === 'admin' && config.devUIShowConfig ? (
|
||||
<Resource name="config" />
|
||||
) : null,
|
||||
<Player />,
|
||||
]}
|
||||
</RAAdmin>
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SongInfo = (props) => {
|
||||
</Tabs>
|
||||
)}
|
||||
<div
|
||||
hidden={tab == 1}
|
||||
hidden={tab === 1}
|
||||
id="mapped-tags-body"
|
||||
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,7 @@ const defaultConfig = {
|
||||
enableExternalServices: true,
|
||||
enableCoverAnimation: true,
|
||||
devShowArtistPage: true,
|
||||
devUIShowConfig: true,
|
||||
enableReplayGain: true,
|
||||
defaultDownsamplingFormat: 'opus',
|
||||
publicBaseUrl: '/share',
|
||||
|
||||
+346
-78
@@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
import Paper from '@material-ui/core/Paper'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import FileCopyIcon from '@material-ui/icons/FileCopy'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
import { useGetOne, usePermissions, useTranslate } from 'react-admin'
|
||||
import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin'
|
||||
import { Tabs, Tab } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import config from '../config'
|
||||
import { DialogTitle } from './DialogTitle'
|
||||
import { DialogContent } from './DialogContent'
|
||||
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||
import subsonic from '../subsonic/index.js'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import TableHead from '@material-ui/core/TableHead'
|
||||
import { configToToml, separateAndSortConfigs } from '../utils/toml'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
configNameColumn: {
|
||||
maxWidth: '200px',
|
||||
width: '200px',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
envVarColumn: {
|
||||
maxWidth: '200px',
|
||||
width: '200px',
|
||||
fontFamily: 'monospace',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
configFileValue: {
|
||||
maxWidth: '300px',
|
||||
width: '300px',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
copyButton: {
|
||||
marginBottom: theme.spacing(2),
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
devSectionHeader: {
|
||||
'& td': {
|
||||
paddingTop: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(2),
|
||||
borderTop: `2px solid ${theme.palette.divider}`,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
configContainer: {
|
||||
paddingTop: theme.spacing(1),
|
||||
},
|
||||
tableContainer: {
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
},
|
||||
}))
|
||||
|
||||
const links = {
|
||||
homepage: 'navidrome.org',
|
||||
@@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => {
|
||||
|
||||
const ShowVersion = ({ uiVersion, serverVersion }) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
const showRefresh = uiVersion !== serverVersion
|
||||
|
||||
return (
|
||||
@@ -90,11 +138,286 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const AboutDialog = ({ open, onClose }) => {
|
||||
const AboutTabContent = ({
|
||||
uiVersion,
|
||||
serverVersion,
|
||||
insightsData,
|
||||
loading,
|
||||
permissions,
|
||||
}) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
const lastRun = !loading && insightsData?.lastRun
|
||||
let insightsStatus = 'N/A'
|
||||
if (lastRun === 'disabled') {
|
||||
insightsStatus = translate('about.links.insights.disabled')
|
||||
} else if (lastRun && lastRun?.startsWith('1969-12-31')) {
|
||||
insightsStatus = translate('about.links.insights.waiting')
|
||||
} else if (lastRun) {
|
||||
insightsStatus = lastRun
|
||||
}
|
||||
|
||||
return (
|
||||
<Table aria-label={translate('menu.about')} size="small">
|
||||
<TableBody>
|
||||
<ShowVersion uiVersion={uiVersion} serverVersion={serverVersion} />
|
||||
{Object.keys(links).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={`https://${links[key]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{links[key]}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{permissions === 'admin' ? (
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.lastInsightsCollection`)}:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
<Link
|
||||
href={'https://github.com/sponsors/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton size={'small'}>
|
||||
<FavoriteBorderIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={'https://ko-fi.com/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ko-fi.com/deluan
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const ConfigTabContent = ({ configData }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
|
||||
if (!configData || !configData.config) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the shared separation and sorting logic
|
||||
const { regularConfigs, devConfigs } = separateAndSortConfigs(
|
||||
configData.config,
|
||||
)
|
||||
|
||||
const handleCopyToml = async () => {
|
||||
try {
|
||||
const tomlContent = configToToml(configData, translate)
|
||||
await navigator.clipboard.writeText(tomlContent)
|
||||
notify(translate('about.config.exportSuccess'), 'info')
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to copy TOML:', err)
|
||||
notify(translate('about.config.exportFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.configContainer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileCopyIcon />}
|
||||
onClick={handleCopyToml}
|
||||
className={classes.copyButton}
|
||||
disabled={!configData}
|
||||
size="small"
|
||||
>
|
||||
{translate('about.config.exportToml')}
|
||||
</Button>
|
||||
<TableContainer className={classes.tableContainer}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="col"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{translate('about.config.configName')}
|
||||
</TableCell>
|
||||
<TableCell align="left" component="th" scope="col">
|
||||
{translate('about.config.environmentVariable')}
|
||||
</TableCell>
|
||||
<TableCell align="left" component="th" scope="col">
|
||||
{translate('about.config.currentValue')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{configData?.configFile && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{translate('about.config.configurationFile')}
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.envVarColumn}>
|
||||
ND_CONFIGFILE
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.configFileValue}>
|
||||
{configData.configFile}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{regularConfigs.map(({ key, envVar, value }) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{key}
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.envVarColumn}>
|
||||
{envVar}
|
||||
</TableCell>
|
||||
<TableCell align="left">{String(value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{devConfigs.length > 0 && (
|
||||
<TableRow className={classes.devSectionHeader}>
|
||||
<TableCell colSpan={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
🚧 {translate('about.config.devFlagsHeader')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{devConfigs.map(({ key, envVar, value }) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{key}
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.envVarColumn}>
|
||||
{envVar}
|
||||
</TableCell>
|
||||
<TableCell align="left">{String(value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabContent = ({
|
||||
tab,
|
||||
setTab,
|
||||
showConfigTab,
|
||||
uiVersion,
|
||||
serverVersion,
|
||||
insightsData,
|
||||
loading,
|
||||
permissions,
|
||||
configData,
|
||||
}) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
{showConfigTab && (
|
||||
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
|
||||
<Tab
|
||||
label={translate('about.tabs.about')}
|
||||
id="about-tab"
|
||||
aria-controls="about-panel"
|
||||
/>
|
||||
<Tab
|
||||
label={translate('about.tabs.config')}
|
||||
id="config-tab"
|
||||
aria-controls="config-panel"
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
<div
|
||||
id="about-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="about-tab"
|
||||
hidden={showConfigTab && tab === 1}
|
||||
>
|
||||
<AboutTabContent
|
||||
uiVersion={uiVersion}
|
||||
serverVersion={serverVersion}
|
||||
insightsData={insightsData}
|
||||
loading={loading}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</div>
|
||||
{showConfigTab && (
|
||||
<div
|
||||
id="config-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="config-tab"
|
||||
hidden={tab === 0}
|
||||
>
|
||||
<ConfigTabContent configData={configData} />
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const AboutDialog = ({ open, onClose }) => {
|
||||
const { permissions } = usePermissions()
|
||||
const { data, loading } = useGetOne('insights', 'insights_status')
|
||||
const { data: insightsData, loading } = useGetOne(
|
||||
'insights',
|
||||
'insights_status',
|
||||
)
|
||||
const [serverVersion, setServerVersion] = useState('')
|
||||
const showConfigTab = permissions === 'admin' && config.devUIShowConfig
|
||||
const [tab, setTab] = useState(0)
|
||||
const { data: configData } = useGetOne('config', 'config', {
|
||||
enabled: showConfigTab,
|
||||
})
|
||||
const expanded = showConfigTab && tab === 1
|
||||
const uiVersion = config.version
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,85 +435,30 @@ const AboutDialog = ({ open, onClose }) => {
|
||||
})
|
||||
}, [setServerVersion])
|
||||
|
||||
const lastRun = !loading && data?.lastRun
|
||||
let insightsStatus = 'N/A'
|
||||
if (lastRun === 'disabled') {
|
||||
insightsStatus = translate('about.links.insights.disabled')
|
||||
} else if (lastRun && lastRun?.startsWith('1969-12-31')) {
|
||||
insightsStatus = translate('about.links.insights.waiting')
|
||||
} else if (lastRun) {
|
||||
insightsStatus = lastRun
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}>
|
||||
<Dialog
|
||||
onClose={onClose}
|
||||
aria-labelledby="about-dialog-title"
|
||||
open={open}
|
||||
fullWidth={true}
|
||||
maxWidth={expanded ? 'lg' : 'sm'}
|
||||
style={{ transition: 'max-width 300ms ease' }}
|
||||
>
|
||||
<DialogTitle id="about-dialog-title" onClose={onClose}>
|
||||
Navidrome Music Server
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TableContainer component={Paper}>
|
||||
<Table aria-label={translate('menu.about')} size="small">
|
||||
<TableBody>
|
||||
<ShowVersion
|
||||
uiVersion={uiVersion}
|
||||
serverVersion={serverVersion}
|
||||
/>
|
||||
{Object.keys(links).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={`https://${links[key]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{links[key]}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{permissions === 'admin' ? (
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.lastInsightsCollection`)}:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
<Link
|
||||
href={'https://github.com/sponsors/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton size={'small'}>
|
||||
<FavoriteBorderIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={'https://ko-fi.com/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ko-fi.com/deluan
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TabContent
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
showConfigTab={showConfigTab}
|
||||
uiVersion={uiVersion}
|
||||
serverVersion={serverVersion}
|
||||
insightsData={insightsData}
|
||||
loading={loading}
|
||||
permissions={permissions}
|
||||
configData={configData}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -498,6 +498,21 @@
|
||||
"disabled": "Disabled",
|
||||
"waiting": "Waiting"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "About",
|
||||
"config": "Configuration"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Config Name",
|
||||
"environmentVariable": "Environment Variable",
|
||||
"currentValue": "Current Value",
|
||||
"configurationFile": "Configuration File",
|
||||
"exportToml": "Export Configuration (TOML)",
|
||||
"exportSuccess": "Configuration exported to clipboard in TOML format",
|
||||
"exportFailed": "Failed to copy configuration",
|
||||
"devFlagsHeader": "Development Flags (subject to change/removal)",
|
||||
"devFlagsComment": "These are experimental settings and may be removed in future versions"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* TOML utility functions for configuration export
|
||||
*/
|
||||
|
||||
/**
|
||||
* Separates and sorts configuration entries into regular and dev configs
|
||||
* @param {Array} configEntries - Array of config objects with key and value
|
||||
* @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted
|
||||
*/
|
||||
export const separateAndSortConfigs = (configEntries) => {
|
||||
const regularConfigs = []
|
||||
const devConfigs = []
|
||||
|
||||
configEntries?.forEach((config) => {
|
||||
// Skip configFile as it's displayed separately
|
||||
if (config.key === 'ConfigFile') {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.key.startsWith('Dev')) {
|
||||
devConfigs.push(config)
|
||||
} else {
|
||||
regularConfigs.push(config)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort configurations alphabetically
|
||||
regularConfigs.sort((a, b) => a.key.localeCompare(b.key))
|
||||
devConfigs.sort((a, b) => a.key.localeCompare(b.key))
|
||||
|
||||
return { regularConfigs, devConfigs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value to proper TOML format
|
||||
* @param {*} value - The value to format
|
||||
* @returns {string} - The TOML-formatted value
|
||||
*/
|
||||
export const formatTomlValue = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '""'
|
||||
}
|
||||
|
||||
const str = String(value)
|
||||
|
||||
// Boolean values
|
||||
if (str === 'true' || str === 'false') {
|
||||
return str
|
||||
}
|
||||
|
||||
// Numbers (integers and floats)
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
return str // Integer
|
||||
}
|
||||
if (/^-?\d*\.\d+$/.test(str)) {
|
||||
return str // Float
|
||||
}
|
||||
|
||||
// Duration values (like "300ms", "1s", "5m")
|
||||
if (/^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)$/.test(str)) {
|
||||
return `"${str}"`
|
||||
}
|
||||
|
||||
// Arrays/JSON objects
|
||||
if (str.startsWith('[') || str.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(str)
|
||||
return `"""${str}"""`
|
||||
} catch {
|
||||
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
}
|
||||
|
||||
// String values (escape backslashes and quotes)
|
||||
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts nested keys to TOML sections
|
||||
* @param {Array} configs - Array of config objects with key and value
|
||||
* @returns {Object} - Object with sections and rootKeys
|
||||
*/
|
||||
export const buildTomlSections = (configs) => {
|
||||
const sections = {}
|
||||
const rootKeys = []
|
||||
|
||||
configs.forEach(({ key, value }) => {
|
||||
if (key.includes('.')) {
|
||||
const parts = key.split('.')
|
||||
const sectionName = parts[0]
|
||||
const keyName = parts.slice(1).join('.')
|
||||
|
||||
if (!sections[sectionName]) {
|
||||
sections[sectionName] = []
|
||||
}
|
||||
sections[sectionName].push({ key: keyName, value })
|
||||
} else {
|
||||
rootKeys.push({ key, value })
|
||||
}
|
||||
})
|
||||
|
||||
return { sections, rootKeys }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts configuration data to TOML format
|
||||
* @param {Object} configData - The configuration data object
|
||||
* @param {Function} translate - Translation function for internationalization
|
||||
* @returns {string} - The TOML-formatted configuration
|
||||
*/
|
||||
export const configToToml = (configData, translate = (key) => key) => {
|
||||
let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n`
|
||||
|
||||
const { regularConfigs, devConfigs } = separateAndSortConfigs(
|
||||
configData.config,
|
||||
)
|
||||
|
||||
// Process regular configs
|
||||
const { sections: regularSections, rootKeys: regularRootKeys } =
|
||||
buildTomlSections(regularConfigs)
|
||||
|
||||
// Add root-level keys first
|
||||
if (regularRootKeys.length > 0) {
|
||||
regularRootKeys.forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
tomlContent += '\n'
|
||||
}
|
||||
|
||||
// Add sections
|
||||
Object.keys(regularSections)
|
||||
.sort()
|
||||
.forEach((sectionName) => {
|
||||
tomlContent += `[${sectionName}]\n`
|
||||
regularSections[sectionName].forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
tomlContent += '\n'
|
||||
})
|
||||
|
||||
// Add dev configs if any
|
||||
if (devConfigs.length > 0) {
|
||||
tomlContent += `# ${translate('about.config.devFlagsHeader')}\n`
|
||||
tomlContent += `# ${translate('about.config.devFlagsComment')}\n\n`
|
||||
|
||||
const { sections: devSections, rootKeys: devRootKeys } =
|
||||
buildTomlSections(devConfigs)
|
||||
|
||||
// Add dev root-level keys
|
||||
devRootKeys.forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
if (devRootKeys.length > 0) {
|
||||
tomlContent += '\n'
|
||||
}
|
||||
|
||||
// Add dev sections
|
||||
Object.keys(devSections)
|
||||
.sort()
|
||||
.forEach((sectionName) => {
|
||||
tomlContent += `[${sectionName}]\n`
|
||||
devSections[sectionName].forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
tomlContent += '\n'
|
||||
})
|
||||
}
|
||||
|
||||
return tomlContent
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatTomlValue,
|
||||
buildTomlSections,
|
||||
configToToml,
|
||||
separateAndSortConfigs,
|
||||
} from './toml'
|
||||
|
||||
describe('formatTomlValue', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatTomlValue(null)).toBe('""')
|
||||
expect(formatTomlValue(undefined)).toBe('""')
|
||||
})
|
||||
|
||||
it('handles boolean values', () => {
|
||||
expect(formatTomlValue('true')).toBe('true')
|
||||
expect(formatTomlValue('false')).toBe('false')
|
||||
expect(formatTomlValue(true)).toBe('true')
|
||||
expect(formatTomlValue(false)).toBe('false')
|
||||
})
|
||||
|
||||
it('handles integer values', () => {
|
||||
expect(formatTomlValue('123')).toBe('123')
|
||||
expect(formatTomlValue('-456')).toBe('-456')
|
||||
expect(formatTomlValue('0')).toBe('0')
|
||||
expect(formatTomlValue(789)).toBe('789')
|
||||
})
|
||||
|
||||
it('handles float values', () => {
|
||||
expect(formatTomlValue('123.45')).toBe('123.45')
|
||||
expect(formatTomlValue('-67.89')).toBe('-67.89')
|
||||
expect(formatTomlValue('0.0')).toBe('0.0')
|
||||
expect(formatTomlValue(12.34)).toBe('12.34')
|
||||
})
|
||||
|
||||
it('handles duration values', () => {
|
||||
expect(formatTomlValue('300ms')).toBe('"300ms"')
|
||||
expect(formatTomlValue('5s')).toBe('"5s"')
|
||||
expect(formatTomlValue('10m')).toBe('"10m"')
|
||||
expect(formatTomlValue('2h')).toBe('"2h"')
|
||||
expect(formatTomlValue('1.5s')).toBe('"1.5s"')
|
||||
})
|
||||
|
||||
it('handles JSON arrays and objects', () => {
|
||||
expect(formatTomlValue('["item1", "item2"]')).toBe(
|
||||
'"""["item1", "item2"]"""',
|
||||
)
|
||||
expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""')
|
||||
})
|
||||
|
||||
it('handles invalid JSON as regular strings', () => {
|
||||
expect(formatTomlValue('[invalid json')).toBe('"[invalid json"')
|
||||
expect(formatTomlValue('{broken')).toBe('"{broken"')
|
||||
})
|
||||
|
||||
it('handles regular strings with quote escaping', () => {
|
||||
expect(formatTomlValue('simple string')).toBe('"simple string"')
|
||||
expect(formatTomlValue('string with "quotes"')).toBe(
|
||||
'"string with \\"quotes\\""',
|
||||
)
|
||||
expect(formatTomlValue('/path/to/file')).toBe('"/path/to/file"')
|
||||
})
|
||||
|
||||
it('handles strings with backslashes and quotes', () => {
|
||||
expect(formatTomlValue('C:\\Program Files\\app')).toBe(
|
||||
'"C:\\\\Program Files\\\\app"',
|
||||
)
|
||||
expect(formatTomlValue('path\\to"file')).toBe('"path\\\\to\\"file"')
|
||||
expect(formatTomlValue('backslash\\ and "quote"')).toBe(
|
||||
'"backslash\\\\ and \\"quote\\""',
|
||||
)
|
||||
expect(formatTomlValue('single\\backslash')).toBe('"single\\\\backslash"')
|
||||
})
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(formatTomlValue('')).toBe('""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTomlSections', () => {
|
||||
it('separates root keys from nested keys', () => {
|
||||
const configs = [
|
||||
{ key: 'RootKey1', value: 'value1' },
|
||||
{ key: 'Section.NestedKey', value: 'value2' },
|
||||
{ key: 'RootKey2', value: 'value3' },
|
||||
{ key: 'Section.AnotherKey', value: 'value4' },
|
||||
{ key: 'AnotherSection.Key', value: 'value5' },
|
||||
]
|
||||
|
||||
const result = buildTomlSections(configs)
|
||||
|
||||
expect(result.rootKeys).toEqual([
|
||||
{ key: 'RootKey1', value: 'value1' },
|
||||
{ key: 'RootKey2', value: 'value3' },
|
||||
])
|
||||
|
||||
expect(result.sections).toEqual({
|
||||
Section: [
|
||||
{ key: 'NestedKey', value: 'value2' },
|
||||
{ key: 'AnotherKey', value: 'value4' },
|
||||
],
|
||||
AnotherSection: [{ key: 'Key', value: 'value5' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles deeply nested keys', () => {
|
||||
const configs = [{ key: 'Section.SubSection.DeepKey', value: 'deepValue' }]
|
||||
|
||||
const result = buildTomlSections(configs)
|
||||
|
||||
expect(result.rootKeys).toEqual([])
|
||||
expect(result.sections).toEqual({
|
||||
Section: [{ key: 'SubSection.DeepKey', value: 'deepValue' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
const result = buildTomlSections([])
|
||||
|
||||
expect(result.rootKeys).toEqual([])
|
||||
expect(result.sections).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('configToToml', () => {
|
||||
const mockTranslate = (key) => {
|
||||
const translations = {
|
||||
'about.config.devFlagsHeader':
|
||||
'Development Flags (subject to change/removal)',
|
||||
'about.config.devFlagsComment':
|
||||
'These are experimental settings and may be removed in future versions',
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
|
||||
it('generates TOML with header and timestamp', () => {
|
||||
const configData = {
|
||||
config: [{ key: 'TestKey', value: 'testValue' }],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Navidrome Configuration')
|
||||
expect(result).toContain('# Generated on')
|
||||
expect(result).toContain('TestKey = "testValue"')
|
||||
})
|
||||
|
||||
it('separates and sorts regular and dev configs', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'ZRegularKey', value: 'regularValue' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
{ key: 'ARegularKey', value: 'anotherValue' },
|
||||
{ key: 'DevAnotherFlag', value: 'false' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
// Check that regular configs come first and are sorted
|
||||
const lines = result.split('\n')
|
||||
const aRegularIndex = lines.findIndex((line) =>
|
||||
line.includes('ARegularKey'),
|
||||
)
|
||||
const zRegularIndex = lines.findIndex((line) =>
|
||||
line.includes('ZRegularKey'),
|
||||
)
|
||||
const devHeaderIndex = lines.findIndex((line) =>
|
||||
line.includes('Development Flags'),
|
||||
)
|
||||
const devAnotherIndex = lines.findIndex((line) =>
|
||||
line.includes('DevAnotherFlag'),
|
||||
)
|
||||
const devTestIndex = lines.findIndex((line) => line.includes('DevTestFlag'))
|
||||
|
||||
expect(aRegularIndex).toBeLessThan(zRegularIndex)
|
||||
expect(zRegularIndex).toBeLessThan(devHeaderIndex)
|
||||
expect(devHeaderIndex).toBeLessThan(devAnotherIndex)
|
||||
expect(devAnotherIndex).toBeLessThan(devTestIndex)
|
||||
})
|
||||
|
||||
it('skips ConfigFile entries', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'ConfigFile', value: '/path/to/config.toml' },
|
||||
{ key: 'TestKey', value: 'testValue' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).not.toContain('ConfigFile =')
|
||||
expect(result).toContain('TestKey = "testValue"')
|
||||
})
|
||||
|
||||
it('handles sections correctly', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'RootKey', value: 'rootValue' },
|
||||
{ key: 'Section.NestedKey', value: 'nestedValue' },
|
||||
{ key: 'Section.AnotherKey', value: 'anotherValue' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('RootKey = "rootValue"')
|
||||
expect(result).toContain('[Section]')
|
||||
expect(result).toContain('NestedKey = "nestedValue"')
|
||||
expect(result).toContain('AnotherKey = "anotherValue"')
|
||||
})
|
||||
|
||||
it('includes dev flags header when dev configs exist', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'RegularKey', value: 'regularValue' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Development Flags (subject to change/removal)')
|
||||
expect(result).toContain(
|
||||
'# These are experimental settings and may be removed in future versions',
|
||||
)
|
||||
expect(result).toContain('DevTestFlag = true')
|
||||
})
|
||||
|
||||
it('does not include dev flags header when no dev configs exist', () => {
|
||||
const configData = {
|
||||
config: [{ key: 'RegularKey', value: 'regularValue' }],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).not.toContain('Development Flags')
|
||||
expect(result).toContain('RegularKey = "regularValue"')
|
||||
})
|
||||
|
||||
it('handles empty config data', () => {
|
||||
const configData = { config: [] }
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Navidrome Configuration')
|
||||
expect(result).not.toContain('Development Flags')
|
||||
})
|
||||
|
||||
it('handles missing config array', () => {
|
||||
const configData = {}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Navidrome Configuration')
|
||||
expect(result).not.toContain('Development Flags')
|
||||
})
|
||||
|
||||
it('works without translate function', () => {
|
||||
const configData = {
|
||||
config: [{ key: 'DevTestFlag', value: 'true' }],
|
||||
}
|
||||
|
||||
const result = configToToml(configData)
|
||||
|
||||
expect(result).toContain('# about.config.devFlagsHeader')
|
||||
expect(result).toContain('# about.config.devFlagsComment')
|
||||
expect(result).toContain('DevTestFlag = true')
|
||||
})
|
||||
|
||||
it('handles various data types correctly', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'StringValue', value: 'test string' },
|
||||
{ key: 'BooleanValue', value: 'true' },
|
||||
{ key: 'IntegerValue', value: '42' },
|
||||
{ key: 'FloatValue', value: '3.14' },
|
||||
{ key: 'DurationValue', value: '5s' },
|
||||
{ key: 'ArrayValue', value: '["item1", "item2"]' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('StringValue = "test string"')
|
||||
expect(result).toContain('BooleanValue = true')
|
||||
expect(result).toContain('IntegerValue = 42')
|
||||
expect(result).toContain('FloatValue = 3.14')
|
||||
expect(result).toContain('DurationValue = "5s"')
|
||||
expect(result).toContain('ArrayValue = """["item1", "item2"]"""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('separateAndSortConfigs', () => {
|
||||
it('separates regular and dev configs correctly', () => {
|
||||
const configs = [
|
||||
{ key: 'RegularKey1', value: 'value1' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
{ key: 'AnotherRegular', value: 'value2' },
|
||||
{ key: 'DevAnotherFlag', value: 'false' },
|
||||
]
|
||||
|
||||
const result = separateAndSortConfigs(configs)
|
||||
|
||||
expect(result.regularConfigs).toEqual([
|
||||
{ key: 'AnotherRegular', value: 'value2' },
|
||||
{ key: 'RegularKey1', value: 'value1' },
|
||||
])
|
||||
|
||||
expect(result.devConfigs).toEqual([
|
||||
{ key: 'DevAnotherFlag', value: 'false' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips ConfigFile entries', () => {
|
||||
const configs = [
|
||||
{ key: 'ConfigFile', value: '/path/to/config.toml' },
|
||||
{ key: 'RegularKey', value: 'value' },
|
||||
{ key: 'DevFlag', value: 'true' },
|
||||
]
|
||||
|
||||
const result = separateAndSortConfigs(configs)
|
||||
|
||||
expect(result.regularConfigs).toEqual([
|
||||
{ key: 'RegularKey', value: 'value' },
|
||||
])
|
||||
expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }])
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
const result = separateAndSortConfigs([])
|
||||
|
||||
expect(result.regularConfigs).toEqual([])
|
||||
expect(result.devConfigs).toEqual([])
|
||||
})
|
||||
|
||||
it('handles null/undefined input', () => {
|
||||
const result1 = separateAndSortConfigs(null)
|
||||
const result2 = separateAndSortConfigs(undefined)
|
||||
|
||||
expect(result1.regularConfigs).toEqual([])
|
||||
expect(result1.devConfigs).toEqual([])
|
||||
expect(result2.regularConfigs).toEqual([])
|
||||
expect(result2.devConfigs).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts configs alphabetically', () => {
|
||||
const configs = [
|
||||
{ key: 'ZRegular', value: 'z' },
|
||||
{ key: 'ARegular', value: 'a' },
|
||||
{ key: 'DevZ', value: 'z' },
|
||||
{ key: 'DevA', value: 'a' },
|
||||
]
|
||||
|
||||
const result = separateAndSortConfigs(configs)
|
||||
|
||||
expect(result.regularConfigs[0].key).toBe('ARegular')
|
||||
expect(result.regularConfigs[1].key).toBe('ZRegular')
|
||||
expect(result.devConfigs[0].key).toBe('DevA')
|
||||
expect(result.devConfigs[1].key).toBe('DevZ')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user