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:
Deluan Quintão
2025-05-30 21:07:08 -04:00
committed by GitHub
parent 22c3486e38
commit 6dd98e0bed
14 changed files with 1356 additions and 95 deletions
+3
View File
@@ -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>
+1 -1
View File
@@ -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}
>
+1
View File
@@ -30,6 +30,7 @@ const defaultConfig = {
enableExternalServices: true,
enableCoverAnimation: true,
devShowArtistPage: true,
devUIShowConfig: true,
enableReplayGain: true,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
+346 -78
View File
@@ -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>
)
+15
View File
@@ -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": {
+170
View File
@@ -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
}
+363
View File
@@ -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')
})
})