From 8ae6b9c28bcece87835feb21c270d0dc1de6678d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 29 Oct 2025 20:53:03 +0100 Subject: [PATCH] Add support for importing loadouts from build XML Adds support for importing only loadouts (item sets, skill sets, tree specs, config sets) from a build XML to current build without overwriting anything else. Signed-off-by: Tomas Slusny --- src/Classes/ConfigTab.lua | 25 ++--- src/Classes/ImportTab.lua | 22 ++++- src/Classes/ItemsTab.lua | 64 ++++++++---- src/Classes/SkillsTab.lua | 65 +++++++------ src/Classes/TreeTab.lua | 16 ++- src/Modules/Build.lua | 199 +++++++++++++++++++++++++++++++++++++- 6 files changed, 320 insertions(+), 71 deletions(-) diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index 28de4d9598..423022fc86 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -611,10 +611,12 @@ local ConfigTabClass = newClass("ConfigTab", "UndoHandler", "ControlHost", "Cont self.controls.scrollBar = new("ScrollBarControl", {"TOPRIGHT",self,"TOPRIGHT"}, {0, 0, 18, 0}, 50, "VERTICAL", true) end) -function ConfigTabClass:Load(xml, fileName) - self.activeConfigSetId = 1 - self.configSets = { } - self.configSetOrderList = { 1 } +function ConfigTabClass:Load(xml, fileName, appendConfigs) + if not appendConfigs then + self.activeConfigSetId = 0 + self.configSets = { } + self.configSetOrderList = { } + end local function setInputAndPlaceholder(node, configSetId) if node.elem == "Input" then @@ -660,24 +662,25 @@ function ConfigTabClass:Load(xml, fileName) if xml.empty then self:NewConfigSet(1, "Default") end - for index, node in ipairs(xml) do + for _, node in ipairs(xml) do if node.elem ~= "ConfigSet" then if not self.configSets[1] then self:NewConfigSet(1, "Default") end setInputAndPlaceholder(node, 1) else - local configSetId = tonumber(node.attrib.id) - self:NewConfigSet(configSetId, node.attrib.title or "Default") - self.configSetOrderList[index] = configSetId + local configSet = self:NewConfigSet(not appendConfigs and tonumber(node.attrib.id) or nil, node.attrib.title or "Default") + t_insert(self.configSetOrderList, configSet.id) for _, child in ipairs(node) do - setInputAndPlaceholder(child, configSetId) + setInputAndPlaceholder(child, configSet.id) end end end - self:SetActiveConfigSet(tonumber(xml.attrib.activeConfigSet) or 1) - self:ResetUndo() + if not appendConfigs then + self:SetActiveConfigSet(tonumber(xml.attrib.activeConfigSet) or 1) + self:ResetUndo() + end end function ConfigTabClass:GetDefaultState(var, varType) diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 2dadf901f3..ba8fe2ae7d 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -314,6 +314,24 @@ You can get this from your web browser's cookies while logged into the Path of E self.build:Init(self.build.dbFileName, self.build.buildName, self.importCodeXML, false, self.importCodeSite and self.controls.importCodeIn.buf or nil) self.build.viewMode = "TREE" end) + elseif self.controls.importCodeMode.selIndex == 3 then + local controls = { } + t_insert(controls, new("LabelControl", nil, {0, 20, 0, 16}, colorCodes.WARNING.."Warning:^7 Importing many loadouts into the same build")) + t_insert(controls, new("LabelControl", nil, {0, 36, 0, 16}, "may cause performance issues. Use with caution.")) + t_insert(controls, new("LabelControl", nil, {0, 64, 0, 16}, "^7Prefix for imported loadouts:")) + controls.prefix = new("EditControl", nil, {0, 84, 350, 20}, "Imported - ", nil, nil, 50) + controls.import = new("ButtonControl", nil, {-45, 114, 80, 20}, "Import", function() + local prefix = controls.prefix.buf + if prefix == "" then + prefix = "Imported - " + end + main:ClosePopup() + self.build:ImportLoadouts(self.importCodeXML, prefix) + end) + t_insert(controls, new("ButtonControl", nil, {45, 114, 80, 20}, "Cancel", function() + main:ClosePopup() + end)) + main:OpenPopup(380, 144, "Import Loadouts", controls, "import") else self.build:Shutdown() self.build:Init(false, "Imported build", self.importCodeXML, false, self.importCodeSite and self.controls.importCodeIn.buf or nil) @@ -331,7 +349,7 @@ You can get this from your web browser's cookies while logged into the Path of E self.controls.importCodeState.label = function() return self.importCodeDetail or "" end - self.controls.importCodeMode = new("DropDownControl", {"TOPLEFT",self.controls.importCodeIn,"BOTTOMLEFT"}, {0, 4, 160, 20}, { "Import to this build", "Import to a new build" }) + self.controls.importCodeMode = new("DropDownControl", {"TOPLEFT",self.controls.importCodeIn,"BOTTOMLEFT"}, {0, 4, 160, 20}, { "Import to this build", "Import to a new build", "Import loadouts only" }) self.controls.importCodeMode.enabled = function() return self.build.dbFileName and self.importCodeValid end @@ -1214,4 +1232,4 @@ function ImportTabClass:SetPredefinedBuildName() local charData = charSelect.list[charSelect.selIndex].char local charName = charData.name main.predefinedBuildName = accountName.." - "..charName -end \ No newline at end of file +end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index d1b08541f8..401e3af260 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -958,15 +958,24 @@ holding Shift will put it in the second.]]) self.lastSlot = self.slots[baseSlots[#baseSlots]] end) -function ItemsTabClass:Load(xml, dbFileName) - self.activeItemSetId = 0 - self.itemSets = { } - self.itemSetOrderList = { } - self.tradeQuery.statSortSelectionList = { } +function ItemsTabClass:Load(xml, dbFileName, appendItems) + if not appendItems then + self.activeItemSetId = 0 + self.itemSets = { } + self.itemSetOrderList = { } + self.tradeQuery.statSortSelectionList = { } + end + + local itemIdMap = { } + local itemSetIdMap = { } for _, node in ipairs(xml) do if node.elem == "Item" then local item = new("Item", "") - item.id = tonumber(node.attrib.id) + local itemId = tonumber(node.attrib.id) + if not appendItems then + item.id = itemId + end + item.variant = tonumber(node.attrib.variant) if node.attrib.variantAlt then item.hasAltVariant = true @@ -1009,8 +1018,13 @@ function ItemsTabClass:Load(xml, dbFileName) end if item.base then item:BuildModList() - self.items[item.id] = item - t_insert(self.itemOrderList, item.id) + if appendItems then + self:AddItem(item, true) + itemIdMap[itemId] = item.id + else + self.items[item.id] = item + t_insert(self.itemOrderList, item.id) + end end -- Below is OBE and left for legacy compatibility (all Slots are part of ItemSets now) elseif node.elem == "Slot" then @@ -1023,14 +1037,22 @@ function ItemsTabClass:Load(xml, dbFileName) end end elseif node.elem == "ItemSet" then - local itemSet = self:NewItemSet(tonumber(node.attrib.id)) + local oldItemSetId = tonumber(node.attrib.id) + local itemSet = self:NewItemSet(not appendItems and oldItemSetId or nil) + if appendItems then + itemSetIdMap[oldItemSetId] = itemSet.id + end itemSet.title = node.attrib.title itemSet.useSecondWeaponSet = node.attrib.useSecondWeaponSet == "true" for _, child in ipairs(node) do if child.elem == "Slot" then local slotName = child.attrib.name or "" if itemSet[slotName] then - itemSet[slotName].selItemId = tonumber(child.attrib.itemId) + local itemId = tonumber(child.attrib.itemId) + if appendItems and itemIdMap[itemId] then + itemId = itemIdMap[itemId] + end + itemSet[slotName].selItemId = itemId itemSet[slotName].active = child.attrib.active == "true" itemSet[slotName].pbURL = child.attrib.itemPbURL or "" end @@ -1051,16 +1073,20 @@ function ItemsTabClass:Load(xml, dbFileName) end end end - if not self.itemSetOrderList[1] then - self.activeItemSet = self:NewItemSet(1) - self.activeItemSet.useSecondWeaponSet = xml.attrib.useSecondWeaponSet == "true" - self.itemSetOrderList[1] = 1 - end - self:SetActiveItemSet(tonumber(xml.attrib.activeItemSet) or 1) - if xml.attrib.showStatDifferences then - self.showStatDifferences = xml.attrib.showStatDifferences == "true" + if not appendItems then + if not self.itemSetOrderList[1] then + self.activeItemSet = self:NewItemSet(1) + self.activeItemSet.useSecondWeaponSet = xml.attrib.useSecondWeaponSet == "true" + self.itemSetOrderList[1] = 1 + end + self:SetActiveItemSet(tonumber(xml.attrib.activeItemSet) or 1) + if xml.attrib.showStatDifferences then + self.showStatDifferences = xml.attrib.showStatDifferences == "true" + end + self:ResetUndo() + else + return itemIdMap, itemSetIdMap end - self:ResetUndo() end function ItemsTabClass:Save(xml) diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index 65a7e498f6..734cb795d5 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -374,33 +374,36 @@ function SkillsTabClass:LoadSkill(node, skillSetId) t_insert(self.skillSets[skillSetId].socketGroupList, socketGroup) end -function SkillsTabClass:Load(xml, fileName) - self.activeSkillSetId = 0 - self.skillSets = { } - self.skillSetOrderList = { } - -- Handle legacy configuration settings when loading `defaultGemLevel` - if xml.attrib.matchGemLevelToCharacterLevel == "true" then - self.controls.defaultLevel:SelByValue("characterLevel", "gemLevel") - elseif type(xml.attrib.defaultGemLevel) == "string" and tonumber(xml.attrib.defaultGemLevel) == nil then - self.controls.defaultLevel:SelByValue(xml.attrib.defaultGemLevel, "gemLevel") - else - self.controls.defaultLevel:SelByValue("normalMaximum", "gemLevel") - end - self.defaultGemLevel = self.controls.defaultLevel:GetSelValueByKey("gemLevel") - self.defaultGemQuality = m_max(m_min(tonumber(xml.attrib.defaultGemQuality) or 0, 23), 0) - self.controls.defaultQuality:SetText(self.defaultGemQuality or "") - if xml.attrib.sortGemsByDPS then - self.sortGemsByDPS = xml.attrib.sortGemsByDPS == "true" - end - self.controls.sortGemsByDPS.state = self.sortGemsByDPS - if xml.attrib.showAltQualityGems then - self.showAltQualityGems = xml.attrib.showAltQualityGems == "true" - end - self.controls.showAltQualityGems.state = self.showAltQualityGems - self.controls.showSupportGemTypes:SelByValue(xml.attrib.showSupportGemTypes or "ALL", "show") - self.controls.sortGemsByDPSFieldControl:SelByValue(xml.attrib.sortGemsByDPSField or "CombinedDPS", "type") - self.showSupportGemTypes = self.controls.showSupportGemTypes:GetSelValueByKey("show") - self.sortGemsByDPSField = self.controls.sortGemsByDPSFieldControl:GetSelValueByKey("type") +function SkillsTabClass:Load(xml, fileName, appendSkills) + if not appendSkills then + self.activeSkillSetId = 0 + self.skillSets = { } + self.skillSetOrderList = { } + -- Handle legacy configuration settings when loading `defaultGemLevel` + if xml.attrib.matchGemLevelToCharacterLevel == "true" then + self.controls.defaultLevel:SelByValue("characterLevel", "gemLevel") + elseif type(xml.attrib.defaultGemLevel) == "string" and tonumber(xml.attrib.defaultGemLevel) == nil then + self.controls.defaultLevel:SelByValue(xml.attrib.defaultGemLevel, "gemLevel") + else + self.controls.defaultLevel:SelByValue("normalMaximum", "gemLevel") + end + self.defaultGemLevel = self.controls.defaultLevel:GetSelValueByKey("gemLevel") + self.defaultGemQuality = m_max(m_min(tonumber(xml.attrib.defaultGemQuality) or 0, 23), 0) + self.controls.defaultQuality:SetText(self.defaultGemQuality or "") + if xml.attrib.sortGemsByDPS then + self.sortGemsByDPS = xml.attrib.sortGemsByDPS == "true" + end + self.controls.sortGemsByDPS.state = self.sortGemsByDPS + if xml.attrib.showAltQualityGems then + self.showAltQualityGems = xml.attrib.showAltQualityGems == "true" + end + self.controls.showAltQualityGems.state = self.showAltQualityGems + self.controls.showSupportGemTypes:SelByValue(xml.attrib.showSupportGemTypes or "ALL", "show") + self.controls.sortGemsByDPSFieldControl:SelByValue(xml.attrib.sortGemsByDPSField or "CombinedDPS", "type") + self.showSupportGemTypes = self.controls.showSupportGemTypes:GetSelValueByKey("show") + self.sortGemsByDPSField = self.controls.sortGemsByDPSFieldControl:GetSelValueByKey("type") + end + for _, node in ipairs(xml) do if node.elem == "Skill" then -- Old format, initialize skill sets if needed @@ -412,7 +415,7 @@ function SkillsTabClass:Load(xml, fileName) end if node.elem == "SkillSet" then - local skillSet = self:NewSkillSet(tonumber(node.attrib.id)) + local skillSet = self:NewSkillSet(not appendSkills and tonumber(node.attrib.id) or nil) skillSet.title = node.attrib.title t_insert(self.skillSetOrderList, skillSet.id) for _, subNode in ipairs(node) do @@ -420,8 +423,10 @@ function SkillsTabClass:Load(xml, fileName) end end end - self:SetActiveSkillSet(tonumber(xml.attrib.activeSkillSet) or 1) - self:ResetUndo() + if not appendSkills then + self:SetActiveSkillSet(tonumber(xml.attrib.activeSkillSet) or 1) + self:ResetUndo() + end end function SkillsTabClass:Save(xml) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index c19edf0223..2d3e662fd6 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -456,8 +456,11 @@ function TreeTabClass:GetSpecList() return newSpecList end -function TreeTabClass:Load(xml, dbFileName) - self.specList = { } +function TreeTabClass:Load(xml, dbFileName, appendSpecs) + if not appendSpecs then + self.specList = { } + end + if xml.elem == "Spec" then -- Import single spec from old build self.specList[1] = new("PassiveSpec", self.build, defaultTreeVersion) @@ -473,16 +476,19 @@ function TreeTabClass:Load(xml, dbFileName) main:OpenMessagePopup("Unknown Passive Tree Version", "The build you are trying to load uses an unrecognised version of the passive skill tree.\nYou may need to update the program before loading this build.") return true end + local newSpec = new("PassiveSpec", self.build, node.attrib.treeVersion or defaultTreeVersion) newSpec:Load(node, dbFileName) t_insert(self.specList, newSpec) end end end - if not self.specList[1] then - self.specList[1] = new("PassiveSpec", self.build, latestTreeVersion) + if not appendSpecs then + if not self.specList[1] then + self.specList[1] = new("PassiveSpec", self.build, latestTreeVersion) + end + self:SetActiveSpec(tonumber(xml.attrib.activeSpec) or 1) end - self:SetActiveSpec(tonumber(xml.attrib.activeSpec) or 1) end function TreeTabClass:PostLoad() diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 769445dfcc..834481e181 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -1824,17 +1824,26 @@ do end end -function buildMode:LoadDB(xmlText, fileName) +function buildMode:ParseXML(xmlText, fileName) -- Parse the XML local dbXML, errMsg = common.xml.ParseXML(xmlText) if errMsg and errMsg:match(".*file returns nil") then main:OpenCloudErrorPopup(fileName) - return true + return nil elseif errMsg then launch:ShowErrMsg("^1"..errMsg) - return true - elseif dbXML[1].elem ~= "PathOfBuilding" then + return nil + elseif dbXML and dbXML[1].elem ~= "PathOfBuilding" then launch:ShowErrMsg("^1Error parsing '%s': 'PathOfBuilding' root element missing", fileName) + return nil + end + return dbXML +end + +function buildMode:LoadDB(xmlText, fileName) + -- Parse the XML + local dbXML = self:ParseXML(xmlText, fileName) + if not dbXML then return true end @@ -1879,6 +1888,188 @@ function buildMode:LoadDBFile() return self:LoadDB(xmlText, self.dbFileName) end +function buildMode:ImportLoadouts(xmlText, prefix) + -- Parse the XML + local dbXML = self:ParseXML(xmlText, self.dbFileName) + if not dbXML then + return true + end + + -- Collect existing names from current build per category + local usedItemSetNames = {} + for _, itemSet in pairs(self.itemsTab.itemSets) do + usedItemSetNames[itemSet.title or "Default"] = true + end + local usedSkillSetNames = {} + for _, skillSet in pairs(self.skillsTab.skillSets) do + usedSkillSetNames[skillSet.title or "Default"] = true + end + local usedTreeSpecNames = {} + for _, spec in ipairs(self.treeTab.specList) do + usedTreeSpecNames[spec.title or "Default"] = true + end + local usedConfigSetNames = {} + for _, configSet in pairs(self.configTab.configSets) do + usedConfigSetNames[configSet.title or "Default"] = true + end + + -- Pre-process the XML to add prefix and ensure unique names per category + local function addPrefixAndMakeUnique(node, usedNames) + local titleAttr = "title" + if node.attrib and node.attrib[titleAttr] then + local title = prefix .. (node.attrib[titleAttr] or "") + local baseName = title + local suffix = 2 + while usedNames[title] do + title = baseName .. " (" .. suffix .. ")" + suffix = suffix + 1 + end + usedNames[title] = true + node.attrib[titleAttr] = title + end + end + + -- Extract relevant sections from imported XML + local itemsSection = nil + local skillsSection = nil + local treeSection = nil + local configSection = nil + local itemSetCount = 0 + local skillSetCount = 0 + local treeSpecCount = 0 + local configSetCount = 0 + for _, node in ipairs(dbXML[1]) do + if type(node) == "table" then + if node.elem == "Items" then + itemsSection = node + for _, n in ipairs(itemsSection) do + if n.elem == "ItemSet" then + addPrefixAndMakeUnique(n, usedItemSetNames) + itemSetCount = itemSetCount + 1 + end + end + elseif node.elem == "Skills" then + skillsSection = node + for _, n in ipairs(skillsSection) do + if n.elem == "SkillSet" then + addPrefixAndMakeUnique(n, usedSkillSetNames) + skillSetCount = skillSetCount + 1 + end + end + elseif node.elem == "Tree" then + treeSection = node + for _, n in ipairs(treeSection) do + if n.elem == "Spec" then + addPrefixAndMakeUnique(n, usedTreeSpecNames) + treeSpecCount = treeSpecCount + 1 + end + end + elseif node.elem == "Config" then + configSection = node + for _, n in ipairs(configSection) do + if n.elem == "ConfigSet" then + addPrefixAndMakeUnique(n, usedConfigSetNames) + configSetCount = configSetCount + 1 + end + end + end + end + end + + if itemSetCount == 0 and skillSetCount == 0 and treeSpecCount == 0 and configSetCount == 0 then + main:OpenMessagePopup("Import Loadouts", "No loadouts (item sets, skill sets, tree specs, or config sets) found in the imported build.") + return + end + + local itemIdMap = { } + local itemSetIdMap = { } + if itemsSection then + -- Load items and return mapping of old to new item and item set IDs + itemIdMap, itemSetIdMap = self.itemsTab:Load(itemsSection, self.dbFileName, true) + end + + if skillsSection then + -- Remap item set references in skills + if next(itemSetIdMap) then + for _, skillSetNode in ipairs(skillsSection) do + if skillSetNode.elem == "SkillSet" then + for _, skillNode in ipairs(skillSetNode) do + if skillNode.elem == "Skill" then + for _, gemNode in ipairs(skillNode) do + if gemNode.elem == "Gem" then + local oldItemSetId = tonumber(gemNode.attrib.skillMinionItemSet) + if oldItemSetId and itemSetIdMap[oldItemSetId] then + gemNode.attrib.skillMinionItemSet = tostring(itemSetIdMap[oldItemSetId]) + end + local oldItemSetCalcsId = tonumber(gemNode.attrib.skillMinionItemSetCalcs) + if oldItemSetCalcsId and itemSetIdMap[oldItemSetCalcsId] then + gemNode.attrib.skillMinionItemSetCalcs = tostring(itemSetIdMap[oldItemSetCalcsId]) + end + end + end + end + end + end + end + end + + -- Load skills + self.skillsTab:Load(skillsSection, self.dbFileName, true) + end + + if treeSection then + -- Remap jewel socket item IDs in tree + if next(itemIdMap) then + for _, specNode in ipairs(treeSection) do + if specNode.elem == "Spec" then + for _, child in ipairs(specNode) do + if child.elem == "Sockets" then + for _, socket in ipairs(child) do + if socket.elem == "Socket" then + local oldItemId = tonumber(socket.attrib.itemId) + if oldItemId and itemIdMap[oldItemId] then + socket.attrib.itemId = tostring(itemIdMap[oldItemId]) + end + end + end + end + end + end + end + end + + -- Load tree specs + self.treeTab:Load(treeSection, self.dbFileName, true) + self.treeTab:PostLoad() + end + + if configSection then + -- Load config sets + self.configTab:Load(configSection, self.dbFileName, true) + end + + -- Mark build as modified + self.modFlag = true + self.buildFlag = true + self:SyncLoadouts() + + -- Show success message + local message = "Successfully imported:\n" + if itemSetCount > 0 then + message = message .. "- " .. itemSetCount .. " item set(s)\n" + end + if skillSetCount > 0 then + message = message .. "- " .. skillSetCount .. " skill set(s)\n" + end + if treeSpecCount > 0 then + message = message .. "- " .. treeSpecCount .. " tree spec(s)\n" + end + if configSetCount > 0 then + message = message .. "- " .. configSetCount .. " config set(s)\n" + end + main:OpenMessagePopup("Import Complete", message) +end + function buildMode:SaveDB(fileName) local dbXML = { elem = "PathOfBuilding" }