diff --git a/src/ModSync.Core/Parsing/DraftInstructionService.cs b/src/ModSync.Core/Parsing/DraftInstructionService.cs index 33bf1317..d0805c63 100644 --- a/src/ModSync.Core/Parsing/DraftInstructionService.cs +++ b/src/ModSync.Core/Parsing/DraftInstructionService.cs @@ -73,38 +73,57 @@ public static IReadOnlyList GenerateDraftInstructions( foreach (ModComponent component in components) { - if (component.Instructions.Count > 0 || string.IsNullOrWhiteSpace(component.Directions)) + if (component.Instructions.Count > 0) { continue; } - ObservableCollection parsed; - try - { - parsed = parser.ParseInstructions( - component.Directions, - string.IsNullOrWhiteSpace(component.DownloadInstructions) ? null : component.DownloadInstructions, - component); - } - catch (Exception ex) + int added = 0; + + if (!string.IsNullOrWhiteSpace(component.Directions)) { - // Graceful degradation: unparseable prose keeps today's behavior (no instructions drafted). - verbose($"[DraftInstructions] Failed to parse prose for '{component.Name}': {ex.Message}"); - continue; + ObservableCollection parsed; + try + { + parsed = parser.ParseInstructions( + component.Directions, + string.IsNullOrWhiteSpace(component.DownloadInstructions) ? null : component.DownloadInstructions, + component); + } + catch (Exception ex) + { + // Graceful degradation: unparseable prose may still get an InstallationMethod fallback. + verbose($"[DraftInstructions] Failed to parse prose for '{component.Name}': {ex.Message}"); + parsed = new ObservableCollection(); + } + + foreach (Instruction instruction in parsed) + { + if (!TrySanitizeInstruction(instruction)) + { + verbose($"[DraftInstructions] Dropped non-sandboxed draft ({instruction.Action}) for '{component.Name}'"); + continue; + } + + instruction.SetParentComponent(component); + component.Instructions.Add(instruction); + added++; + } } - int added = 0; - foreach (Instruction instruction in parsed) + // K2 Full and similar guides often describe TSLPatcher/HoloPatcher installs via + // Installation Method alone, or preference prose ("recommend the X option") that yields + // no regex hits. Still draft a sandboxed Patcher (or loose-file Move) for review. + if (added == 0) { - if (!TrySanitizeInstruction(instruction)) + Instruction fallback = TryCreateMethodFallbackInstruction(component); + if (fallback != null && TrySanitizeInstruction(fallback)) { - verbose($"[DraftInstructions] Dropped non-sandboxed draft ({instruction.Action}) for '{component.Name}'"); - continue; + fallback.SetParentComponent(component); + component.Instructions.Add(fallback); + added++; + verbose($"[DraftInstructions] Applied InstallationMethod fallback ({fallback.Action}) for '{component.Name}'"); } - - instruction.SetParentComponent(component); - component.Instructions.Add(instruction); - added++; } if (added > 0) @@ -118,6 +137,76 @@ public static IReadOnlyList GenerateDraftInstructions( return results; } + /// + /// When prose does not parse into instructions, invent a minimal sandboxed draft from + /// and/or patcher keywords in Directions. + /// + [CanBeNull] + private static Instruction TryCreateMethodFallbackInstruction([NotNull] ModComponent component) + { + string method = component.InstallationMethod ?? string.Empty; + string directions = component.Directions ?? string.Empty; + string combined = method + " " + directions; + + if (LooksLikePatcherInstall(combined)) + { + return new Instruction + { + Action = Instruction.ActionType.Patcher, + Source = new List { ModDirectoryPlaceholder }, + Destination = KotorDirectoryPlaceholder, + Overwrite = true, + }; + } + + if (LooksLikeLooseFileInstall(method) + && !LooksLikePatcherInstall(directions) + && method.IndexOf("executable", StringComparison.OrdinalIgnoreCase) < 0) + { + string destination = directions.IndexOf("movies", StringComparison.OrdinalIgnoreCase) >= 0 + ? KotorDirectoryPlaceholder + @"\Movies" + : KotorDirectoryPlaceholder + @"\Override"; + + return new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { ModDirectoryPlaceholder + @"\*" }, + Destination = destination, + Overwrite = directions.IndexOf("do not overwrite", StringComparison.OrdinalIgnoreCase) < 0 + && directions.IndexOf("don't overwrite", StringComparison.OrdinalIgnoreCase) < 0, + }; + } + + if (method.IndexOf("executable", StringComparison.OrdinalIgnoreCase) >= 0 + || directions.IndexOf("executable", StringComparison.OrdinalIgnoreCase) >= 0) + { + return new Instruction + { + Action = Instruction.ActionType.Execute, + Source = new List { ModDirectoryPlaceholder + @"\*" }, + Destination = string.Empty, + Overwrite = true, + }; + } + + return null; + } + + private static bool LooksLikePatcherInstall([NotNull] string text) + { + string lower = text.ToLowerInvariant(); + return lower.IndexOf("tslpatcher", StringComparison.Ordinal) >= 0 + || lower.IndexOf("holopatcher", StringComparison.Ordinal) >= 0 + || lower.IndexOf("multi-run", StringComparison.Ordinal) >= 0; + } + + private static bool LooksLikeLooseFileInstall([NotNull] string text) + { + string lower = text.ToLowerInvariant(); + return lower.IndexOf("loose-file", StringComparison.Ordinal) >= 0 + || lower.IndexOf("loose file", StringComparison.Ordinal) >= 0; + } + /// /// Normalizes placeholders and enforces the path-sandboxing rules on a draft instruction: /// every Source entry and any non-empty Destination must start with diff --git a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs index 8bc42d7b..19587ca1 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -26,6 +26,50 @@ public sealed class NaturalLanguageInstructionParser // These patterns cover ALL variations found in KOTOR mod build documentation private static readonly List s_instructionPatterns = new List { + // === K2 FULL / NEOCITIES COMMON PHRASES (high priority) === + // "Install the files within/from the (included) Override folder/directory" + new InstructionPattern( + @"install\s+(?:the\s+)?files?\s+(?:within|from|in)\s+(?:the\s+)?(?:included\s+)?override\s+(?:folder|directory)", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // "Install the contents of every/all folder(s) but X" + new InstructionPattern( + @"install\s+(?:the\s+)?contents?\s+of\s+(?:every|all|each)\s+folders?", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // "Use the files in the \"Alternate Textures\" folder" + new InstructionPattern( + @"use\s+(?:the\s+)?files?\s+in\s+(?:the\s+)?[""'](?[^""']+)[""']\s+folder", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // "Go into the NPC Replacement folder and move all the loose files to the override" + new InstructionPattern( + @"go\s+into\s+(?:the\s+)?[""']?(?[\w\s\-_]+?)[""']?\s+folder\s+and\s+move\s+(?:all\s+)?(?:the\s+)?(?:loose\s+)?files?\s+to\s+(?:the\s+|your\s+)?(?[\w\s\-_/\\]+)", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // "files from this mod go in your movies folder" / "move ... to movies" + new InstructionPattern( + @"(?:files?\s+(?:from\s+this\s+mod\s+)?go\s+in\s+(?:your\s+)?movies\s+folder|(?:move|copy|place|put)\s+(?:the\s+)?(?:files?|contents?|everything)\s+(?:to|into)\s+(?:your\s+)?(?:game'?s?\s+)?movies(?:\s+folder)?)", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // Implied bulk move: "before moving the files to your override" / "moving to your Override" + new InstructionPattern( + @"(?:before\s+)?mov(?:e|ing)\s+(?:the\s+)?(?:files?|contents?)\s+to\s+(?:your\s+)?(?:game'?s?\s+)?(?override|movies)(?:\s+(?:folder|directory))?", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // "Download the .tpc/.tga variant" then install to override (common Ultimate HR phrasing) + new InstructionPattern( + @"download\s+the\s+\.?(?tpc|tga)\s+variant", + Instruction.ActionType.Move, + RegexOptions.IgnoreCase + ), + // === HIGHLY SPECIFIC MOVE/COPY PATTERNS (must come first) === // "Move everything from X, Y, and Z folders to override" new InstructionPattern( @@ -206,6 +250,36 @@ public sealed class NaturalLanguageInstructionParser @"run\s+the\s+holopatcher\s+executable(?:\.\s+select\s+the\s+(?