mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(device-sync): fixed cross-OS naming scheme + playlist folders
Replace the user-configurable filename template with a fixed scheme so
syncing the same library across Linux/Windows/macOS no longer re-downloads
files just because the template string differs by OS.
Track layout on the device:
{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}
Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}
Playlists/{PlaylistName}/{PlaylistName}.m3u8
Playlist tracks go into a self-contained folder with a sibling-referencing
Extended M3U, instead of being scattered across the album tree with an
external .m3u8 — the stick stays tidy even for 50-artist playlists.
Rust (lib.rs):
- TrackSyncInfo: drop disc_number/year (not used in fixed schema), add
album_artist, duration, playlist_name, playlist_index
- sanitize_path_component strengthens empty-result handling via new
sanitize_or() which falls back to "Unknown Artist/Album/Title"
- build_track_path() dispatches on playlist context — playlist tracks
get the "Playlists/{Name}/…" shape, album tracks the traditional tree
- albumArtist resolution: the server's albumArtist tag, falling back to
the track artist (empty-string treated as missing)
- calculate_sync_payload: dedup key is now (source_id, track_id) instead
of track_id alone, so a track appearing in both an album and a playlist
ends up on the device in both locations; playlist context is embedded
into the response JSON as _playlistName / _playlistIndex so the follow-
up sync_batch_to_device call can route each track correctly
- write_playlist_m3u8: rewritten — writes into the playlist folder with
sibling filenames instead of a top-level .m3u8 with ../relative paths
- New rename_device_files command: atomic renames (per-entry result,
skip-if-exists, skip-if-source-missing) plus empty-dir cleanup. Used
by the migration flow
- write_device_manifest: v2 format, drops filenameTemplate field
- Drop the `template` parameter from sync_track_to_device,
compute_sync_paths, calculate_sync_payload, sync_batch_to_device
Frontend:
- DeviceSyncStore: drop filenameTemplate from state and persist
- DeviceSync.tsx: strip template editor UI (presets, token buttons, live
preview); replace with a compact read-only schema info card
- trackToSyncInfo(track, url, playlistCtx?): optional playlist context
parameter. Tracks coming back from calculate_sync_payload with embedded
_playlistName/_playlistIndex fall through via the same parameter
- compute_sync_paths / write_playlist_m3u8 / delete paths now pass the
playlist context per-source so status checks + deletions hit the right
files (album tree vs playlist folder)
- Album-Artist fallback: frontend falls back to track artist when the
server has no albumArtist tag (empty strings treated as missing) —
"Unknown Artist" is only used as a last-resort placeholder
- New migration flow: "Reorganize existing files…" button opens a modal
that reads the legacy filenameTemplate from the v1 manifest, computes
per-track (old, new) rename pairs, detects collisions (two old files
mapping onto one new path), and executes via rename_device_files.
Playlist-only tracks are left for the next sync to re-download into
the new playlist folder
- Small JS-side applyLegacyTemplate() helper: only used by the migration
preview so we can diff old paths against the current files on disk
- CSS: device-sync-schema-section / -code / -hint, migrate-modal +
summary + warning + result layout
i18n (en + de):
- Remove dead keys: filenameTemplate, templatePreview, templateHint,
templatePresetStandard/MultiDisc/AltFolder, tokenSlashHint
- Add schemaLabel, schemaHint, migrateButton/Title/Tooltip/Loading/
NothingToDo/NoTemplate/FilesToRename/Unchanged/Collisions/
PreviewNote/Executing/Success/Failed/ShowErrors/Start
Other locales (fr/nl/zh/nb/ru/es) fall through to the English defaultValue
until translated in a follow-up pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+17
-7
@@ -1144,9 +1144,24 @@ export const deTranslation = {
|
||||
free: 'frei',
|
||||
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
|
||||
chooseFolder: 'Auswählen…',
|
||||
filenameTemplate: 'Dateinamen-Vorlage',
|
||||
targetDevice: 'Zielgerät',
|
||||
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
schemaLabel: 'Benennungsschema',
|
||||
schemaHint: 'Festes Schema für zuverlässige Synchronisation über verschiedene Betriebssysteme hinweg. Playlists werden als .m3u8-Dateien geschrieben und referenzieren die Album-Tracks — keine Duplikate auf dem Gerät.',
|
||||
migrateButton: 'Bestehende Dateien re-organisieren…',
|
||||
migrateTooltip: 'Vorhandene Dateien auf dem Gerät nach dem neuen Schema umbenennen (basierend auf der alten Namensvorlage).',
|
||||
migrateTitle: 'Bestehende Dateien re-organisieren',
|
||||
migrateLoading: 'Analysiere vorhandene Dateien…',
|
||||
migrateNothingToDo: 'Alle vorhandenen Dateien entsprechen bereits dem neuen Schema — nichts zu tun.',
|
||||
migrateNoTemplate: 'Keine alte Namensvorlage auf dem Gerät gefunden. Migration ist nur möglich, wenn der Stick mit einer Psysonic-Version synchronisiert wurde, die konfigurierbare Vorlagen unterstützte.',
|
||||
migrateFilesToRename: 'Dateien werden umbenannt',
|
||||
migrateUnchanged: '{{n}} Dateien liegen bereits am korrekten Pfad',
|
||||
migrateCollisions: '{{n}} Dateien können nicht automatisch umbenannt werden (mehrere Tracks zeigen auf dasselbe Ziel). Sie bleiben unverändert — beim nächsten Sync werden sie neu heruntergeladen.',
|
||||
migratePreviewNote: 'Alte Vorlage: {{tpl}}',
|
||||
migrateExecuting: 'Dateien werden umbenannt…',
|
||||
migrateSuccess: '{{n}} Dateien erfolgreich umbenannt',
|
||||
migrateFailed: '{{n}} Umbenennungen fehlgeschlagen',
|
||||
migrateShowErrors: 'Fehler anzeigen',
|
||||
migrateStart: 'Umbenennen starten',
|
||||
onDevice: 'Auf dem Gerät',
|
||||
addSources: 'Hinzufügen…',
|
||||
colName: 'Name',
|
||||
@@ -1167,11 +1182,6 @@ export const deTranslation = {
|
||||
actionTransfer: 'Auf Gerät übertragen',
|
||||
actionDelete: 'Vom Gerät löschen',
|
||||
actionApplyAll: 'Änderungen synchronisieren',
|
||||
templatePreview: 'Vorschau',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. Ordner',
|
||||
tokenSlashHint: '/ = Ordner-Trennzeichen',
|
||||
cancel: 'Abbrechen',
|
||||
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
|
||||
noSources: 'Bitte mindestens eine Quelle auswählen.',
|
||||
|
||||
+17
-7
@@ -1146,9 +1146,24 @@ export const enTranslation = {
|
||||
free: 'free',
|
||||
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
|
||||
chooseFolder: 'Choose…',
|
||||
filenameTemplate: 'Filename Template',
|
||||
targetDevice: 'Target Device',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
schemaLabel: 'Naming scheme',
|
||||
schemaHint: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
|
||||
migrateButton: 'Reorganize existing files…',
|
||||
migrateTooltip: 'Rename existing files on the device into the new scheme (from the old filename template).',
|
||||
migrateTitle: 'Reorganize existing files',
|
||||
migrateLoading: 'Analyzing existing files…',
|
||||
migrateNothingToDo: 'All existing files already match the new scheme — nothing to do.',
|
||||
migrateNoTemplate: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.',
|
||||
migrateFilesToRename: 'files will be renamed',
|
||||
migrateUnchanged: '{{n}} files are already at the correct path',
|
||||
migrateCollisions: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
|
||||
migratePreviewNote: 'Old template: {{tpl}}',
|
||||
migrateExecuting: 'Renaming files…',
|
||||
migrateSuccess: '{{n}} files renamed successfully',
|
||||
migrateFailed: '{{n}} renames failed',
|
||||
migrateShowErrors: 'Show errors',
|
||||
migrateStart: 'Start renaming',
|
||||
onDevice: 'Device Manager',
|
||||
addSources: 'Add…',
|
||||
colName: 'Name',
|
||||
@@ -1170,11 +1185,6 @@ export const enTranslation = {
|
||||
actionTransfer: 'Transfer to Device',
|
||||
actionDelete: 'Delete from Device',
|
||||
actionApplyAll: 'Apply All Changes',
|
||||
templatePreview: 'Preview',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. Folder',
|
||||
tokenSlashHint: '/ = folder separator',
|
||||
cancel: 'Cancel',
|
||||
noTargetDir: 'Please choose a target folder first.',
|
||||
noSources: 'Please select at least one source.',
|
||||
|
||||
+366
-147
@@ -17,6 +17,7 @@ import {
|
||||
SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist,
|
||||
} from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
type SourceTab = 'playlists' | 'albums' | 'artists';
|
||||
|
||||
@@ -24,6 +25,40 @@ type SourceTab = 'playlists' | 'albums' | 'artists';
|
||||
|
||||
function uuid(): string { return crypto.randomUUID(); }
|
||||
|
||||
// Same sanitize rules the Rust side uses (`sanitize_path_component`): strip
|
||||
// Windows-illegal chars and control chars, trim leading/trailing dots + spaces.
|
||||
// Kept in JS only for the migration flow — computes the *old* path under a
|
||||
// user-supplied template so we can diff against the current files on disk.
|
||||
function sanitizeComponent(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
|
||||
}
|
||||
|
||||
interface OldTemplateTrack {
|
||||
artist: string;
|
||||
album: string;
|
||||
title: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
year?: number;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
/** Renders a track's path under a legacy (user-configurable) template. Used only
|
||||
* for the migration preview — the live sync flow goes through Rust's fixed
|
||||
* `build_track_path`. */
|
||||
function applyLegacyTemplate(template: string, track: OldTemplateTrack): string {
|
||||
const relative = template
|
||||
.replace(/\{artist\}/g, sanitizeComponent(track.artist))
|
||||
.replace(/\{album\}/g, sanitizeComponent(track.album))
|
||||
.replace(/\{title\}/g, sanitizeComponent(track.title))
|
||||
.replace(/\{track_number\}/g, track.trackNumber != null ? String(track.trackNumber).padStart(2, '0') : '')
|
||||
.replace(/\{disc_number\}/g, track.discNumber != null ? String(track.discNumber) : '')
|
||||
.replace(/\{year\}/g, track.year != null ? String(track.year) : '');
|
||||
const withExt = `${relative}.${track.suffix}`;
|
||||
return IS_WINDOWS ? withExt.replace(/\//g, '\\') : withExt;
|
||||
}
|
||||
|
||||
async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
||||
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
|
||||
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
|
||||
@@ -33,16 +68,31 @@ async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicS
|
||||
return all;
|
||||
}
|
||||
|
||||
function trackToSyncInfo(track: SubsonicSong, url: string) {
|
||||
/** Tracks that came from `calculate_sync_payload` may carry embedded playlist
|
||||
* context so the follow-up `sync_batch_to_device` call knows to place them
|
||||
* under `Playlists/{Name}/` instead of the album tree. */
|
||||
type SyncTrackMaybePlaylist = SubsonicSong & { _playlistName?: string; _playlistIndex?: number };
|
||||
|
||||
function trackToSyncInfo(
|
||||
track: SyncTrackMaybePlaylist,
|
||||
url: string,
|
||||
playlistCtx?: { name: string; index: number },
|
||||
) {
|
||||
// Fall back to track artist when the file has no albumArtist tag — not every
|
||||
// library is tagged with it. Treat empty strings as missing (some Subsonic
|
||||
// servers return "" rather than omitting the field).
|
||||
const albumArtist = (track.albumArtist?.trim() || track.artist?.trim() || '');
|
||||
return {
|
||||
id: track.id, url,
|
||||
suffix: track.suffix ?? 'mp3',
|
||||
artist: track.artist ?? '',
|
||||
albumArtist,
|
||||
album: track.album ?? '',
|
||||
title: track.title ?? '',
|
||||
trackNumber: track.track,
|
||||
discNumber: track.discNumber,
|
||||
year: track.year,
|
||||
duration: track.duration,
|
||||
playlistName: playlistCtx?.name ?? track._playlistName,
|
||||
playlistIndex: playlistCtx?.index ?? track._playlistIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,14 +121,13 @@ export default function DeviceSync() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const targetDir = useDeviceSyncStore(s => s.targetDir);
|
||||
const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate);
|
||||
const sources = useDeviceSyncStore(s => s.sources);
|
||||
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
|
||||
const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion);
|
||||
const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths);
|
||||
const scanning = useDeviceSyncStore(s => s.scanning);
|
||||
const {
|
||||
setTargetDir, setFilenameTemplate, addSource, removeSource,
|
||||
setTargetDir, addSource, removeSource,
|
||||
clearSources, toggleChecked, setCheckedIds, markForDeletion,
|
||||
unmarkDeletion, removeSources, setDeviceFilePaths, setScanning,
|
||||
} = useDeviceSyncStore.getState();
|
||||
@@ -103,9 +152,6 @@ export default function DeviceSync() {
|
||||
|
||||
// Map source IDs → computed device paths (for status derivation)
|
||||
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
|
||||
// Template stored in the manifest — may differ from local filenameTemplate when reading a
|
||||
// manifest written on another OS. Used only for status checks, not for new syncs.
|
||||
const [deviceManifestTemplate, setDeviceManifestTemplate] = useState<string | null>(null);
|
||||
|
||||
// ─── Removable drive detection ──────────────────────────────────────────
|
||||
const [drives, setDrives] = useState<RemovableDrive[]>([]);
|
||||
@@ -115,6 +161,15 @@ export default function DeviceSync() {
|
||||
const [preSyncLoading, setPreSyncLoading] = useState(false);
|
||||
const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] });
|
||||
|
||||
// ─── Migration (rename existing files into the fixed scheme) ────────────
|
||||
type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing';
|
||||
const [migrationPhase, setMigrationPhase] = useState<MigrationPhase>('closed');
|
||||
const [migrationOldTemplate, setMigrationOldTemplate] = useState<string>('');
|
||||
const [migrationPairs, setMigrationPairs] = useState<{ old: string; new: string }[]>([]);
|
||||
const [migrationCollisions, setMigrationCollisions] = useState<{ old: string; new: string }[]>([]);
|
||||
const [migrationUnchanged, setMigrationUnchanged] = useState(0);
|
||||
const [migrationResult, setMigrationResult] = useState<{ ok: number; failed: number; errors: string[] } | null>(null);
|
||||
|
||||
const refreshDrives = useCallback(async () => {
|
||||
setDrivesLoading(true);
|
||||
try {
|
||||
@@ -170,13 +225,12 @@ export default function DeviceSync() {
|
||||
useEffect(() => {
|
||||
if (!targetDir || !driveDetected || manifestImportedRef.current) return;
|
||||
manifestImportedRef.current = true;
|
||||
invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
|
||||
invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
|
||||
'read_device_manifest', { destDir: targetDir }
|
||||
).then(manifest => {
|
||||
if (manifest?.sources?.length) {
|
||||
useDeviceSyncStore.getState().clearSources();
|
||||
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
|
||||
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
|
||||
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -186,7 +240,6 @@ export default function DeviceSync() {
|
||||
useEffect(() => {
|
||||
if (!driveDetected) {
|
||||
setDeviceFilePaths([]);
|
||||
setDeviceManifestTemplate(null);
|
||||
manifestImportedRef.current = false;
|
||||
}
|
||||
}, [driveDetected]);
|
||||
@@ -197,9 +250,7 @@ export default function DeviceSync() {
|
||||
setSourcePathsMap(new Map());
|
||||
return;
|
||||
}
|
||||
// Use the template from the manifest (written by the original sync machine) so that
|
||||
// status checks work correctly even when the local template differs (e.g. Windows→Linux).
|
||||
const templateForStatus = deviceManifestTemplate ?? filenameTemplate;
|
||||
// Path schema is fixed in the Rust backend now — no template parameter.
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
@@ -208,9 +259,11 @@ export default function DeviceSync() {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map(t => trackToSyncInfo(t, '')),
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(
|
||||
tr, '',
|
||||
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
|
||||
)),
|
||||
destDir: targetDir,
|
||||
template: templateForStatus,
|
||||
});
|
||||
map.set(source.id, paths);
|
||||
} catch {
|
||||
@@ -220,7 +273,7 @@ export default function DeviceSync() {
|
||||
if (!cancelled) setSourcePathsMap(map);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [targetDir, filenameTemplate, deviceManifestTemplate, sources]);
|
||||
}, [targetDir, sources]);
|
||||
|
||||
// Derive sync status per source
|
||||
const sourceStatuses = useMemo(() => {
|
||||
@@ -300,8 +353,24 @@ export default function DeviceSync() {
|
||||
5000, 'info'
|
||||
);
|
||||
// Write manifest so another machine can read the synced sources from the stick
|
||||
const { targetDir: dir, sources: srcs, filenameTemplate: tpl } = useDeviceSyncStore.getState();
|
||||
if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs, filenameTemplate: tpl }).catch(() => {});
|
||||
const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState();
|
||||
if (dir) {
|
||||
invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {});
|
||||
// For every playlist source, write an Extended-M3U next to the
|
||||
// playlist-folder tracks. Context carries the playlist name +
|
||||
// per-track index so the filenames match the files we just synced.
|
||||
const playlistSources = srcs.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: dir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* m3u8 failure is non-fatal — skip silently */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
// Re-scan the device after sync completes (cancelled or not)
|
||||
scanDevice();
|
||||
@@ -380,6 +449,119 @@ export default function DeviceSync() {
|
||||
const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]);
|
||||
const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]);
|
||||
|
||||
// ─── Migration handlers ─────────────────────────────────────────────────
|
||||
const startMigrationPreview = async () => {
|
||||
if (!targetDir || sources.length === 0) return;
|
||||
setMigrationPhase('loading');
|
||||
setMigrationResult(null);
|
||||
try {
|
||||
// Look up the old template from the v1 manifest on disk.
|
||||
const manifest = await invoke<{ version: number; filenameTemplate?: string } | null>(
|
||||
'read_device_manifest', { destDir: targetDir }
|
||||
);
|
||||
const oldTemplate = manifest?.filenameTemplate?.trim() || '';
|
||||
if (!oldTemplate) {
|
||||
// v2 manifest or missing — nothing to migrate from.
|
||||
setMigrationPhase('nothing');
|
||||
return;
|
||||
}
|
||||
setMigrationOldTemplate(oldTemplate);
|
||||
|
||||
// Migration only renames tracks that came from album/artist sources —
|
||||
// under the old template all tracks lived in a flat album tree. Playlist
|
||||
// sources get their own `Playlists/{name}/…` folder under the new scheme,
|
||||
// so the files they need are a subset (or copies) of the album tracks and
|
||||
// are cleaner to just re-download on the next sync.
|
||||
const albumSourceTracks: SubsonicSong[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const source of sources.filter(s => s.type !== 'playlist')) {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
for (const tr of tracks) {
|
||||
if (seenIds.has(tr.id)) continue;
|
||||
seenIds.add(tr.id);
|
||||
albumSourceTracks.push(tr);
|
||||
}
|
||||
} catch { /* skip unreachable source */ }
|
||||
}
|
||||
|
||||
// New paths via Rust (fixed album-tree schema).
|
||||
const newAbsPaths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')),
|
||||
destDir: targetDir,
|
||||
});
|
||||
const sepChar = IS_WINDOWS ? '\\' : '/';
|
||||
const prefix = targetDir.endsWith(sepChar) ? targetDir : targetDir + sepChar;
|
||||
const newRelPaths = newAbsPaths.map(p => p.startsWith(prefix) ? p.slice(prefix.length) : p);
|
||||
|
||||
// Old paths via the legacy template (JS).
|
||||
const oldRelPaths = albumSourceTracks.map(tr => applyLegacyTemplate(oldTemplate, {
|
||||
artist: tr.artist ?? '',
|
||||
album: tr.album ?? '',
|
||||
title: tr.title ?? '',
|
||||
trackNumber: tr.track,
|
||||
discNumber: tr.discNumber,
|
||||
year: tr.year,
|
||||
suffix: tr.suffix ?? 'mp3',
|
||||
}));
|
||||
|
||||
const pairs: { old: string; new: string }[] = [];
|
||||
const collisions: { old: string; new: string }[] = [];
|
||||
const newPathCounts = new Map<string, number>();
|
||||
let unchanged = 0;
|
||||
|
||||
for (let i = 0; i < albumSourceTracks.length; i++) {
|
||||
const o = oldRelPaths[i];
|
||||
const n = newRelPaths[i];
|
||||
if (o === n) { unchanged += 1; continue; }
|
||||
newPathCounts.set(n, (newPathCounts.get(n) ?? 0) + 1);
|
||||
pairs.push({ old: o, new: n });
|
||||
}
|
||||
// Two separate old files mapping onto the same new path → collision.
|
||||
const colliding = new Set([...newPathCounts.entries()].filter(([, c]) => c > 1).map(([p]) => p));
|
||||
const cleanPairs = pairs.filter(p => !colliding.has(p.new));
|
||||
for (const p of pairs.filter(p => colliding.has(p.new))) collisions.push(p);
|
||||
|
||||
setMigrationPairs(cleanPairs);
|
||||
setMigrationCollisions(collisions);
|
||||
setMigrationUnchanged(unchanged);
|
||||
setMigrationPhase(cleanPairs.length === 0 && collisions.length === 0 ? 'nothing' : 'preview');
|
||||
} catch (e) {
|
||||
setMigrationResult({ ok: 0, failed: 0, errors: [String(e)] });
|
||||
setMigrationPhase('done');
|
||||
}
|
||||
};
|
||||
|
||||
const executeMigration = async () => {
|
||||
if (!targetDir || migrationPairs.length === 0) { setMigrationPhase('closed'); return; }
|
||||
setMigrationPhase('executing');
|
||||
try {
|
||||
const results = await invoke<{ oldPath: string; newPath: string; ok: boolean; error: string | null }[]>(
|
||||
'rename_device_files',
|
||||
{ targetDir, pairs: migrationPairs.map(p => [p.old, p.new]) }
|
||||
);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
const errors = results.filter(r => !r.ok).map(r => `${r.oldPath}: ${r.error ?? 'unknown'}`);
|
||||
setMigrationResult({ ok, failed, errors });
|
||||
// Bump manifest to v2 (no template field) + rescan the device.
|
||||
invoke('write_device_manifest', { destDir: targetDir, sources }).catch(() => {});
|
||||
scanDevice();
|
||||
setMigrationPhase('done');
|
||||
} catch (e) {
|
||||
setMigrationResult({ ok: 0, failed: migrationPairs.length, errors: [String(e)] });
|
||||
setMigrationPhase('done');
|
||||
}
|
||||
};
|
||||
|
||||
const closeMigration = () => {
|
||||
setMigrationPhase('closed');
|
||||
setMigrationPairs([]);
|
||||
setMigrationCollisions([]);
|
||||
setMigrationResult(null);
|
||||
setMigrationOldTemplate('');
|
||||
};
|
||||
|
||||
const handleChooseFolder = async () => {
|
||||
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
|
||||
if (sel) {
|
||||
@@ -388,13 +570,12 @@ export default function DeviceSync() {
|
||||
// If the device has a psysonic-sync.json, always import it — replacing any
|
||||
// sources from a previous device so switching sticks works correctly.
|
||||
try {
|
||||
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
|
||||
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
|
||||
'read_device_manifest', { destDir: dir }
|
||||
);
|
||||
if (manifest?.sources?.length) {
|
||||
useDeviceSyncStore.getState().clearSources();
|
||||
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
|
||||
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
|
||||
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
|
||||
}
|
||||
} catch { /* no manifest, that's fine */ }
|
||||
@@ -422,7 +603,6 @@ export default function DeviceSync() {
|
||||
deletionIds: pendingDeletion,
|
||||
auth: { baseUrl, ...params },
|
||||
targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
|
||||
setSyncDelta(payload);
|
||||
@@ -442,16 +622,20 @@ export default function DeviceSync() {
|
||||
if (deletionSources.length > 0) {
|
||||
try {
|
||||
const allPaths: string[] = [];
|
||||
const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s)));
|
||||
const deletionTracks = trackArrays.flat();
|
||||
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: deletionTracks.map(t => trackToSyncInfo(t, '')),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
allPaths.push(...paths);
|
||||
|
||||
// Compute paths per source so playlist sources delete from their own
|
||||
// folder (Playlists/{Name}/…) rather than from the album tree.
|
||||
for (const source of deletionSources) {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(
|
||||
tr, '',
|
||||
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
|
||||
)),
|
||||
destDir: targetDir,
|
||||
});
|
||||
allPaths.push(...paths);
|
||||
}
|
||||
|
||||
await invoke<number>('delete_device_files', { paths: allPaths });
|
||||
removeSources(deletionSources.map(s => s.id));
|
||||
// Update manifest so it stays in sync after deletions
|
||||
@@ -468,6 +652,21 @@ export default function DeviceSync() {
|
||||
|
||||
const allTracks = syncDelta.tracks;
|
||||
if (allTracks.length === 0) {
|
||||
// No new downloads needed, but the user may still have added a
|
||||
// playlist source — (re)write its .m3u8 against the existing files.
|
||||
if (targetDir) {
|
||||
const playlistSources = sources.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: targetDir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
});
|
||||
}
|
||||
scanDevice();
|
||||
return;
|
||||
}
|
||||
@@ -480,7 +679,6 @@ export default function DeviceSync() {
|
||||
invoke('sync_batch_to_device', {
|
||||
tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
jobId,
|
||||
expectedBytes: syncDelta.addBytes,
|
||||
}).catch((err: string) => {
|
||||
@@ -524,63 +722,6 @@ export default function DeviceSync() {
|
||||
(!driveDetected && !!targetDir) ||
|
||||
(pendingCount === 0 && deletionCount === 0);
|
||||
|
||||
// ─── Template presets & token insertion ────────────────────────────────
|
||||
const TEMPLATE_PRESETS = useMemo(() => [
|
||||
{ key: 'standard', value: '{artist}/{album}/{track_number} - {title}', label: t('deviceSync.templatePresetStandard') },
|
||||
{ key: 'multidisc', value: '{artist}/{album}/{disc_number}-{track_number} - {title}', label: t('deviceSync.templatePresetMultiDisc') },
|
||||
{ key: 'altfolder', value: '{artist} - {album}/{track_number} - {title}', label: t('deviceSync.templatePresetAltFolder') },
|
||||
], [t]);
|
||||
|
||||
const TEMPLATE_TOKENS = ['{artist}', '{album}', '{title}', '{track_number}', '{disc_number}', '{year}', '/', '-'];
|
||||
|
||||
const activePreset = TEMPLATE_PRESETS.find(p => p.value === filenameTemplate)?.key ?? null;
|
||||
|
||||
const templateInputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPosRef = useRef<number>(filenameTemplate.length);
|
||||
|
||||
const insertToken = useCallback((token: string) => {
|
||||
const input = templateInputRef.current;
|
||||
const pos = cursorPosRef.current;
|
||||
const next = filenameTemplate.slice(0, pos) + token + filenameTemplate.slice(pos);
|
||||
setFilenameTemplate(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
const newPos = pos + token.length;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
cursorPosRef.current = newPos;
|
||||
});
|
||||
}, [filenameTemplate, setFilenameTemplate]);
|
||||
|
||||
const trackCursor = useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
cursorPosRef.current = (e.currentTarget.selectionStart ?? filenameTemplate.length);
|
||||
}, [filenameTemplate.length]);
|
||||
|
||||
// ─── Template preview (dummy track) ─────────────────────────────────────
|
||||
const PREVIEW_TRACK = {
|
||||
artist: 'Artist Name',
|
||||
album: 'Album Title',
|
||||
title: 'Track Title',
|
||||
track_number: '01',
|
||||
disc_number: '1',
|
||||
year: '2024',
|
||||
} as const;
|
||||
|
||||
const templatePreviewText = useMemo(() => {
|
||||
try {
|
||||
const result = filenameTemplate
|
||||
.replace(/\{artist\}/g, PREVIEW_TRACK.artist)
|
||||
.replace(/\{album\}/g, PREVIEW_TRACK.album)
|
||||
.replace(/\{title\}/g, PREVIEW_TRACK.title)
|
||||
.replace(/\{track_number\}/g, PREVIEW_TRACK.track_number)
|
||||
.replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number)
|
||||
.replace(/\{year\}/g, PREVIEW_TRACK.year);
|
||||
return `${result}.mp3`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [filenameTemplate]);
|
||||
|
||||
const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: 'playlists', icon: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
|
||||
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
|
||||
@@ -598,63 +739,30 @@ export default function DeviceSync() {
|
||||
</div>
|
||||
|
||||
<div className="device-sync-config-row">
|
||||
|
||||
{/* ── Left: Template ── */}
|
||||
<div className="device-sync-template-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
|
||||
<div className="device-sync-template-presets">
|
||||
{TEMPLATE_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`device-sync-template-preset-btn${activePreset === p.key ? ' active' : ''}`}
|
||||
onClick={() => setFilenameTemplate(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="device-sync-template-input-wrap">
|
||||
<div className="device-sync-template-input-row">
|
||||
<input
|
||||
ref={templateInputRef}
|
||||
className="input device-sync-template-input"
|
||||
value={filenameTemplate}
|
||||
onChange={e => { setFilenameTemplate(e.target.value); trackCursor(e); }}
|
||||
onSelect={trackCursor}
|
||||
onKeyUp={trackCursor}
|
||||
onClick={trackCursor}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{filenameTemplate && (
|
||||
<button
|
||||
className="device-sync-template-clear"
|
||||
onClick={() => { setFilenameTemplate(''); cursorPosRef.current = 0; templateInputRef.current?.focus(); }}
|
||||
data-tooltip={t('common.clear')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-template-tokens">
|
||||
{TEMPLATE_TOKENS.map(tok => (
|
||||
<button
|
||||
key={tok}
|
||||
className="device-sync-template-token"
|
||||
onClick={() => insertToken(tok)}
|
||||
data-tooltip={tok === '/' ? t('deviceSync.tokenSlashHint') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{templatePreviewText && (
|
||||
<span className="device-sync-template-preview">
|
||||
{t('deviceSync.templatePreview')}: {templatePreviewText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Left: Fixed schema info ── */}
|
||||
<div className="device-sync-schema-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })}</span>
|
||||
<code className="device-sync-schema-code">
|
||||
{'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'}
|
||||
</code>
|
||||
<span className="device-sync-schema-hint">
|
||||
{t('deviceSync.schemaHint', {
|
||||
defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
|
||||
})}
|
||||
</span>
|
||||
{targetDir && sources.length > 0 && (
|
||||
<button
|
||||
className="btn btn-ghost device-sync-migrate-btn"
|
||||
onClick={startMigrationPreview}
|
||||
data-tooltip={t('deviceSync.migrateTooltip', {
|
||||
defaultValue: 'Rename existing files on the device into the new scheme (from the old filename template).',
|
||||
})}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{t('deviceSync.migrateButton', { defaultValue: 'Reorganize existing files…' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: Drive config ── */}
|
||||
@@ -1039,6 +1147,117 @@ export default function DeviceSync() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Migration modal (rename existing files into the fixed scheme) ── */}
|
||||
{migrationPhase !== 'closed' && (
|
||||
<div className="modal-overlay" onClick={migrationPhase === 'executing' ? undefined : closeMigration}>
|
||||
<div className="modal-content device-sync-migrate-modal" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="modal-title">{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}</h2>
|
||||
<div className="device-sync-migrate-body">
|
||||
{migrationPhase === 'loading' && (
|
||||
<div className="device-sync-migrate-loading">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>{t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'nothing' && (
|
||||
<div className="device-sync-migrate-nothing">
|
||||
{migrationOldTemplate ? (
|
||||
t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' })
|
||||
) : (
|
||||
t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'preview' && (
|
||||
<>
|
||||
<div className="device-sync-migrate-summary">
|
||||
<div>
|
||||
<strong>{migrationPairs.length}</strong>{' '}
|
||||
{t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })}
|
||||
</div>
|
||||
{migrationUnchanged > 0 && (
|
||||
<div className="muted">
|
||||
{t('deviceSync.migrateUnchanged', {
|
||||
defaultValue: '{{n}} files are already at the correct path',
|
||||
n: migrationUnchanged,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{migrationCollisions.length > 0 && (
|
||||
<div className="device-sync-migrate-warning">
|
||||
<AlertCircle size={14} />
|
||||
{t('deviceSync.migrateCollisions', {
|
||||
defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
|
||||
n: migrationCollisions.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-migrate-preview-note">
|
||||
{t('deviceSync.migratePreviewNote', {
|
||||
defaultValue: 'Old template: {{tpl}}',
|
||||
tpl: migrationOldTemplate,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{migrationPhase === 'executing' && (
|
||||
<div className="device-sync-migrate-loading">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>{t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'done' && migrationResult && (
|
||||
<div className="device-sync-migrate-result">
|
||||
<div className="device-sync-migrate-result-line">
|
||||
<CheckCircle2 size={14} className="positive" />
|
||||
{t('deviceSync.migrateSuccess', {
|
||||
defaultValue: '{{n}} files renamed successfully',
|
||||
n: migrationResult.ok,
|
||||
})}
|
||||
</div>
|
||||
{migrationResult.failed > 0 && (
|
||||
<div className="device-sync-migrate-result-line">
|
||||
<AlertCircle size={14} className="danger" />
|
||||
{t('deviceSync.migrateFailed', {
|
||||
defaultValue: '{{n}} renames failed',
|
||||
n: migrationResult.failed,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{migrationResult.errors.length > 0 && (
|
||||
<details className="device-sync-migrate-errors">
|
||||
<summary>{t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })}</summary>
|
||||
<ul>
|
||||
{migrationResult.errors.slice(0, 50).map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
{migrationResult.errors.length > 50 && (
|
||||
<li>… {migrationResult.errors.length - 50} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-migrate-footer">
|
||||
{migrationPhase === 'preview' && (
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={closeMigration}>{t('common.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={executeMigration} disabled={migrationPairs.length === 0}>
|
||||
{t('deviceSync.migrateStart', { defaultValue: 'Start renaming' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(migrationPhase === 'done' || migrationPhase === 'nothing') && (
|
||||
<button className="btn btn-primary" onClick={closeMigration}>{t('common.close')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface DeviceSyncSource {
|
||||
|
||||
interface DeviceSyncState {
|
||||
targetDir: string | null;
|
||||
filenameTemplate: string;
|
||||
sources: DeviceSyncSource[]; // persistent device content list
|
||||
checkedIds: string[]; // currently checked for bulk actions (not persisted)
|
||||
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
|
||||
@@ -17,7 +16,6 @@ interface DeviceSyncState {
|
||||
scanning: boolean; // true while scanning the device
|
||||
|
||||
setTargetDir: (dir: string | null) => void;
|
||||
setFilenameTemplate: (t: string) => void;
|
||||
addSource: (source: DeviceSyncSource) => void;
|
||||
removeSource: (id: string) => void;
|
||||
clearSources: () => void;
|
||||
@@ -35,7 +33,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
targetDir: null,
|
||||
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
|
||||
sources: [],
|
||||
checkedIds: [],
|
||||
pendingDeletion: [],
|
||||
@@ -43,7 +40,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
scanning: false,
|
||||
|
||||
setTargetDir: (dir) => set({ targetDir: dir }),
|
||||
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
|
||||
|
||||
addSource: (source) =>
|
||||
set((s) => ({
|
||||
@@ -97,7 +93,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
name: 'psysonic_device_sync',
|
||||
partialize: (s) => ({
|
||||
targetDir: s.targetDir,
|
||||
filenameTemplate: s.filenameTemplate,
|
||||
sources: s.sources,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -8035,6 +8035,116 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.device-sync-schema-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
.device-sync-schema-code {
|
||||
display: inline-block;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.device-sync-schema-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
max-width: 520px;
|
||||
}
|
||||
.device-sync-migrate-btn {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Migration modal ──────────────────────────────────────────────────── */
|
||||
.device-sync-migrate-modal {
|
||||
min-width: 440px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.device-sync-migrate-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.device-sync-migrate-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.device-sync-migrate-nothing {
|
||||
padding: 12px 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.device-sync-migrate-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.device-sync-migrate-summary .muted { color: var(--text-muted); font-size: 12px; }
|
||||
.device-sync-migrate-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--warning, var(--accent));
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.device-sync-migrate-warning svg { flex-shrink: 0; margin-top: 2px; }
|
||||
.device-sync-migrate-preview-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
.device-sync-migrate-result { display: flex; flex-direction: column; gap: 6px; }
|
||||
.device-sync-migrate-result-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.device-sync-migrate-result-line .positive { color: var(--positive, var(--accent)); }
|
||||
.device-sync-migrate-result-line .danger { color: var(--danger); }
|
||||
.device-sync-migrate-errors {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.device-sync-migrate-errors summary { cursor: pointer; color: var(--accent); }
|
||||
.device-sync-migrate-errors ul { max-height: 180px; overflow-y: auto; padding-left: 18px; margin: 6px 0 0; }
|
||||
.device-sync-migrate-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.device-sync-target-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user