/**
* On-disk background library for the Background Swapper host half.
*
* The library is one JSON index beside the image files it describes:
*
* ```text
*
/index.json { version, items[], activeId, tint, backgroundOpacity, elementOpacity, sidebarOpacity, blur, presets[] }
* /images/. one file per item, named by the item's opaque id
* ```
*
* The display name a person typed is data in the index, never part of a
* pathname, so no accepted name can escape the directory. Every mutation is
* serialized on one promise chain and published with a temporary file plus a
* rename, so a crash cannot leave a truncated index behind.
*
* @module dsh-background-swapper/store
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
/** Index format this module reads and writes. */
export const STORE_VERSION = 1
/** Largest display name kept, in characters. */
export const MAX_NAME_LENGTH = 80
/** Largest number of saved looks a library keeps. */
export const MAX_PRESETS = 6
/** Largest saved-look name kept, in characters. It labels a chip, not a page. */
export const MAX_PRESET_NAME_LENGTH = 24
/** Accepted upload media types mapped to the file extension each is stored under. */
export const IMAGE_TYPES = new Map([
['image/png', 'png'],
['image/jpeg', 'jpg'],
['image/webp', 'webp'],
['image/gif', 'gif'],
['image/avif', 'avif'],
])
/**
* Settings defaults, also the values a fresh library reports.
*
* Every alpha starts below 1 on purpose: at 1 that layer's surfaces are exactly
* as opaque as the stock interface, so a newly added photo would be completely
* hidden behind it and the plugin would look broken. They are kept apart
* because they fade different layers: `backgroundOpacity` the ground the frame
* and body paint, `elementOpacity` the panels, cards and menus raised over it,
* and `sidebarOpacity` the left column, which is the one surface large enough,
* and prominent enough, to want tuning on its own.
*
* They ship at the balance the split exists to make possible — the photo fills
* the page, the panels still read — and the panel's Wallpaper preset repeats
* these numbers, so a fresh library and a Reset both light it up.
*/
export const DEFAULT_SETTINGS = Object.freeze({
activeId: null,
tint: 0,
backgroundOpacity: 0.2,
elementOpacity: 0.85,
sidebarOpacity: 0.85,
blur: 0,
})
/**
* The appearance preferences stored beside the library.
* @typedef {object} Settings
* @property {string | null} activeId - Background currently painted, or null for none.
* @property {number} tint - Darken-to-lighten wash over the photo, from -1 to 1.
* @property {number} backgroundOpacity - Alpha of the ground behind the interface, from 0 to 1.
* @property {number} elementOpacity - Alpha of the panels, cards and menus raised over that ground, from 0 to 1.
* @property {number} sidebarOpacity - Alpha of the left column in particular, from 0 to 1.
* @property {number} blur - Photo blur radius in pixels, from 0 to 24.
*/
/**
* One stored background's metadata.
* @typedef {object} BackgroundItem
* @property {string} id - Opaque identifier; also the image file's base name.
* @property {string} name - Display name the person typed.
* @property {string} file - Image file name below `/images/`.
* @property {string} mime - Media type the file is served with.
* @property {number} bytes - Stored file size in bytes.
* @property {number} addedAt - Creation time as epoch milliseconds.
*/
/**
* Trim a typed name to a value the index accepts.
* @param {unknown} raw - Candidate name.
* @returns {string | undefined} The trimmed name, or undefined when it is empty after trimming.
*/
export function normalizeName(raw) {
if (typeof raw !== 'string') return undefined
// Control characters would survive into a rendered caption and a download
// name; drop them rather than reject an otherwise usable name.
const cleaned = raw.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim()
if (cleaned === '') return undefined
return cleaned.length > MAX_NAME_LENGTH ? cleaned.slice(0, MAX_NAME_LENGTH) : cleaned
}
/**
* Read a bounded numeric setting.
* @param {unknown} value - Candidate value.
* @param {number} min - Smallest accepted value.
* @param {number} max - Largest accepted value.
* @returns {number | undefined} The value, or undefined when it is not a finite number in range.
*/
function normalizeNumber(value, min, max) {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
if (value < min || value > max) return undefined
return value
}
/** @param {unknown} value - Candidate tint. @returns {number | undefined} Tint in -1..1, or undefined. */
export function normalizeTint(value) {
return normalizeNumber(value, -1, 1)
}
/** @param {unknown} value - Candidate alpha. @returns {number | undefined} Surface alpha in 0..1, or undefined. */
export function normalizeOpacity(value) {
return normalizeNumber(value, 0, 1)
}
/** @param {unknown} value - Candidate blur. @returns {number | undefined} Blur radius in 0..24 pixels, or undefined. */
export function normalizeBlur(value) {
return normalizeNumber(value, 0, 24)
}
/**
* A look someone saved: the three alphas, and nothing else.
*
* Tint and Blur describe the photo rather than the interface, and the shipped
* presets leave them alone so a preset never undoes a tuned photo. Saved looks
* keep to the same contract, which is also what makes loading one after trying
* Glass restore everything Glass could have changed.
*
* @typedef {object} SavedPreset
* @property {string} id - Opaque identifier, unique within the list.
* @property {string} name - Label the person typed, at most {@link MAX_PRESET_NAME_LENGTH} characters.
* @property {number} backgroundOpacity - Alpha of the ground, 0 to 1.
* @property {number} elementOpacity - Alpha of the panels, 0 to 1.
* @property {number} sidebarOpacity - Alpha of the left column, 0 to 1.
*/
/**
* Normalize a saved-look list.
*
* A value that is not a list, or one longer than {@link MAX_PRESETS}, is
* refused whole so the caller can report it. A single entry that is unusable is
* dropped and the rest are kept, the way the index treats an unusable image
* record, so one bad line cannot cost someone every look they saved.
*
* @param {unknown} value - Candidate list.
* @returns {SavedPreset[] | undefined} The normalized list, or undefined when the list's own shape is wrong.
*/
export function normalizePresets(value) {
if (!Array.isArray(value) || value.length > MAX_PRESETS) return undefined
const presets = []
const seen = new Set()
for (const raw of value) {
if (typeof raw !== 'object' || raw === null) continue
const { id, backgroundOpacity, elementOpacity, sidebarOpacity } =
/** @type {Record} */ (raw)
if (typeof id !== 'string' || id === '' || seen.has(id)) continue
const name = normalizeName(raw.name)
if (name === undefined) continue
const background = normalizeOpacity(backgroundOpacity)
const element = normalizeOpacity(elementOpacity)
const sidebar = normalizeOpacity(sidebarOpacity)
if (background === undefined || element === undefined || sidebar === undefined) continue
seen.add(id)
presets.push({
id,
name: name.length > MAX_PRESET_NAME_LENGTH ? name.slice(0, MAX_PRESET_NAME_LENGTH) : name,
backgroundOpacity: background,
elementOpacity: element,
sidebarOpacity: sidebar,
})
}
return presets
}
/**
* Rebuild one item from stored JSON.
* @param {unknown} raw - Candidate value from the index.
* @returns {BackgroundItem | undefined} The item, or undefined when a required field is missing or wrong.
*/
function parseItem(raw) {
if (typeof raw !== 'object' || raw === null) return undefined
const { id, name, file, mime, bytes, addedAt } = /** @type {Record} */ (raw)
if (typeof id !== 'string' || id === '') return undefined
if (typeof name !== 'string' || name === '') return undefined
if (typeof file !== 'string' || file === '') return undefined
if (typeof mime !== 'string' || !IMAGE_TYPES.has(mime)) return undefined
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return undefined
if (typeof addedAt !== 'number' || !Number.isFinite(addedAt)) return undefined
return { id, name, file, mime, bytes, addedAt }
}
/**
* Open the library under `dir`, creating it on first use.
* @param {object} options - Store configuration.
* @param {string} options.dir - Absolute library directory.
* @param {(message: string) => void} options.warn - Receives one line per discarded record.
* @returns {Promise