Add Path Mapping UI with Simple Mapping and Regex Rules modes

- Updated frontend/types.ts with new types: ReplacementRule, PathMappingRules, PathMappingMode, PathMappingConfig
- Replaced StrategySelector.tsx with new UI featuring:
  - Simple Mapping tab for local/cloud path pairs
  - Regex Rules tab with 4 rule groups (localPre, localPost, remotePre, remotePost)
  - MappingGroupEditor sub-component for editing rule lists
- Updated App.tsx to use PathMappingConfig state instead of RegexReplacement[]
- Updated api.ts to handle new PathMappingConfig structure
- Updated backend config.py with path_mapping field support
- Added /api/settings/path-mapping endpoint in main.py

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-30 22:11:29 +00:00
parent c18ff5b2ef
commit 350f1d97e6
6 changed files with 567 additions and 188 deletions
+61 -13
View File
@@ -1,4 +1,4 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy, ScheduleSettings } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
@@ -41,21 +41,69 @@ const mapLibrary = (item: any): PlexLibrary => ({
type: item.type ?? 'artist',
});
const mapRegexRules = (rules: any[]): RegexReplacement[] =>
// Helper function to map raw rules array to ReplacementRule[]
const mapReplacementRules = (rules: any[]): ReplacementRule[] =>
(rules || []).map((rule, index) => ({
id: rule.id || `${rule.pattern || 'rule'}-${index}`,
pattern: rule.pattern || '',
replacement: rule.replacement || '',
id: rule.id || `${rule.search || 'rule'}-${index}-${Date.now()}`,
search: rule.search || rule.pattern || '',
replace: rule.replace || rule.replacement || '',
}));
// Helper function to map API path_mapping response to PathMappingConfig
const mapPathMappingConfig = (data: any): PathMappingConfig => {
const defaultConfig: PathMappingConfig = {
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
};
if (!data || !data.path_mapping) {
return defaultConfig;
}
const pm = data.path_mapping;
return {
mode: pm.mode === 'REGEX' ? PathMappingMode.REGEX : PathMappingMode.SIMPLE,
simple: mapReplacementRules(pm.simple || []),
regex: {
localPre: mapReplacementRules(pm.regex?.localPre || pm.regex?.local_pre || []),
localPost: mapReplacementRules(pm.regex?.localPost || pm.regex?.local_post || []),
remotePre: mapReplacementRules(pm.regex?.remotePre || pm.regex?.remote_pre || []),
remotePost: mapReplacementRules(pm.regex?.remotePost || pm.regex?.remote_post || [])
}
};
};
// Helper function to convert PathMappingConfig to API format
const pathMappingToApi = (config: PathMappingConfig) => {
const rulesToApi = (rules: ReplacementRule[]) =>
rules.map(({ search, replace }) => ({ search, replace }));
return {
mode: config.mode,
simple: rulesToApi(config.simple),
regex: {
local_pre: rulesToApi(config.regex.localPre),
local_post: rulesToApi(config.regex.localPost),
remote_pre: rulesToApi(config.regex.remotePre),
remote_post: rulesToApi(config.regex.remotePost)
}
};
};
export const apiService = {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>> {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>> {
const response = await fetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const regex = mapRegexRules(result.data.path_rules || []);
const pathMapping = mapPathMappingConfig(result.data);
const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '',
@@ -63,9 +111,9 @@ export const apiService = {
token: result.data.token || '',
libraryName: result.data.library_name || '',
};
return { status: 'success', data: { strategy, regex, connection, localPath: result.data.local_path || '' } };
return { status: 'success', data: { strategy, pathMapping, connection, localPath: result.data.local_path || '' } };
}
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>;
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>;
},
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
@@ -78,9 +126,9 @@ export const apiService = {
return handleResponse(response);
},
async saveRegexRules(replacements: RegexReplacement[]): Promise<ApiResponse<{ rules: RegexReplacement[] }>> {
const payload = { rules: replacements.map(({ pattern, replacement }) => ({ pattern, replacement })) };
const response = await fetch(`${API_BASE}/api/settings/regex-rules`, {
async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = pathMappingToApi(config);
const response = await fetch(`${API_BASE}/api/settings/path-mapping`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -170,7 +218,7 @@ export const apiService = {
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
},
async syncPlaylists(strategy: SyncStrategy, _regexRules: RegexReplacement[], localPath?: string): Promise<ApiResponse<null>> {
async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },