4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 304e973db1 Fix Simple Mapping Windows path handling with double backslashes
- Normalize Windows paths by replacing \\\\ with \\ before pattern matching
- Escape backslashes in replacement strings for post-processing
- Add debug logging to help diagnose path matching issues

Root cause: UI stored escaped paths (\\\\Koha9-Main\\\\Music) but playlist
content uses single backslashes (\\Koha9-Main\\Music). Now normalizes paths
before compiling regex patterns.

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 22:14:03 +00:00
copilot-swe-agent[bot] 6c84112d29 Reset config.json to clean defaults for testing
Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 14:36:51 +00:00
copilot-swe-agent[bot] 1131b81454 Fix Simple Mapping not applying during sync - preserve id field
Root cause: The UUID (id) field was being stripped when saving path mapping:
- Backend ReplacementRule model was missing id field
- Frontend pathMappingToApi() didn't include id in conversion
- Backend update_path_mapping endpoint didn't save id

Changes:
- Add id field to ReplacementRule model in main.py
- Include id when saving path mapping rules in update_path_mapping
- Include id in frontend pathMappingToApi conversion

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 13:46:37 +00:00
copilot-swe-agent[bot] 6a1780bcee Fix Simple Mapping to use proper UUIDs for mapping IDs
- Add generateUUID() function using crypto.randomUUID() with fallback
- Update handleAdd to use UUID instead of Date.now() + Math.random()
- UUIDs are now properly validated in backend to prevent injection
- mapping_id is persisted when creating mapping pairs for reuse

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 12:44:27 +00:00
5 changed files with 75 additions and 14 deletions
+18 -2
View File
@@ -2,11 +2,27 @@
"theme": "auto", "theme": "auto",
"token": "", "token": "",
"server_url": "", "server_url": "",
"server_port": "32400",
"server_scheme": "https", "server_scheme": "https",
"server_port": "32400",
"timeout": 9, "timeout": 9,
"library_name": "", "library_name": "",
"sync_mode": "merge_local_primary", "sync_mode": "merge_local_primary",
"local_path": "playlist", "local_path": "playlist",
"path_rules": [] "path_rules": [],
"path_mapping": {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
},
"schedule_mode": "DISABLED",
"schedule_cron": "",
"schedule_daily_time": "02:00",
"schedule_weekly_days": [0],
"schedule_weekly_time": "03:00",
"schedule_auto_watch": false
} }
+6 -5
View File
@@ -93,6 +93,7 @@ class RegexRule(BaseModel):
class ReplacementRule(BaseModel): class ReplacementRule(BaseModel):
id: str = ""
search: str search: str
replace: str = "" replace: str = ""
@@ -404,12 +405,12 @@ async def update_regex_rules(payload: RegexRulePayload):
async def update_path_mapping(payload: PathMappingPayload): async def update_path_mapping(payload: PathMappingPayload):
path_mapping_dict = { path_mapping_dict = {
"mode": payload.mode, "mode": payload.mode,
"simple": [{"search": rule.search, "replace": rule.replace} for rule in payload.simple], "simple": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.simple],
"regex": { "regex": {
"local_pre": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.local_pre], "local_pre": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.local_pre],
"local_post": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.local_post], "local_post": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.local_post],
"remote_pre": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_pre], "remote_pre": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_pre],
"remote_post": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_post], "remote_post": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_post],
} }
} }
server_config.set_and_save_config(path_mapping=path_mapping_dict) server_config.set_and_save_config(path_mapping=path_mapping_dict)
+34 -4
View File
@@ -683,34 +683,58 @@ def _compile_simple_mapping_rules(simple_mappings: list[dict]) -> CompiledRegexR
logger.warning(f"Skipping mapping with missing cloud path: {mapping}") logger.warning(f"Skipping mapping with missing cloud path: {mapping}")
continue continue
# Normalize Windows paths: Replace double backslashes with single backslashes
# This handles cases where users enter escaped paths like \\Koha9-Main\\Music
# when the actual playlist content uses \Koha9-Main\Music
original_local = local_path
original_cloud = cloud_path
local_path = local_path.replace("\\\\", "\\")
cloud_path = cloud_path.replace("\\\\", "\\")
if local_path != original_local or cloud_path != original_cloud:
logger.info(f"Normalized Windows paths:")
logger.info(f" Local: {repr(original_local)} -> {repr(local_path)}")
logger.info(f" Cloud: {repr(original_cloud)} -> {repr(cloud_path)}")
# Create a unique placeholder using the validated UUID # Create a unique placeholder using the validated UUID
# Using special markers to prevent conflicts with actual paths # Using special markers to prevent conflicts with actual paths
placeholder = f"__MAPPING__{mapping_id}__" placeholder = f"__MAPPING__{mapping_id}__"
# Debug logging for path mapping
logger.debug(f"Simple mapping pair:")
logger.debug(f" Local path (search): {repr(local_path)}")
logger.debug(f" Cloud path (replace): {repr(cloud_path)}")
logger.debug(f" Placeholder: {placeholder}")
# Pre-processing rules (use re.escape to treat paths as literal strings) # Pre-processing rules (use re.escape to treat paths as literal strings)
# local_pre: Replace local path with placeholder # local_pre: Replace local path with placeholder
local_pattern = re.escape(local_path)
logger.debug(f" Local pre pattern: {repr(local_pattern)}")
local_pre_rules.append({ local_pre_rules.append({
"pattern": re.escape(local_path), "pattern": local_pattern,
"replacement": placeholder "replacement": placeholder
}) })
# remote_pre: Replace cloud path with placeholder # remote_pre: Replace cloud path with placeholder
remote_pattern = re.escape(cloud_path)
logger.debug(f" Remote pre pattern: {repr(remote_pattern)}")
remote_pre_rules.append({ remote_pre_rules.append({
"pattern": re.escape(cloud_path), "pattern": remote_pattern,
"replacement": placeholder "replacement": placeholder
}) })
# Post-processing rules # Post-processing rules
# local_post: Replace placeholder with local path # local_post: Replace placeholder with local path
# Note: In regex replacement, backslashes need to be escaped
local_post_rules.append({ local_post_rules.append({
"pattern": re.escape(placeholder), "pattern": re.escape(placeholder),
"replacement": local_path "replacement": local_path.replace("\\", "\\\\")
}) })
# remote_post: Replace placeholder with cloud path # remote_post: Replace placeholder with cloud path
remote_post_rules.append({ remote_post_rules.append({
"pattern": re.escape(placeholder), "pattern": re.escape(placeholder),
"replacement": cloud_path "replacement": cloud_path.replace("\\", "\\\\")
}) })
logger.info(f"Compiled {len(local_pre_rules)} simple mapping pairs into rules") logger.info(f"Compiled {len(local_pre_rules)} simple mapping pairs into rules")
@@ -803,13 +827,19 @@ def sync_all_playlists(
# Apply pre-processing rules for REGEX or SIMPLE mode # Apply pre-processing rules for REGEX or SIMPLE mode
# base_text doesn't need pre-processing as it's the normalized state # base_text doesn't need pre-processing as it's the normalized state
if local_text is not None and compiled_rules.local_pre: if local_text is not None and compiled_rules.local_pre:
logger.debug(f"Applying local_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(local_text[:200])}")
local_text = preprocess_playlist_text( local_text = preprocess_playlist_text(
local_text, [], compiled_rules.local_pre local_text, [], compiled_rules.local_pre
) )
logger.debug(f" After preprocessing (first 200 chars): {repr(local_text[:200])}")
if remote_text and compiled_rules.remote_pre: if remote_text and compiled_rules.remote_pre:
logger.debug(f"Applying remote_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(remote_text[:200])}")
remote_text = preprocess_playlist_text( remote_text = preprocess_playlist_text(
remote_text, [], compiled_rules.remote_pre remote_text, [], compiled_rules.remote_pre
) )
logger.debug(f" After preprocessing (first 200 chars): {repr(remote_text[:200])}")
elif legacy_compiled_rules: elif legacy_compiled_rules:
# Use legacy preprocessing for all texts # Use legacy preprocessing for all texts
base_text = preprocess_playlist_text( base_text = preprocess_playlist_text(
+15 -1
View File
@@ -22,6 +22,20 @@ import {
Code2 Code2
} from 'lucide-react'; } from 'lucide-react';
// Generate a UUID for mapping rules
const generateUUID = (): string => {
// Use crypto.randomUUID() if available (modern browsers)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback to manual UUID v4 generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
interface StrategyOption { interface StrategyOption {
value: SyncStrategy; value: SyncStrategy;
label: string; label: string;
@@ -137,7 +151,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
const handleAdd = () => { const handleAdd = () => {
if (isLocked) return; if (isLocked) return;
const newId = Date.now().toString() + Math.random().toString(); const newId = generateUUID();
onChange([...rules, { id: newId, search: '', replace: '' }]); onChange([...rules, { id: newId, search: '', replace: '' }]);
}; };
+1 -1
View File
@@ -82,7 +82,7 @@ const mapPathMappingConfig = (data: any): PathMappingConfig => {
// Helper function to convert PathMappingConfig to API format // Helper function to convert PathMappingConfig to API format
const pathMappingToApi = (config: PathMappingConfig) => { const pathMappingToApi = (config: PathMappingConfig) => {
const rulesToApi = (rules: ReplacementRule[]) => const rulesToApi = (rules: ReplacementRule[]) =>
rules.map(({ search, replace }) => ({ search, replace })); rules.map(({ id, search, replace }) => ({ id, search, replace }));
return { return {
mode: config.mode, mode: config.mode,