mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(dedup): consolidate byte / sanitize / clock / album-duration helpers (Phase L, part 2) (#692)
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).
This commit is contained in:
committed by
GitHub
parent
0153435787
commit
4b1dd3c29f
@@ -13,31 +13,8 @@ import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
|||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||||
import { formatLongDuration } from '../utils/format/formatDuration';
|
import { formatLongDuration } from '../utils/format/formatDuration';
|
||||||
|
import { formatMb } from '../utils/format/formatBytes';
|
||||||
function formatSize(bytes?: number): string {
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -372,7 +349,7 @@ export default function AlbumHeader({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { open } from '@tauri-apps/plugin-shell';
|
|||||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { version as currentVersion } from '../../package.json';
|
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 { useAppUpdater } from '../hooks/useAppUpdater';
|
||||||
import Changelog from './appUpdater/Changelog';
|
import Changelog from './appUpdater/Changelog';
|
||||||
|
|
||||||
@@ -112,8 +112,8 @@ export default function AppUpdater() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="app-updater-pct">{pct}%</span>
|
<span className="app-updater-pct">{pct}%</span>
|
||||||
<span className="update-modal-dl-bytes">
|
<span className="update-modal-dl-bytes">
|
||||||
{fmtBytes(dlProgress.bytes)}
|
{formatBytes(dlProgress.bytes)}
|
||||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +139,7 @@ export default function AppUpdater() {
|
|||||||
{dlState === 'idle' && (
|
{dlState === 'idle' && (
|
||||||
<div className="update-modal-asset">
|
<div className="update-modal-asset">
|
||||||
<span className="update-modal-asset-name">{asset.name}</span>
|
<span className="update-modal-asset-name">{asset.name}</span>
|
||||||
<span className="update-modal-asset-size">{fmtBytes(asset.size)}</span>
|
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{dlState === 'downloading' && (
|
{dlState === 'downloading' && (
|
||||||
@@ -149,8 +149,8 @@ export default function AppUpdater() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="app-updater-pct">{pct}%</span>
|
<span className="app-updater-pct">{pct}%</span>
|
||||||
<span className="update-modal-dl-bytes">
|
<span className="update-modal-dl-bytes">
|
||||||
{fmtBytes(dlProgress.bytes)}
|
{formatBytes(dlProgress.bytes)}
|
||||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CachedImage, { useCachedUrl } from './CachedImage';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { playAlbum } from '../utils/playback/playAlbum';
|
import { playAlbum } from '../utils/playback/playAlbum';
|
||||||
|
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
||||||
import AlbumRow from './AlbumRow';
|
import AlbumRow from './AlbumRow';
|
||||||
|
|
||||||
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
|
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
|
||||||
@@ -72,13 +73,6 @@ function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): Anchor[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAlbumDuration(seconds: number, t: (key: string, opts?: Record<string, unknown>) => 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
|
/** Both rotation memories are **per-server** — server A and server B keep
|
||||||
* independent state, so switching servers doesn't snap the anchor cooldown
|
* independent state, so switching servers doesn't snap the anchor cooldown
|
||||||
@@ -353,7 +347,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
|
|||||||
<div className="because-card-meta">
|
<div className="because-card-meta">
|
||||||
{album.year ? <span>{album.year}</span> : null}
|
{album.year ? <span>{album.year}</span> : null}
|
||||||
{album.songCount ? <span>{t('home.becauseYouLikeTracks', { count: album.songCount })}</span> : null}
|
{album.songCount ? <span>{t('home.becauseYouLikeTracks', { count: album.songCount })}</span> : null}
|
||||||
{album.duration ? <span>{formatAlbumDuration(album.duration, t)}</span> : null}
|
{album.duration ? <span>{formatHumanHoursMinutes(album.duration)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
|
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { formatPlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
|
import { formatPlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
|
||||||
|
import { formatClockTime } from '../utils/format/formatClockTime';
|
||||||
function formatClockTime(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One tap = schedule; custom minutes still covers any duration. */
|
/** One tap = schedule; custom minutes still covers any duration. */
|
||||||
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
|
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { HardDriveDownload, Check, X } from 'lucide-react';
|
import { HardDriveDownload, Check, X } from 'lucide-react';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
|
import { formatBytes } from '../utils/format/formatBytes';
|
||||||
function formatMB(bytes: number): string {
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ZipDownloadItem({ id }: { id: string }) {
|
function ZipDownloadItem({ id }: { id: string }) {
|
||||||
const dismiss = useZipDownloadStore(s => s.dismiss);
|
const dismiss = useZipDownloadStore(s => s.dismiss);
|
||||||
@@ -47,9 +43,9 @@ function ZipDownloadItem({ id }: { id: string }) {
|
|||||||
{!item.done && !item.error && (
|
{!item.done && !item.error && (
|
||||||
<>
|
<>
|
||||||
<div className="zip-dl-info">
|
<div className="zip-dl-info">
|
||||||
{formatMB(item.bytes)}
|
{formatBytes(item.bytes)}
|
||||||
{item.total !== null && item.total > 0 && (
|
{item.total !== null && item.total > 0 && (
|
||||||
<> / {formatMB(item.total)} ({pct!.toFixed(0)}%)</>
|
<> / {formatBytes(item.total)} ({pct!.toFixed(0)}%)</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`zip-dl-track${isIndeterminate ? ' zip-dl-indeterminate' : ''}`}>
|
<div className={`zip-dl-track${isIndeterminate ? ' zip-dl-indeterminate' : ''}`}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import type { SyncDelta } from '../../utils/deviceSync/runDeviceSyncExecution';
|
import type { SyncDelta } from '../../utils/deviceSync/runDeviceSyncExecution';
|
||||||
|
import { formatMb } from '../../utils/format/formatBytes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
preSyncOpen: boolean;
|
preSyncOpen: boolean;
|
||||||
@@ -32,20 +33,20 @@ export default function DeviceSyncPreSyncModal({
|
|||||||
<div className="device-sync-summary-stats" style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '10px 0' }}>
|
<div className="device-sync-summary-stats" style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '10px 0' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||||
<span>{t('deviceSync.filesToAdd')}</span>
|
<span>{t('deviceSync.filesToAdd')}</span>
|
||||||
<span className="color-success">+{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB)</span>
|
<span className="color-success">+{syncDelta.addCount} ({formatMb(syncDelta.addBytes)})</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||||
<span>{t('deviceSync.filesToDelete')}</span>
|
<span>{t('deviceSync.filesToDelete')}</span>
|
||||||
<span className="color-error">-{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB)</span>
|
<span className="color-error">-{syncDelta.delCount} ({formatMb(syncDelta.delBytes)})</span>
|
||||||
</div>
|
</div>
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '10px 0' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '10px 0' }} />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||||
<span>{t('deviceSync.netChange')}</span>
|
<span>{t('deviceSync.netChange')}</span>
|
||||||
<span>{((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB</span>
|
<span>{formatMb(syncDelta.addBytes - syncDelta.delBytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', color: syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', color: syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
|
||||||
<span>{t('deviceSync.availableSpace')}</span>
|
<span>{t('deviceSync.availableSpace')}</span>
|
||||||
<span>{(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB</span>
|
<span>{formatMb(syncDelta.availableBytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
|
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
|
||||||
<div className="sync-warning error" style={{ background: 'color-mix(in srgb, var(--danger) 15%, transparent)', padding: '10px', borderRadius: 'var(--radius-md)', marginTop: '15px', display: 'flex', gap: '10px', color: 'var(--danger)', alignItems: 'flex-start' }}>
|
<div className="sync-warning error" style={{ background: 'color-mix(in srgb, var(--danger) 15%, transparent)', padding: '10px', borderRadius: 'var(--radius-md)', marginTop: '15px', display: 'flex', gap: '10px', color: 'var(--danger)', alignItems: 'flex-start' }}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePlayerStore } from '../../store/playerStore';
|
|||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
||||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||||
|
import { formatClockTime } from '../../utils/format/formatClockTime';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
@@ -35,16 +36,11 @@ export function QueueHeader({
|
|||||||
|
|
||||||
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
|
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;
|
let dur: string | null = null;
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
|
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
|
||||||
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
|
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
|
||||||
else dur = fmtEta(remainingSecs);
|
else dur = formatClockTime(Date.now() + remainingSecs * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMode: DurationMode =
|
const nextMode: DurationMode =
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { extractCoverColors } from '../utils/ui/dynamicColors';
|
|||||||
import StarRating from '../components/StarRating';
|
import StarRating from '../components/StarRating';
|
||||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||||
|
|
||||||
import { sanitizeHtml } from '../utils/componentHelpers/artistDetailHelpers';
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -15,25 +15,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComposerDetail() {
|
export default function ComposerDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ export function isNewer(a: string, b: string): boolean {
|
|||||||
return false;
|
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 {
|
export interface GithubAsset {
|
||||||
name: string;
|
name: string;
|
||||||
browser_download_url: string;
|
browser_download_url: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import { sanitizeHtml as sanitizeHtmlBase } from '../sanitizeHtml';
|
||||||
|
|
||||||
export function formatTime(s: number): string {
|
export function formatTime(s: number): string {
|
||||||
if (!s || isNaN(s)) return '0:00';
|
if (!s || isNaN(s)) return '0:00';
|
||||||
@@ -22,21 +23,10 @@ export function formatTotalDuration(s: number): string {
|
|||||||
return `${sec}s`;
|
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 {
|
export function sanitizeHtml(html: string): string {
|
||||||
const parser = new DOMParser();
|
return sanitizeHtmlBase(html).replace(/<a [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
|
||||||
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 [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
|
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import { formatHumanHoursMinutes } from '../format/formatHumanDuration';
|
import { formatHumanHoursMinutes } from '../format/formatHumanDuration';
|
||||||
|
import { formatMb } from '../format/formatBytes';
|
||||||
|
|
||||||
export function sanitizeFilename(name: string): string {
|
export function sanitizeFilename(name: string): string {
|
||||||
return name
|
return name
|
||||||
@@ -10,8 +11,7 @@ export function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatSize(bytes?: number): string {
|
export function formatSize(bytes?: number): string {
|
||||||
if (!bytes) return '';
|
return bytes ? formatMb(bytes) : '';
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function totalDurationLabel(songs: SubsonicSong[]): string {
|
export function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ export function formatBytes(bytes: number): string {
|
|||||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
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. */
|
/** Align hot-cache size slider (step 32 MB) to valid values. */
|
||||||
export function snapHotCacheMb(v: number): number {
|
export function snapHotCacheMb(v: number): number {
|
||||||
const x = Math.min(20000, Math.max(32, Math.round(v)));
|
const x = Math.min(20000, Math.max(32, Math.round(v)));
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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 {
|
export function sanitizeHtml(html: string): string {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
@@ -7,7 +11,11 @@ export function sanitizeHtml(html: string): string {
|
|||||||
Array.from(el.attributes).forEach(attr => {
|
Array.from(el.attributes).forEach(attr => {
|
||||||
const name = attr.name.toLowerCase();
|
const name = attr.name.toLowerCase();
|
||||||
const val = attr.value.toLowerCase().trim();
|
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);
|
el.removeAttribute(attr.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user