From 4b1dd3c29f0f0678602e740e8a7e3e5bba6db09a Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 15:19:42 +0200 Subject: [PATCH] refactor(dedup): consolidate byte / sanitize / clock / album-duration helpers (Phase L, part 2) (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings 5-8 of the dedup audit: - F5 byte formatters: appUpdaterHelpers.fmtBytes + ZipDownloadOverlay .formatMB route through the existing formatBytes; a new formatMb (always-MB) backs playlistDetailHelpers.formatSize, AlbumHeader and the 4 inline DeviceSyncPreSyncModal expressions. SongInfoModal.format Size is intentionally left — it uses decimal (1e6) divisors, not 1024. - F6 sanitizeHtml: extracted to utils/sanitizeHtml.ts; AlbumHeader, ComposerDetail and the (now-empty, deleted) artistDetailHelpers use it directly. nowPlayingHelpers keeps its own export but now delegates to the shared sanitiser and only adds its trailing-link strip on top. - F7 album duration: BecauseYouLikeRail's formatAlbumDuration drops in favour of the shared formatHumanHoursMinutes. Behaviour note: total minutes now floor instead of round (<=1 min display difference, matches every other caller). - F8 clock time: extracted to utils/format/formatClockTime.ts; PlaybackDelayModal + QueueHeader use it (toLocaleTimeString and Intl.DateTimeFormat produced identical output). Behaviour preserved except the two explicitly noted divergences (F7 round->floor; F5 appUpdater/Zip now show GB above 1 GB instead of a large MB number). --- src/components/AlbumHeader.tsx | 29 ++----------------- src/components/AppUpdater.tsx | 12 ++++---- src/components/BecauseYouLikeRail.tsx | 10 ++----- src/components/PlaybackDelayModal.tsx | 6 +--- src/components/ZipDownloadOverlay.tsx | 10 ++----- .../deviceSync/DeviceSyncPreSyncModal.tsx | 9 +++--- src/components/queuePanel/QueueHeader.tsx | 8 ++--- src/pages/ArtistDetail.tsx | 2 +- src/pages/ComposerDetail.tsx | 20 +------------ .../componentHelpers/appUpdaterHelpers.ts | 5 ---- .../componentHelpers/nowPlayingHelpers.ts | 18 +++--------- .../componentHelpers/playlistDetailHelpers.ts | 4 +-- src/utils/format/formatBytes.ts | 6 ++++ src/utils/format/formatClockTime.ts | 4 +++ ...artistDetailHelpers.ts => sanitizeHtml.ts} | 12 ++++++-- 15 files changed, 50 insertions(+), 105 deletions(-) create mode 100644 src/utils/format/formatClockTime.ts rename src/utils/{componentHelpers/artistDetailHelpers.ts => sanitizeHtml.ts} (54%) diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index d0b5f01b..86ce9a31 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -13,31 +13,8 @@ import { copyEntityShareLink } from '../utils/share/copyEntityShareLink'; import { showToast } from '../utils/ui/toast'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { formatLongDuration } from '../utils/format/formatDuration'; - -function formatSize(bytes?: number): string { - if (!bytes) return ''; - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; -} - -function sanitizeHtml(html: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); - doc.querySelectorAll('*').forEach(el => { - Array.from(el.attributes).forEach(attr => { - const name = attr.name.toLowerCase(); - const val = attr.value.toLowerCase().trim(); - if ( - name.startsWith('on') || - (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || - (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:'))) - ) { - el.removeAttribute(attr.name); - } - }); - }); - return doc.body.innerHTML; -} +import { formatMb } from '../utils/format/formatBytes'; +import { sanitizeHtml } from '../utils/sanitizeHtml'; function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) { const { t } = useTranslation(); @@ -372,7 +349,7 @@ export default function AlbumHeader({ ) : ( )} {offlineStatus === 'downloading' && offlineProgress ? ( diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index c0705d4b..30383772 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -3,7 +3,7 @@ import { open } from '@tauri-apps/plugin-shell'; import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { version as currentVersion } from '../../package.json'; -import { fmtBytes } from '../utils/componentHelpers/appUpdaterHelpers'; +import { formatBytes } from '../utils/format/formatBytes'; import { useAppUpdater } from '../hooks/useAppUpdater'; import Changelog from './appUpdater/Changelog'; @@ -112,8 +112,8 @@ export default function AppUpdater() { {pct}% - {fmtBytes(dlProgress.bytes)} - {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`} + {formatBytes(dlProgress.bytes)} + {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`} )} @@ -139,7 +139,7 @@ export default function AppUpdater() { {dlState === 'idle' && (
{asset.name} - {fmtBytes(asset.size)} + {formatBytes(asset.size)}
)} {dlState === 'downloading' && ( @@ -149,8 +149,8 @@ export default function AppUpdater() { {pct}% - {fmtBytes(dlProgress.bytes)} - {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`} + {formatBytes(dlProgress.bytes)} + {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`} )} diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index 02fae902..c9ea50e2 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -12,6 +12,7 @@ import CachedImage, { useCachedUrl } from './CachedImage'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { playAlbum } from '../utils/playback/playAlbum'; +import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration'; import AlbumRow from './AlbumRow'; const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:'; @@ -72,13 +73,6 @@ function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): Anchor[] { return out; } -function formatAlbumDuration(seconds: number, t: (key: string, opts?: Record) => string): string { - const totalMin = Math.max(0, Math.round(seconds / 60)); - const hours = Math.floor(totalMin / 60); - const minutes = totalMin % 60; - if (hours > 0) return t('common.durationHoursMinutes', { hours, minutes }); - return t('common.durationMinutesOnly', { minutes: totalMin }); -} /** Both rotation memories are **per-server** — server A and server B keep * independent state, so switching servers doesn't snap the anchor cooldown @@ -353,7 +347,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
{album.year ? {album.year} : null} {album.songCount ? {t('home.becauseYouLikeTracks', { count: album.songCount })} : null} - {album.duration ? {formatAlbumDuration(album.duration, t)} : null} + {album.duration ? {formatHumanHoursMinutes(album.duration)} : null}
diff --git a/src/components/PlaybackDelayModal.tsx b/src/components/PlaybackDelayModal.tsx index dbc609bc..aefa9321 100644 --- a/src/components/PlaybackDelayModal.tsx +++ b/src/components/PlaybackDelayModal.tsx @@ -7,11 +7,7 @@ import { useShallow } from 'zustand/react/shallow'; import type { TFunction } from 'i18next'; import { formatPlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat'; - -function formatClockTime(ts: number): string { - const d = new Date(ts); - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); -} +import { formatClockTime } from '../utils/format/formatClockTime'; /** One tap = schedule; custom minutes still covers any duration. */ const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const; diff --git a/src/components/ZipDownloadOverlay.tsx b/src/components/ZipDownloadOverlay.tsx index 1194ba7d..8f169aaa 100644 --- a/src/components/ZipDownloadOverlay.tsx +++ b/src/components/ZipDownloadOverlay.tsx @@ -2,11 +2,7 @@ import { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { HardDriveDownload, Check, X } from 'lucide-react'; import { useZipDownloadStore } from '../store/zipDownloadStore'; - -function formatMB(bytes: number): string { - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} +import { formatBytes } from '../utils/format/formatBytes'; function ZipDownloadItem({ id }: { id: string }) { const dismiss = useZipDownloadStore(s => s.dismiss); @@ -47,9 +43,9 @@ function ZipDownloadItem({ id }: { id: string }) { {!item.done && !item.error && ( <>
- {formatMB(item.bytes)} + {formatBytes(item.bytes)} {item.total !== null && item.total > 0 && ( - <> / {formatMB(item.total)}  ({pct!.toFixed(0)}%) + <> / {formatBytes(item.total)}  ({pct!.toFixed(0)}%) )}
diff --git a/src/components/deviceSync/DeviceSyncPreSyncModal.tsx b/src/components/deviceSync/DeviceSyncPreSyncModal.tsx index c7ee30e3..1f196b38 100644 --- a/src/components/deviceSync/DeviceSyncPreSyncModal.tsx +++ b/src/components/deviceSync/DeviceSyncPreSyncModal.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { AlertCircle, Loader2 } from 'lucide-react'; import type { SyncDelta } from '../../utils/deviceSync/runDeviceSyncExecution'; +import { formatMb } from '../../utils/format/formatBytes'; interface Props { preSyncOpen: boolean; @@ -32,20 +33,20 @@ export default function DeviceSyncPreSyncModal({
{t('deviceSync.filesToAdd')} - +{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB) + +{syncDelta.addCount} ({formatMb(syncDelta.addBytes)})
{t('deviceSync.filesToDelete')} - -{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB) + -{syncDelta.delCount} ({formatMb(syncDelta.delBytes)})

{t('deviceSync.netChange')} - {((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB + {formatMb(syncDelta.addBytes - syncDelta.delBytes)}
syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}> {t('deviceSync.availableSpace')} - {(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB + {formatMb(syncDelta.availableBytes)}
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx index ab51a416..c5be0c33 100644 --- a/src/components/queuePanel/QueueHeader.tsx +++ b/src/components/queuePanel/QueueHeader.tsx @@ -5,6 +5,7 @@ import { usePlayerStore } from '../../store/playerStore'; import type { Track } from '../../store/playerStoreTypes'; import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers'; import { formatLongDuration } from '../../utils/format/formatDuration'; +import { formatClockTime } from '../../utils/format/formatClockTime'; interface Props { queue: Track[]; @@ -35,16 +36,11 @@ export function QueueHeader({ const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration); - const fmtEta = (secs: number) => { - const finishTime = new Date(Date.now() + secs * 1000); - return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime); - }; - let dur: string | null = null; if (queue.length > 0) { if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs)); else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`; - else dur = fmtEta(remainingSecs); + else dur = formatClockTime(Date.now() + remainingSecs * 1000); } const nextMode: DurationMode = diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index c78c2b02..0d8c0a65 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -28,7 +28,7 @@ import { extractCoverColors } from '../utils/ui/dynamicColors'; import StarRating from '../components/StarRating'; import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore'; -import { sanitizeHtml } from '../utils/componentHelpers/artistDetailHelpers'; +import { sanitizeHtml } from '../utils/sanitizeHtml'; import { useArtistDetailData } from '../hooks/useArtistDetailData'; import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists'; import { diff --git a/src/pages/ComposerDetail.tsx b/src/pages/ComposerDetail.tsx index a1f4dcfd..ef662bb5 100644 --- a/src/pages/ComposerDetail.tsx +++ b/src/pages/ComposerDetail.tsx @@ -15,25 +15,7 @@ import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { copyEntityShareLink } from '../utils/share/copyEntityShareLink'; import { showToast } from '../utils/ui/toast'; - -/** Strip dangerous tags/attributes from server-provided HTML. Mirrors the - * ArtistDetail sanitiser — kept inline because it's a 10-liner not worth a - * separate util module. */ -function sanitizeHtml(html: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); - doc.querySelectorAll('*').forEach(el => { - Array.from(el.attributes).forEach(attr => { - const name = attr.name.toLowerCase(); - const val = attr.value.toLowerCase().trim(); - if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) { - el.removeAttribute(attr.name); - } - }); - }); - return doc.body.innerHTML; -} +import { sanitizeHtml } from '../utils/sanitizeHtml'; export default function ComposerDetail() { const { t } = useTranslation(); diff --git a/src/utils/componentHelpers/appUpdaterHelpers.ts b/src/utils/componentHelpers/appUpdaterHelpers.ts index 3098551d..3872b08e 100644 --- a/src/utils/componentHelpers/appUpdaterHelpers.ts +++ b/src/utils/componentHelpers/appUpdaterHelpers.ts @@ -13,11 +13,6 @@ export function isNewer(a: string, b: string): boolean { return false; } -export function fmtBytes(n: number): string { - if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; - return `${(n / 1024 / 1024).toFixed(1)} MB`; -} - export interface GithubAsset { name: string; browser_download_url: string; diff --git a/src/utils/componentHelpers/nowPlayingHelpers.ts b/src/utils/componentHelpers/nowPlayingHelpers.ts index c7cf8931..7947f2e0 100644 --- a/src/utils/componentHelpers/nowPlayingHelpers.ts +++ b/src/utils/componentHelpers/nowPlayingHelpers.ts @@ -1,4 +1,5 @@ import type { SubsonicSong } from '../../api/subsonicTypes'; +import { sanitizeHtml as sanitizeHtmlBase } from '../sanitizeHtml'; export function formatTime(s: number): string { if (!s || isNaN(s)) return '0:00'; @@ -22,21 +23,10 @@ export function formatTotalDuration(s: number): string { return `${sec}s`; } +/** Shared HTML sanitiser plus a now-playing-specific tweak: strip the trailing + * "Read more on Last.fm" style link so clamped bios end cleanly. */ export function sanitizeHtml(html: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); - doc.querySelectorAll('*').forEach(el => { - Array.from(el.attributes).forEach(attr => { - const name = attr.name.toLowerCase(); - const val = attr.value.toLowerCase().trim(); - if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) { - el.removeAttribute(attr.name); - } - }); - }); - // Strip trailing "Read more on Last.fm" style links for cleaner clamped bios. - return doc.body.innerHTML.replace(/]*>.*?<\/a>\.?\s*$/i, '').trim(); + return sanitizeHtmlBase(html).replace(/]*>.*?<\/a>\.?\s*$/i, '').trim(); } export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null { diff --git a/src/utils/componentHelpers/playlistDetailHelpers.ts b/src/utils/componentHelpers/playlistDetailHelpers.ts index da984860..7e86a3b6 100644 --- a/src/utils/componentHelpers/playlistDetailHelpers.ts +++ b/src/utils/componentHelpers/playlistDetailHelpers.ts @@ -1,5 +1,6 @@ import type { SubsonicSong } from '../../api/subsonicTypes'; import { formatHumanHoursMinutes } from '../format/formatHumanDuration'; +import { formatMb } from '../format/formatBytes'; export function sanitizeFilename(name: string): string { return name @@ -10,8 +11,7 @@ export function sanitizeFilename(name: string): string { } export function formatSize(bytes?: number): string { - if (!bytes) return ''; - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return bytes ? formatMb(bytes) : ''; } export function totalDurationLabel(songs: SubsonicSong[]): string { diff --git a/src/utils/format/formatBytes.ts b/src/utils/format/formatBytes.ts index 885a3826..5f1cc098 100644 --- a/src/utils/format/formatBytes.ts +++ b/src/utils/format/formatBytes.ts @@ -4,6 +4,12 @@ export function formatBytes(bytes: number): string { return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } +/** Always-MB variant for size totals that are conventionally shown in MB + * regardless of magnitude (album / playlist / device-sync totals). */ +export function formatMb(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + /** Align hot-cache size slider (step 32 MB) to valid values. */ export function snapHotCacheMb(v: number): number { const x = Math.min(20000, Math.max(32, Math.round(v))); diff --git a/src/utils/format/formatClockTime.ts b/src/utils/format/formatClockTime.ts new file mode 100644 index 00000000..2557a0b0 --- /dev/null +++ b/src/utils/format/formatClockTime.ts @@ -0,0 +1,4 @@ +/** Localized wall-clock `HH:MM` for a timestamp (sleep-timer / queue-ETA labels). */ +export function formatClockTime(timestampMs: number): string { + return new Date(timestampMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} diff --git a/src/utils/componentHelpers/artistDetailHelpers.ts b/src/utils/sanitizeHtml.ts similarity index 54% rename from src/utils/componentHelpers/artistDetailHelpers.ts rename to src/utils/sanitizeHtml.ts index 93a5196a..78d80168 100644 --- a/src/utils/componentHelpers/artistDetailHelpers.ts +++ b/src/utils/sanitizeHtml.ts @@ -1,4 +1,8 @@ -/** Strip dangerous tags/attributes from server-provided HTML */ +/** + * Strip dangerous tags / attributes from server-provided HTML (artist & album + * biographies). Removes embedded/active elements and `on*` / `javascript:` / + * `data:` handlers before the result is fed to `dangerouslySetInnerHTML`. + */ export function sanitizeHtml(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); @@ -7,7 +11,11 @@ export function sanitizeHtml(html: string): string { Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const val = attr.value.toLowerCase().trim(); - if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) { + if ( + name.startsWith('on') || + (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || + (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:'))) + ) { el.removeAttribute(attr.name); } });