diff --git a/.gitignore b/.gitignore index cf61a3a3d..615890856 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,10 @@ test/plugin-test-config/plugin-config-data/ test/plugin-test-config/ssl-cert.pem test/plugin-test-config/ssl-key.pem test/plugin-test-config/baseDeltas.json +test/plugin-test-config/unitpreferences/ test/server-test-config/applicationData/ +test/server-test-config/unitpreferences/ test/server-test-config/ssl-cert.pem test/server-test-config/ssl-key.pem test/server-test-config/plugin-config-data/ diff --git a/src/config/config.ts b/src/config/config.ts index cd8294085..5e4f605de 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -159,8 +159,8 @@ export function load(app: ConfigApp) { // Load unit preferences try { - loadUnitPreferences() setApplicationDataPath(app.config.configPath) + loadUnitPreferences() debug('Unit preferences loaded') } catch (err) { console.error('Failed to load unit preferences:', err) diff --git a/src/interfaces/unitpreferences-api.js b/src/interfaces/unitpreferences-api.js index 382d6cec9..10c0836c6 100644 --- a/src/interfaces/unitpreferences-api.js +++ b/src/interfaces/unitpreferences-api.js @@ -124,26 +124,6 @@ function validatePreset(preset, definitions) { return null } -/** - * Check if a preset name already exists - * @param {string} presetName - The preset filename (without .json) - * @returns {object|null} - { type: 'builtin'|'custom' } if exists, null if not - */ -function checkPresetExists(presetName) { - const builtInPath = path.join(UNITPREFS_DIR, 'presets', `${presetName}.json`) - if (fs.existsSync(builtInPath)) { - return { type: 'builtin' } - } - const customPath = path.join( - UNITPREFS_DIR, - 'presets/custom', - `${presetName}.json` - ) - if (fs.existsSync(customPath)) { - return { type: 'custom' } - } - return null -} const { getConfig, getCategories, @@ -156,9 +136,31 @@ const { getDefaultCategory } = require('../unitpreferences') -const UNITPREFS_DIR = path.join(__dirname, '../../unitpreferences') +const PACKAGE_UNITPREFS_DIR = path.join(__dirname, '../../unitpreferences') module.exports = function (app) { + const configUnitprefsDir = path.join(app.config.configPath, 'unitpreferences') + + function checkPresetExists(presetName) { + const builtInPath = path.join( + PACKAGE_UNITPREFS_DIR, + 'presets', + `${presetName}.json` + ) + if (fs.existsSync(builtInPath)) { + return { type: 'builtin' } + } + const customPath = path.join( + configUnitprefsDir, + 'presets/custom', + `${presetName}.json` + ) + if (fs.existsSync(customPath)) { + return { type: 'custom' } + } + return null + } + const router = express.Router() // GET /signalk/v1/unitpreferences/config @@ -175,7 +177,7 @@ module.exports = function (app) { // PUT /signalk/v1/unitpreferences/config router.put('/config', (req, res) => { try { - const configPath = path.join(UNITPREFS_DIR, 'config.json') + const configPath = path.join(configUnitprefsDir, 'config.json') fs.writeFileSync(configPath, JSON.stringify(req.body, null, 2)) reloadPreset() app.emit('unitpreferencesChanged', { type: 'global' }) @@ -212,7 +214,7 @@ module.exports = function (app) { router.get('/custom-definitions', (req, res) => { try { const customPath = path.join( - UNITPREFS_DIR, + configUnitprefsDir, 'custom-units-definitions.json' ) if (fs.existsSync(customPath)) { @@ -238,7 +240,7 @@ module.exports = function (app) { } const customPath = path.join( - UNITPREFS_DIR, + configUnitprefsDir, 'custom-units-definitions.json' ) fs.writeFileSync(customPath, JSON.stringify(req.body, null, 2)) @@ -265,7 +267,7 @@ module.exports = function (app) { // PUT /signalk/v1/unitpreferences/custom-categories router.put('/custom-categories', (req, res) => { try { - const customPath = path.join(UNITPREFS_DIR, 'custom-categories.json') + const customPath = path.join(configUnitprefsDir, 'custom-categories.json') fs.writeFileSync(customPath, JSON.stringify(req.body, null, 2)) reloadCustomCategories() app.emit('unitpreferencesChanged', { type: 'global' }) @@ -279,13 +281,13 @@ module.exports = function (app) { // GET /signalk/v1/unitpreferences/presets router.get('/presets', (req, res) => { try { - const presetsDir = path.join(UNITPREFS_DIR, 'presets') - const customDir = path.join(presetsDir, 'custom') + const presetsDir = path.join(PACKAGE_UNITPREFS_DIR, 'presets') + const customDir = path.join(configUnitprefsDir, 'presets', 'custom') const builtIn = [] const custom = [] - // List built-in presets + // List built-in presets from package dir const builtInFiles = fs.readdirSync(presetsDir) for (const file of builtInFiles) { if (file.endsWith('.json')) { @@ -299,7 +301,7 @@ module.exports = function (app) { } } - // List custom presets + // List custom presets from config dir if (fs.existsSync(customDir)) { const customFiles = fs.readdirSync(customDir) for (const file of customFiles) { @@ -332,9 +334,9 @@ module.exports = function (app) { return } - // Check custom first + // Check custom presets in config dir first const customPath = path.join( - UNITPREFS_DIR, + configUnitprefsDir, 'presets/custom', `${presetName}.json` ) @@ -344,9 +346,9 @@ module.exports = function (app) { return } - // Fall back to built-in + // Fall back to built-in in package dir const builtInPath = path.join( - UNITPREFS_DIR, + PACKAGE_UNITPREFS_DIR, 'presets', `${presetName}.json` ) @@ -388,7 +390,7 @@ module.exports = function (app) { return } - const customDir = path.join(UNITPREFS_DIR, 'presets/custom') + const customDir = path.join(configUnitprefsDir, 'presets/custom') if (!fs.existsSync(customDir)) { fs.mkdirSync(customDir, { recursive: true }) } @@ -413,7 +415,7 @@ module.exports = function (app) { } const presetPath = path.join( - UNITPREFS_DIR, + configUnitprefsDir, 'presets/custom', `${presetName}.json` ) @@ -435,7 +437,24 @@ module.exports = function (app) { router.get('/active', (req, res) => { try { const preset = getActivePreset() - res.json(preset) + const definitions = getMergedDefinitions() + const result = { + ...preset, + categories: { ...preset.categories } + } + for (const [category, catDef] of Object.entries(result.categories)) { + const unitDef = definitions[catDef.baseUnit] + const conversion = unitDef?.conversions?.[catDef.targetUnit] + if (conversion) { + result.categories[category] = { + ...catDef, + formula: conversion.formula, + inverseFormula: conversion.inverseFormula, + symbol: conversion.symbol + } + } + } + res.json(result) } catch (err) { debug('Error getting active preset:', err) res.status(500).json({ error: 'Failed to get active preset' }) @@ -443,10 +462,12 @@ module.exports = function (app) { }) // GET /signalk/v1/unitpreferences/default-categories - // Returns the full default-categories.json data router.get('/default-categories', (req, res) => { try { - const defaultCatPath = path.join(UNITPREFS_DIR, 'default-categories.json') + const defaultCatPath = path.join( + PACKAGE_UNITPREFS_DIR, + 'default-categories.json' + ) if (fs.existsSync(defaultCatPath)) { const data = JSON.parse(fs.readFileSync(defaultCatPath, 'utf-8')) res.json(data) @@ -460,7 +481,6 @@ module.exports = function (app) { }) // GET /signalk/v1/unitpreferences/default-category/:path - // Returns the default category for a specific SignalK path router.get('/default-category/*', (req, res) => { try { const signalkPath = req.params[0] @@ -473,7 +493,6 @@ module.exports = function (app) { }) // POST /signalk/v1/unitpreferences/presets/custom/upload - // Upload a custom preset file (admin only) const MAX_PRESET_SIZE = 100 * 1024 // 100KB router.post('/presets/custom/upload', (req, res) => { @@ -569,7 +588,7 @@ module.exports = function (app) { } // Ensure custom directory exists - const customDir = path.join(UNITPREFS_DIR, 'presets/custom') + const customDir = path.join(configUnitprefsDir, 'presets/custom') if (!fs.existsSync(customDir)) { fs.mkdirSync(customDir, { recursive: true }) } diff --git a/src/unitpreferences/index.ts b/src/unitpreferences/index.ts index 7be8133de..797337903 100644 --- a/src/unitpreferences/index.ts +++ b/src/unitpreferences/index.ts @@ -10,6 +10,7 @@ export { getActivePreset, getActivePresetForUser, getDefaultCategory, + getConfigUnitprefsDir, setApplicationDataPath, DEFAULT_PRESET } from './loader' diff --git a/src/unitpreferences/loader.ts b/src/unitpreferences/loader.ts index e06ad0db7..07c9c60b1 100644 --- a/src/unitpreferences/loader.ts +++ b/src/unitpreferences/loader.ts @@ -7,7 +7,7 @@ import { UnitPreferencesConfig } from './types' -const UNITPREFS_DIR = path.join(__dirname, '../../unitpreferences') +const PACKAGE_UNITPREFS_DIR = path.join(__dirname, '../../unitpreferences') export const DEFAULT_PRESET = 'nautical-metric' let categories: CategoryMap @@ -18,51 +18,109 @@ let activePreset: Preset let config: UnitPreferencesConfig let defaultCategories: { [path: string]: string } = {} let applicationDataPath: string = '' +let configUnitprefsDir: string = '' export function setApplicationDataPath(configPath: string): void { applicationDataPath = path.join(configPath, 'applicationData') + configUnitprefsDir = path.join(configPath, 'unitpreferences') + ensureConfigDir() +} + +export function getConfigUnitprefsDir(): string { + return configUnitprefsDir +} + +function ensureConfigDir(): void { + if (!configUnitprefsDir) return + + // Create directory structure + fs.mkdirSync(path.join(configUnitprefsDir, 'presets', 'custom'), { + recursive: true + }) + + // Seed mutable files from package dir if not present in config dir + const mutableFiles = [ + 'config.json', + 'custom-units-definitions.json', + 'custom-categories.json' + ] + for (const file of mutableFiles) { + const destPath = path.join(configUnitprefsDir, file) + if (!fs.existsSync(destPath)) { + const srcPath = path.join(PACKAGE_UNITPREFS_DIR, file) + if (fs.existsSync(srcPath)) { + fs.copyFileSync(srcPath, destPath) + } + } + } + + // Migrate custom presets from package dir if any exist + const pkgCustomDir = path.join(PACKAGE_UNITPREFS_DIR, 'presets', 'custom') + if (fs.existsSync(pkgCustomDir)) { + const configCustomDir = path.join(configUnitprefsDir, 'presets', 'custom') + for (const file of fs.readdirSync(pkgCustomDir)) { + if (file.endsWith('.json')) { + const destPath = path.join(configCustomDir, file) + if (!fs.existsSync(destPath)) { + fs.copyFileSync(path.join(pkgCustomDir, file), destPath) + } + } + } + } } export function loadAll(): void { - // Load categories + // Load categories (read-only, from package) categories = JSON.parse( - fs.readFileSync(path.join(UNITPREFS_DIR, 'categories.json'), 'utf-8') + fs.readFileSync( + path.join(PACKAGE_UNITPREFS_DIR, 'categories.json'), + 'utf-8' + ) ) - // Load standard definitions + // Load standard definitions (read-only, from package) standardDefinitions = JSON.parse( fs.readFileSync( - path.join(UNITPREFS_DIR, 'standard-units-definitions.json'), + path.join(PACKAGE_UNITPREFS_DIR, 'standard-units-definitions.json'), 'utf-8' ) ) - // Load custom definitions (if exists) - const customPath = path.join(UNITPREFS_DIR, 'custom-units-definitions.json') + // Load custom definitions from config dir + const customPath = configUnitprefsDir + ? path.join(configUnitprefsDir, 'custom-units-definitions.json') + : path.join(PACKAGE_UNITPREFS_DIR, 'custom-units-definitions.json') if (fs.existsSync(customPath)) { customDefinitions = JSON.parse(fs.readFileSync(customPath, 'utf-8')) } else { customDefinitions = {} } - // Load custom categories (if exists) - const customCatPath = path.join(UNITPREFS_DIR, 'custom-categories.json') + // Load custom categories from config dir + const customCatPath = configUnitprefsDir + ? path.join(configUnitprefsDir, 'custom-categories.json') + : path.join(PACKAGE_UNITPREFS_DIR, 'custom-categories.json') if (fs.existsSync(customCatPath)) { customCategories = JSON.parse(fs.readFileSync(customCatPath, 'utf-8')) } else { customCategories = {} } - // Load config - const configPath = path.join(UNITPREFS_DIR, 'config.json') - if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + // Load config from config dir + const cfgPath = configUnitprefsDir + ? path.join(configUnitprefsDir, 'config.json') + : path.join(PACKAGE_UNITPREFS_DIR, 'config.json') + if (fs.existsSync(cfgPath)) { + config = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } else { config = { activePreset: DEFAULT_PRESET } } - // Load default categories - const defaultCatPath = path.join(UNITPREFS_DIR, 'default-categories.json') + // Load default categories (read-only, from package) + const defaultCatPath = path.join( + PACKAGE_UNITPREFS_DIR, + 'default-categories.json' + ) if (fs.existsSync(defaultCatPath)) { const defaultCatData = JSON.parse(fs.readFileSync(defaultCatPath, 'utf-8')) // Build flat lookup: path -> category @@ -84,28 +142,34 @@ export function loadAll(): void { function loadActivePreset(): void { const presetName = config.activePreset - // Check custom presets first - const customPresetPath = path.join( - UNITPREFS_DIR, - 'presets/custom', - `${presetName}.json` - ) - if (fs.existsSync(customPresetPath)) { - activePreset = JSON.parse(fs.readFileSync(customPresetPath, 'utf-8')) - return + // Check custom presets in config dir first + if (configUnitprefsDir) { + const customPresetPath = path.join( + configUnitprefsDir, + 'presets/custom', + `${presetName}.json` + ) + if (fs.existsSync(customPresetPath)) { + activePreset = JSON.parse(fs.readFileSync(customPresetPath, 'utf-8')) + return + } } - // Fall back to built-in presets - const builtInPath = path.join(UNITPREFS_DIR, 'presets', `${presetName}.json`) + // Fall back to built-in presets in package dir + const builtInPath = path.join( + PACKAGE_UNITPREFS_DIR, + 'presets', + `${presetName}.json` + ) if (fs.existsSync(builtInPath)) { activePreset = JSON.parse(fs.readFileSync(builtInPath, 'utf-8')) return } - // Default to nautical + // Default to nautical-metric activePreset = JSON.parse( fs.readFileSync( - path.join(UNITPREFS_DIR, `presets/${DEFAULT_PRESET}.json`), + path.join(PACKAGE_UNITPREFS_DIR, `presets/${DEFAULT_PRESET}.json`), 'utf-8' ) ) @@ -137,16 +201,20 @@ export function getConfig(): UnitPreferencesConfig { } export function reloadPreset(): void { - // Re-read config from file to get updated activePreset - const configPath = path.join(UNITPREFS_DIR, 'config.json') - if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + // Re-read config from config dir + const cfgPath = configUnitprefsDir + ? path.join(configUnitprefsDir, 'config.json') + : path.join(PACKAGE_UNITPREFS_DIR, 'config.json') + if (fs.existsSync(cfgPath)) { + config = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } loadActivePreset() } export function reloadCustomDefinitions(): void { - const customPath = path.join(UNITPREFS_DIR, 'custom-units-definitions.json') + const customPath = configUnitprefsDir + ? path.join(configUnitprefsDir, 'custom-units-definitions.json') + : path.join(PACKAGE_UNITPREFS_DIR, 'custom-units-definitions.json') if (fs.existsSync(customPath)) { customDefinitions = JSON.parse(fs.readFileSync(customPath, 'utf-8')) } else { @@ -155,7 +223,9 @@ export function reloadCustomDefinitions(): void { } export function reloadCustomCategories(): void { - const customCatPath = path.join(UNITPREFS_DIR, 'custom-categories.json') + const customCatPath = configUnitprefsDir + ? path.join(configUnitprefsDir, 'custom-categories.json') + : path.join(PACKAGE_UNITPREFS_DIR, 'custom-categories.json') if (fs.existsSync(customCatPath)) { customCategories = JSON.parse(fs.readFileSync(customCatPath, 'utf-8')) } else { @@ -207,18 +277,24 @@ function loadPresetByName(presetName: string): Preset | null { return null } - // Check custom presets first - const customPresetPath = path.join( - UNITPREFS_DIR, - 'presets/custom', - `${presetName}.json` - ) - if (fs.existsSync(customPresetPath)) { - return JSON.parse(fs.readFileSync(customPresetPath, 'utf-8')) + // Check custom presets in config dir first + if (configUnitprefsDir) { + const customPresetPath = path.join( + configUnitprefsDir, + 'presets/custom', + `${presetName}.json` + ) + if (fs.existsSync(customPresetPath)) { + return JSON.parse(fs.readFileSync(customPresetPath, 'utf-8')) + } } - // Fall back to built-in presets - const builtInPath = path.join(UNITPREFS_DIR, 'presets', `${presetName}.json`) + // Fall back to built-in presets in package dir + const builtInPath = path.join( + PACKAGE_UNITPREFS_DIR, + 'presets', + `${presetName}.json` + ) if (fs.existsSync(builtInPath)) { return JSON.parse(fs.readFileSync(builtInPath, 'utf-8')) } diff --git a/unitpreferences/default-categories.json b/unitpreferences/default-categories.json index 8371bb9ba..f958ae210 100644 --- a/unitpreferences/default-categories.json +++ b/unitpreferences/default-categories.json @@ -120,6 +120,9 @@ "steering.rudderAngleTarget", "steering.autopilot.targetHeadingTrue", "steering.autopilot.targetHeadingMagnetic", + "steering.autopilot.target.headingMagnetic", + "steering.autopilot.target.headingTrue", + "steering.autopilot.target.windAngleApparent", "sails.*.reefState" ] }, diff --git a/unitpreferences/standard-units-definitions.json b/unitpreferences/standard-units-definitions.json index 6b0a0067f..e91e98678 100644 --- a/unitpreferences/standard-units-definitions.json +++ b/unitpreferences/standard-units-definitions.json @@ -36,16 +36,6 @@ "formula": "value * 3.280839895013124", "inverseFormula": "value / 3.280839895013124", "symbol": "fps" - }, - "knot": { - "formula": "value * 1.943844494119952", - "inverseFormula": "value / 1.943844494119952", - "symbol": "knot" - }, - "kph": { - "formula": "value * 3.5999999971200007", - "inverseFormula": "value / 3.5999999971200007", - "symbol": "kph" } } },