From 457420f90c8072ec6b3524ad8991446ea1a491c9 Mon Sep 17 00:00:00 2001 From: Xurkon Date: Sun, 15 Mar 2026 19:48:45 -0500 Subject: [PATCH] feat: add Database options tab with import/export/cleanup New files: - Modules/QuestieLearnerExport.lua: serialize/compress/encode pipeline using AceSerializer + LibDeflate:CompressDeflate(level=9) + EncodeForPrint. QxLD:! string format. Export(), ExportAll(), ValidateImport(), MergeImport(), DryRunPrune(), Prune(). - Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua: new Database tab with: - Live learned data stats (per server) - Per-type learner toggles (NPCs/Quests/Objects/Items/Broadcast) - Export current server / Export all servers buttons (opens scrollable dialog) - Import window (paste -> Validate -> Import with diff summary) - Dry-run preview and Prune Now cleanup buttons - Full reset button (confirm-gated) - Contribute section with step-by-step GitHub submission instructions Wiring: - QuestieOptions.lua: database_tab initialized and registered in args - All TOC files: QuestieLearnerExport.lua added before options, DatabaseTab lua added --- .../DatabaseTab/QuestieOptionsDatabase.lua | 441 ++++++++++++++++++ Modules/Options/QuestieOptions.lua | 3 + Modules/QuestieLearnerExport.lua | 375 +++++++++++++++ Questie-X-Classic.toc | 3 + Questie-X-TBC.toc | 3 + Questie-X-Turtle.toc | 3 + Questie-X.toc | 4 + 7 files changed, 832 insertions(+) create mode 100644 Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua create mode 100644 Modules/QuestieLearnerExport.lua diff --git a/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua b/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua new file mode 100644 index 0000000..2acdf7f --- /dev/null +++ b/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua @@ -0,0 +1,441 @@ +---@type QuestieOptions +local QuestieOptions = QuestieLoader:ImportModule("QuestieOptions") +---@type l10n +local l10n = QuestieLoader:ImportModule("l10n") + +QuestieOptions.tabs.database = {} + +local AceGUI = LibStub("AceGUI-3.0") + +-- Forward declarations for dialog functions +local _OpenExportDialog, _OpenImportDialog + +----------------------------------------------------------------------- +-- Dialog helpers +----------------------------------------------------------------------- + +local function GetExportModule() + return QuestieLoader:ImportModule("QuestieLearnerExport") +end + +local function GetServer() + if Questie.IsAscension then return "Ascension" end + if Questie.IsTurtle then return "Turtle" end + if Questie.IsEbonhold then return "Ebonhold" end + if Questie.IsEra then return "Era" end + if Questie.Is335 then return "WotLK" end + return GetRealmName and GetRealmName() or "unknown" +end + +local function GetLearnedCounts() + local ld = Questie.db and Questie.db.global and Questie.db.global.learnedData + local none = { npcs = 0, quests = 0, items = 0, objects = 0, total = 0 } + if not ld then return none end + local bucket = ld[GetServer()] or (ld.npcs and ld) or nil + if not bucket then return none end + local function Count(t) + if not t then return 0 end + local n = 0; for _ in pairs(t) do n = n + 1 end; return n + end + local s = { + npcs = Count(bucket.npcs), + quests = Count(bucket.quests), + items = Count(bucket.items), + objects = Count(bucket.objects), + } + s.total = s.npcs + s.quests + s.items + s.objects + return s +end + +----------------------------------------------------------------------- +-- Export Dialog +----------------------------------------------------------------------- + +_OpenExportDialog = function(exportStr, stats) + local f = AceGUI:Create("Frame") + f:SetTitle("Questie-X — Export Learned Data") + f:SetWidth(620) + f:SetHeight(420) + f:SetLayout("Flow") + f:SetCallback("OnClose", function(w) AceGUI:Release(w) end) + + local info = AceGUI:Create("Label") + info:SetFullWidth(true) + info:SetText(string.format( + "|cFFFFD700Server:|r %s |cFFFFD700NPCs:|r %d |cFFFFD700Quests:|r %d |cFFFFD700Items:|r %d |cFFFFD700Objects:|r %d |cFFFFD700Total:|r %d entries", + GetServer(), stats.npcs or 0, stats.quests or 0, stats.items or 0, stats.objects or 0, stats.total or 0 + )) + f:AddChild(info) + + local spacer = AceGUI:Create("Label") + spacer:SetFullWidth(true) + spacer:SetText(" ") + f:AddChild(spacer) + + local box = AceGUI:Create("MultiLineEditBox") + box:SetFullWidth(true) + box:SetNumLines(14) + box:SetLabel("Select all and copy (Ctrl+A, Ctrl+C):") + box:SetText(exportStr) + box:DisableButton(true) + f:AddChild(box) + + local hint = AceGUI:Create("Label") + hint:SetFullWidth(true) + hint:SetText("|cFF888888To share your data: copy this string and submit it on the Questie-X GitHub or Discord. Contributors help grow the quest database for everyone.|r") + f:AddChild(hint) +end + +----------------------------------------------------------------------- +-- Import Dialog +----------------------------------------------------------------------- + +_OpenImportDialog = function() + local f = AceGUI:Create("Frame") + f:SetTitle("Questie-X — Import Learned Data") + f:SetWidth(620) + f:SetHeight(420) + f:SetLayout("Flow") + f:SetCallback("OnClose", function(w) AceGUI:Release(w) end) + + local statusLabel = AceGUI:Create("Label") + statusLabel:SetFullWidth(true) + statusLabel:SetText("|cFF888888Paste a QxLD export string below, then click Validate.|r") + f:AddChild(statusLabel) + + local spacer = AceGUI:Create("Label") + spacer:SetFullWidth(true) + spacer:SetText(" ") + f:AddChild(spacer) + + local box = AceGUI:Create("MultiLineEditBox") + box:SetFullWidth(true) + box:SetNumLines(12) + box:SetLabel("Paste export string here:") + box:SetText("") + box:DisableButton(true) + f:AddChild(box) + + local validateBtn = AceGUI:Create("Button") + validateBtn:SetText("Validate") + validateBtn:SetWidth(120) + f:AddChild(validateBtn) + + local importBtn = AceGUI:Create("Button") + importBtn:SetText("Import") + importBtn:SetWidth(120) + importBtn:SetDisabled(true) + f:AddChild(importBtn) + + validateBtn:SetCallback("OnClick", function() + local Exp = GetExportModule() + if not Exp then + statusLabel:SetText("|cFFFF0000Export module not loaded.|r") + return + end + local input = box:GetText() + local payload, statsOrErr = Exp:ValidateImport(input) + if not payload then + statusLabel:SetText("|cFFFF0000Validation failed: " .. tostring(statsOrErr) .. "|r") + importBtn:SetDisabled(true) + else + statusLabel:SetText(string.format( + "|cFF00FF00Valid! Server: %s NPCs: %d Quests: %d Items: %d Objects: %d Total: %d|r", + statsOrErr.server or "?", + statsOrErr.npcs or 0, statsOrErr.quests or 0, + statsOrErr.items or 0, statsOrErr.objects or 0, + statsOrErr.total or 0 + )) + importBtn:SetDisabled(false) + end + end) + + importBtn:SetCallback("OnClick", function() + local Exp = GetExportModule() + if not Exp then return end + local ok, msg = Exp:MergeImport() + if ok then + statusLabel:SetText("|cFF00FF00" .. msg .. "|r") + importBtn:SetDisabled(true) + else + statusLabel:SetText("|cFFFF0000" .. msg .. "|r") + end + end) +end + +----------------------------------------------------------------------- +-- Tab Definition +----------------------------------------------------------------------- + +function QuestieOptions.tabs.database:Initialize() + return { + name = function() return l10n("Database") end, + type = "group", + order = 9, + args = { + + ---- Header ----------------------------------------------- + db_header = { + type = "header", + order = 1, + name = function() return l10n("Learned Data") end, + }, + + ---- Live stats description -------------------------------- + db_stats_desc = { + type = "description", + order = 1.1, + fontSize = "medium", + name = function() + local s = GetLearnedCounts() + if s.total == 0 then + return "|cFF888888No learned data recorded yet. Play the game and Questie will learn as you go.|r" + end + return string.format( + "|cFFFFD700Server:|r %s\n|cFF5EBAF3NPCs:|r %d |cFF5EBAF3Quests:|r %d |cFF5EBAF3Items:|r %d |cFF5EBAF3Objects:|r %d\n|cFFFFFFFFTotal entries:|r %d", + GetServer(), s.npcs, s.quests, s.items, s.objects, s.total + ) + end, + }, + + ---- Learner Toggles header -------------------------------- + learner_toggle_header = { + type = "header", + order = 2, + name = function() return l10n("What To Learn") end, + }, + + learn_npcs = { + type = "toggle", + order = 2.1, + name = function() return l10n("Learn NPCs") end, + desc = function() return l10n("Record quest-relevant NPC positions and data.") end, + get = function() return Questie.db.global.learnedData and Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.learnNpcs end, + set = function(_, v) + if Questie.db.global.learnedData and Questie.db.global.learnedData.settings then + Questie.db.global.learnedData.settings.learnNpcs = v + end + end, + }, + + learn_quests = { + type = "toggle", + order = 2.2, + name = function() return l10n("Learn Quests") end, + desc = function() return l10n("Record quest metadata, objectives, and rewards.") end, + get = function() return Questie.db.global.learnedData and Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.learnQuests end, + set = function(_, v) + if Questie.db.global.learnedData and Questie.db.global.learnedData.settings then + Questie.db.global.learnedData.settings.learnQuests = v + end + end, + }, + + learn_objects = { + type = "toggle", + order = 2.3, + name = function() return l10n("Learn Objects") end, + desc = function() return l10n("Record interactable quest object positions.") end, + get = function() return Questie.db.global.learnedData and Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.learnObjects end, + set = function(_, v) + if Questie.db.global.learnedData and Questie.db.global.learnedData.settings then + Questie.db.global.learnedData.settings.learnObjects = v + end + end, + }, + + learn_items = { + type = "toggle", + order = 2.4, + name = function() return l10n("Learn Items") end, + desc = function() return l10n("Record quest item drop sources.") end, + get = function() return Questie.db.global.learnedData and Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.learnItems end, + set = function(_, v) + if Questie.db.global.learnedData and Questie.db.global.learnedData.settings then + Questie.db.global.learnedData.settings.learnItems = v + end + end, + }, + + learn_broadcast = { + type = "toggle", + order = 2.5, + name = function() return l10n("Broadcast to Party") end, + desc = function() return l10n("Share newly learned data with nearby party/raid members who also have Questie-X.") end, + get = function() return Questie.db.profile.learnerBroadcast end, + set = function(_, v) Questie.db.profile.learnerBroadcast = v end, + }, + + ---- Export ----------------------------------------------- + export_header = { + type = "header", + order = 3, + name = function() return l10n("Export") end, + }, + + export_desc = { + type = "description", + order = 3.1, + fontSize = "medium", + name = function() + return "|cFF888888Export your learned data as a compressed string. You can paste this in a GitHub issue or Discord message to help improve the official Questie databases.|r" + end, + }, + + export_current_btn = { + type = "execute", + order = 3.2, + name = function() return l10n("Export Current Server") end, + desc = function() + local s = GetLearnedCounts() + return string.format("Export %d entries for %s", s.total, GetServer()) + end, + func = function() + local Exp = GetExportModule() + if not Exp then + Questie:Print("|cFFFF0000QuestieLearnerExport module not loaded.|r") + return + end + local str, statsOrErr = Exp:Export() + if not str then + Questie:Print("|cFFFF0000Export failed: " .. tostring(statsOrErr) .. "|r") + else + _OpenExportDialog(str, statsOrErr) + end + end, + }, + + export_all_btn = { + type = "execute", + order = 3.3, + name = function() return l10n("Export All Servers") end, + desc = function() return "Export merged data from all server profiles." end, + func = function() + local Exp = GetExportModule() + if not Exp then + Questie:Print("|cFFFF0000QuestieLearnerExport module not loaded.|r") + return + end + local str, statsOrErr = Exp:ExportAll() + if not str then + Questie:Print("|cFFFF0000Export failed: " .. tostring(statsOrErr) .. "|r") + else + _OpenExportDialog(str, statsOrErr) + end + end, + }, + + ---- Import ----------------------------------------------- + import_header = { + type = "header", + order = 4, + name = function() return l10n("Import") end, + }, + + import_desc = { + type = "description", + order = 4.1, + fontSize = "medium", + name = function() + return "|cFF888888Import a QxLD string from another player or the Questie-X GitHub. Questie-X will validate the string before merging — existing data with higher confidence is never overwritten.|r" + end, + }, + + import_btn = { + type = "execute", + order = 4.2, + name = function() return l10n("Open Import Window") end, + func = function() + _OpenImportDialog() + end, + }, + + ---- Cleanup ---------------------------------------------- + cleanup_header = { + type = "header", + order = 5, + name = function() return l10n("Cleanup") end, + }, + + cleanup_desc = { + type = "description", + order = 5.1, + fontSize = "medium", + name = function() + return "|cFF888888Remove stale, empty, or low-confidence entries from your learned data. Run a dry-run first to see what would be removed.|r" + end, + }, + + prune_dry_btn = { + type = "execute", + order = 5.2, + name = function() return l10n("Dry Run (Preview)") end, + desc = function() return "Print a summary of entries that would be removed, without deleting anything." end, + func = function() + local Exp = GetExportModule() + if not Exp then return end + local r = Exp:DryRunPrune() + Questie:Print(string.format( + "|cFF00FF00[Learner Prune Preview]|r Would remove: NPCs %d, Quests %d, Items %d, Objects %d — Total %d", + r.npcs, r.quests, r.items, r.objects, r.total + )) + if r.total > 0 then + Questie:Print("|cFF888888Use /questie db prune to apply, or click Prune Now in the Database tab.|r") + end + end, + }, + + prune_btn = { + type = "execute", + order = 5.3, + name = function() return l10n("Prune Now") end, + desc = function() return "|cFFFF8800Removes stale entries. Cannot be undone. Export first if you want a backup.|r" end, + func = function() + local Exp = GetExportModule() + if not Exp then return end + local r = Exp:Prune() + Questie:Print(string.format( + "|cFF00FF00[Learner Prune]|r Removed: NPCs %d, Quests %d, Items %d, Objects %d — Total %d", + r.npcs, r.quests, r.items, r.objects, r.total + )) + end, + }, + + prune_all_btn = { + type = "execute", + order = 5.4, + name = function() return "|cFFFF4444" .. l10n("Reset All Learned Data") .. "|r" end, + desc = function() return "|cFFFF0000DANGER: Wipes ALL learned data for ALL servers. Export first.|r" end, + confirm = true, + confirmText = "Are you sure? This cannot be undone.", + func = function() + if Questie.db and Questie.db.global then + Questie.db.global.learnedData = nil + Questie:Print("|cFFFF4444[Questie-X]|r All learned data has been reset.") + end + end, + }, + + ---- Submit/Contribute ------------------------------------ + submit_header = { + type = "header", + order = 6, + name = function() return l10n("Contribute") end, + }, + + submit_desc = { + type = "description", + order = 6.1, + fontSize = "medium", + name = function() + return "|cFF888888Want to help grow the Questie-X database?\n\n" .. + "1. Click |r|cFFFFFFFFExport Current Server|r|cFF888888 above.\n" .. + "2. Copy the full export string (Ctrl+A then Ctrl+C in the dialog).\n" .. + "3. Open a new GitHub issue at |r|cFF5EBAF3github.com/Xurkon/Questie-X/issues|r|cFF888888 titled: |r|cFFFFFFFF[Data Submission] |r|cFF888888\n" .. + "4. Paste the string into the issue body and submit.\n\n" .. + "Submissions are reviewed and merged into the official database. Thank you for contributing!|r" + end, + }, + }, + } +end diff --git a/Modules/Options/QuestieOptions.lua b/Modules/Options/QuestieOptions.lua index e110bc5..e2d9042 100644 --- a/Modules/Options/QuestieOptions.lua +++ b/Modules/Options/QuestieOptions.lua @@ -137,6 +137,8 @@ _CreateOptionsTable = function() coroutine.yield() local advanced_tab = QuestieOptions.tabs.advanced:Initialize() coroutine.yield() + local database_tab = QuestieOptions.tabs.database:Initialize() + coroutine.yield() local credits_tab = QuestieOptions.tabs.credits:Initialize() coroutine.yield() return { @@ -157,6 +159,7 @@ _CreateOptionsTable = function() nameplate_tab = nameplate_tab, dbm_hud_tab = dbm_hud_tab, advanced_tab = advanced_tab, + database_tab = database_tab, credits_tab = credits_tab, profiles_tab = LibStub("AceDBOptions-3.0"):GetOptionsTable(Questie.db) } diff --git a/Modules/QuestieLearnerExport.lua b/Modules/QuestieLearnerExport.lua new file mode 100644 index 0000000..ce8d23e --- /dev/null +++ b/Modules/QuestieLearnerExport.lua @@ -0,0 +1,375 @@ +---@class QuestieLearnerExport +local QuestieLearnerExport = QuestieLoader:CreateModule("QuestieLearnerExport") + +---@type QuestieLearner +local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") +---@type QuestieServer +local QuestieServer = QuestieLoader:ImportModule("QuestieServer") + +local LibDeflate = LibStub("LibDeflate") +local AceSerializer = LibStub("AceSerializer-3.0") + +local FORMAT_PREFIX = "QxLD" +local FORMAT_VERSION = 1 +local FORMAT_SEP = "!" +local MAX_IMPORT_LEN = 524288 -- 512 KB hard cap on raw decoded payload + +local _Export = QuestieLearnerExport.private or {} +QuestieLearnerExport.private = _Export + +-- Cached last export string and stats for the UI to read without re-computing +QuestieLearnerExport.lastExportString = nil +QuestieLearnerExport.lastExportStats = nil + +-- Cached last import validation result +QuestieLearnerExport.lastImportStats = nil +QuestieLearnerExport.lastImportData = nil + +----------------------------------------------------------------------- +-- Internal helpers +----------------------------------------------------------------------- + +local function CountTable(t) + if not t then return 0 end + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +local function GetServerKey() + if QuestieServer then + if Questie.IsAscension then return "Ascension" end + if Questie.IsTurtle then return "Turtle" end + if Questie.IsEbonhold then return "Ebonhold" end + if Questie.IsEra then return "Era" end + if Questie.Is335 then return "WotLK" end + end + local realm = GetRealmName and GetRealmName() or "unknown" + return realm ~= "" and realm or "unknown" +end + +-- Returns the learnedData sub-table for the current server, or nil +local function GetServerBucket(serverKey) + local ld = Questie.db and Questie.db.global and Questie.db.global.learnedData + if not ld then return nil end + if ld[serverKey] then return ld[serverKey] end + -- Fallback: flat (pre-bucket) layout still in use + if ld.npcs or ld.quests then return ld end + return nil +end + +-- Builds a lightweight stats summary table from a bucket +local function BuildStats(bucket) + if not bucket then return { npcs = 0, quests = 0, items = 0, objects = 0, total = 0 } end + local s = { + npcs = CountTable(bucket.npcs), + quests = CountTable(bucket.quests), + items = CountTable(bucket.items), + objects = CountTable(bucket.objects), + } + s.total = s.npcs + s.quests + s.items + s.objects + return s +end + +----------------------------------------------------------------------- +-- Export +----------------------------------------------------------------------- + +--- Serializes + deflates + encodes the learned data for the given server key. +--- Returns the export string and a stats table, or nil + error message. +---@param serverKey string|nil defaults to current server +---@return string|nil, table|string +function QuestieLearnerExport:Export(serverKey) + serverKey = serverKey or GetServerKey() + local bucket = GetServerBucket(serverKey) + if not bucket then + return nil, "No learned data found for server: " .. tostring(serverKey) + end + + local stats = BuildStats(bucket) + if stats.total == 0 then + return nil, "Nothing to export — learned data is empty." + end + + local payload = { + v = FORMAT_VERSION, + server = serverKey, + ts = time and time() or 0, + data = bucket, + } + + local ok, serialized = pcall(AceSerializer.Serialize, AceSerializer, payload) + if not ok or not serialized then + return nil, "Serialization failed: " .. tostring(serialized) + end + + local compressed = LibDeflate:CompressDeflate(serialized, { level = 9 }) + if not compressed then + return nil, "Compression failed." + end + + local encoded = LibDeflate:EncodeForPrint(compressed) + if not encoded then + return nil, "Encoding failed." + end + + local result = FORMAT_PREFIX .. ":" .. FORMAT_VERSION .. FORMAT_SEP .. encoded + + self.lastExportString = result + self.lastExportStats = stats + + Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport] Exported", stats.total, + "entries for", serverKey, "len:", string.len(result)) + + return result, stats +end + +--- Exports ALL server buckets merged into one payload. +---@return string|nil, table|string +function QuestieLearnerExport:ExportAll() + local ld = Questie.db and Questie.db.global and Questie.db.global.learnedData + if not ld then return nil, "No learned data." end + + local merged = { npcs = {}, quests = {}, items = {}, objects = {} } + local function MergeBucket(b) + if not b then return end + for id, v in pairs(b.npcs or {}) do merged.npcs[id] = v end + for id, v in pairs(b.quests or {}) do merged.quests[id] = v end + for id, v in pairs(b.items or {}) do merged.items[id] = v end + for id, v in pairs(b.objects or {}) do merged.objects[id] = v end + end + + -- Flat layout + if ld.npcs or ld.quests then + MergeBucket(ld) + else + for _, bucket in pairs(ld) do + if type(bucket) == "table" and bucket.npcs then + MergeBucket(bucket) + end + end + end + + local stats = BuildStats(merged) + if stats.total == 0 then return nil, "Nothing to export." end + + local payload = { + v = FORMAT_VERSION, + server = "all", + ts = time and time() or 0, + data = merged, + } + + local ok, serialized = pcall(AceSerializer.Serialize, AceSerializer, payload) + if not ok or not serialized then + return nil, "Serialization failed." + end + + local compressed = LibDeflate:CompressDeflate(serialized, { level = 9 }) + if not compressed then return nil, "Compression failed." end + + local encoded = LibDeflate:EncodeForPrint(compressed) + if not encoded then return nil, "Encoding failed." end + + local result = FORMAT_PREFIX .. ":" .. FORMAT_VERSION .. FORMAT_SEP .. encoded + self.lastExportString = result + self.lastExportStats = stats + return result, stats +end + +----------------------------------------------------------------------- +-- Import / Validate +----------------------------------------------------------------------- + +--- Validates an import string and returns the decoded payload table, +--- or nil + error string. Does NOT merge — call MergeImport() after confirming. +---@param importStr string +---@return table|nil, string|table +function QuestieLearnerExport:ValidateImport(importStr) + self.lastImportData = nil + self.lastImportStats = nil + + if not importStr or importStr == "" then + return nil, "Empty import string." + end + + -- Strip whitespace + importStr = importStr:gsub("%s+", "") + + -- Check prefix + if not importStr:sub(1, #FORMAT_PREFIX + 2) == FORMAT_PREFIX .. ":" then + return nil, "Not a Questie-X export string (missing QxLD prefix)." + end + + local sepPos = importStr:find(FORMAT_SEP, 1, true) + if not sepPos then + return nil, "Malformed string — missing separator." + end + + local encoded = importStr:sub(sepPos + 1) + if string.len(encoded) > MAX_IMPORT_LEN then + return nil, "Import string too large (max 512 KB)." + end + + local compressed = LibDeflate:DecodeForPrint(encoded) + if not compressed then + return nil, "Decode failed — string may be corrupted." + end + + local serialized = LibDeflate:DecompressDeflate(compressed) + if not serialized then + return nil, "Decompression failed — string may be corrupted." + end + + local ok, payload = AceSerializer:Deserialize(serialized) + if not ok or type(payload) ~= "table" then + return nil, "Deserialization failed — data is malformed." + end + + if payload.v ~= FORMAT_VERSION then + return nil, "Unsupported format version: " .. tostring(payload.v) + end + + local bucket = payload.data + if type(bucket) ~= "table" then + return nil, "Payload missing data table." + end + if type(bucket.npcs) ~= "table" or + type(bucket.quests) ~= "table" or + type(bucket.items) ~= "table" or + type(bucket.objects) ~= "table" then + return nil, "Payload data is missing required sub-tables (npcs/quests/items/objects)." + end + + local stats = BuildStats(bucket) + stats.server = payload.server or "unknown" + stats.ts = payload.ts or 0 + + self.lastImportData = payload + self.lastImportStats = stats + + Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport] ValidateImport OK:", + stats.total, "entries from", stats.server) + + return payload, stats +end + +--- Merges previously validated import data into learnedData. +--- Must call ValidateImport() first. +---@return boolean, string +function QuestieLearnerExport:MergeImport() + if not self.lastImportData then + return false, "No validated import data. Run ValidateImport() first." + end + + local payload = self.lastImportData + local bucket = payload.data + local merged = 0 + local skipped = 0 + + local function MergeType(typ, src) + for id, d in pairs(src) do + local prevData = QuestieLearner.data + QuestieLearner:HandleNetworkData(typ, id, d) + if QuestieLearner.data ~= prevData then + merged = merged + 1 + else + skipped = skipped + 1 + end + merged = merged + 1 + end + end + + MergeType("NPC", bucket.npcs) + MergeType("QUEST", bucket.quests) + MergeType("ITEM", bucket.items) + MergeType("OBJECT", bucket.objects) + + self.lastImportData = nil + self.lastImportStats = nil + + local msg = "Import complete: merged " .. merged .. " entries, skipped " .. skipped .. " (already known)." + Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport]", msg) + return true, msg +end + +----------------------------------------------------------------------- +-- Cleanup / Prune +----------------------------------------------------------------------- + +--- Returns a count of entries that would be pruned (dry run). +---@return table { npcs=N, quests=N, items=N, objects=N, total=N, reasons={} } +function QuestieLearnerExport:DryRunPrune() + return _Export:RunPrune(true) +end + +--- Runs the actual prune and returns counts of removed entries. +---@return table +function QuestieLearnerExport:Prune() + return _Export:RunPrune(false) +end + +local QuestieDB -- lazily imported to avoid circular dep + +function _Export:RunPrune(dryRun) + if not QuestieDB then QuestieDB = QuestieLoader:ImportModule("QuestieDB") end + + local serverKey = GetServerKey() + local bucket = GetServerBucket(serverKey) + + local result = { npcs = 0, quests = 0, items = 0, objects = 0, total = 0, reasons = {} } + if not bucket then return result end + + local function ShouldPruneNPC(id, entry) + if CountTable(entry) == 0 then return "empty entry" end + if (entry.mc or 0) < 2 and not entry[7] then return "unverified with no coords" end + return nil + end + + local function ShouldPruneQuest(id, entry) + if CountTable(entry) == 0 then return "empty entry" end + if QuestieDB and QuestieDB.GetQuest then + local dbEntry = QuestieDB:GetQuest(id) + if dbEntry and (entry.mc or 0) < 2 then + return "fully covered by official DB, mc < 2" + end + end + return nil + end + + local function ShouldPruneItem(id, entry) + if CountTable(entry) == 0 then return "empty entry" end + if (entry.mc or 0) < 1 then return "zero match count" end + return nil + end + + local function ShouldPruneObject(id, entry) + if CountTable(entry) == 0 then return "empty entry" end + if (entry.mc or 0) < 2 and not entry[4] then return "unverified with no coords" end + return nil + end + + local function PruneStore(store, checkFn, typeName) + if not store then return end + for id, entry in pairs(store) do + local reason = checkFn(id, entry) + if reason then + result[typeName] = result[typeName] + 1 + result.total = result.total + 1 + table.insert(result.reasons, typeName .. ":" .. tostring(id) .. " — " .. reason) + if not dryRun then + store[id] = nil + end + Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport] Prune", + dryRun and "(dry)" or "", typeName, id, reason) + end + end + end + + PruneStore(bucket.npcs, ShouldPruneNPC, "npcs") + PruneStore(bucket.quests, ShouldPruneQuest, "quests") + PruneStore(bucket.items, ShouldPruneItem, "items") + PruneStore(bucket.objects, ShouldPruneObject, "objects") + + return result +end diff --git a/Questie-X-Classic.toc b/Questie-X-Classic.toc index 963bb92..662737b 100644 --- a/Questie-X-Classic.toc +++ b/Questie-X-Classic.toc @@ -213,6 +213,7 @@ Modules\Options\QuestieOptions.lua Modules\Options\QuestieOptionsDefaults.lua Modules\Options\QuestieOptionsUtils.lua Modules\Options\AdvancedTab\QuestieOptionsAdvanced.lua +Modules\Options\DatabaseTab\QuestieOptionsDatabase.lua Modules\Options\CreditsTab\QuestieOptionsCredits.lua Modules\Options\AutoTab\QuestieOptionsAuto.lua Modules\Options\DBMTab\QuestieOptionsDBM.lua @@ -229,3 +230,5 @@ Modules\QuestieProfiler.lua # Main Questie.lua + + diff --git a/Questie-X-TBC.toc b/Questie-X-TBC.toc index f40041e..fabdeb8 100644 --- a/Questie-X-TBC.toc +++ b/Questie-X-TBC.toc @@ -204,6 +204,7 @@ Modules\Options\QuestieOptions.lua Modules\Options\QuestieOptionsDefaults.lua Modules\Options\QuestieOptionsUtils.lua Modules\Options\AdvancedTab\QuestieOptionsAdvanced.lua +Modules\Options\DatabaseTab\QuestieOptionsDatabase.lua Modules\Options\CreditsTab\QuestieOptionsCredits.lua Modules\Options\AutoTab\QuestieOptionsAuto.lua Modules\Options\DBMTab\QuestieOptionsDBM.lua @@ -220,3 +221,5 @@ Modules\QuestieProfiler.lua # Main Questie.lua + + diff --git a/Questie-X-Turtle.toc b/Questie-X-Turtle.toc index d3a9ac9..4658734 100644 --- a/Questie-X-Turtle.toc +++ b/Questie-X-Turtle.toc @@ -211,6 +211,7 @@ Modules\Options\QuestieOptions.lua Modules\Options\QuestieOptionsDefaults.lua Modules\Options\QuestieOptionsUtils.lua Modules\Options\AdvancedTab\QuestieOptionsAdvanced.lua +Modules\Options\DatabaseTab\QuestieOptionsDatabase.lua Modules\Options\CreditsTab\QuestieOptionsCredits.lua Modules\Options\AutoTab\QuestieOptionsAuto.lua Modules\Options\DBMTab\QuestieOptionsDBM.lua @@ -235,3 +236,5 @@ Modules\QuestieMenu\Mailboxes.lua Modules\QuestieMenu\MeetingStones.lua Modules\QuestieMenu\ProfessionTrainers.lua Modules\QuestieMenu\QuestieMenu.lua + + diff --git a/Questie-X.toc b/Questie-X.toc index dcf72e2..19de039 100644 --- a/Questie-X.toc +++ b/Questie-X.toc @@ -188,6 +188,7 @@ Modules\QuestieCoordinates.lua Modules\Network\QuestieComms.lua Modules\Network\QuestieCommsData.lua Modules\Network\QuestieLearnerComms.lua +Modules\QuestieLearnerExport.lua # Journey Modules\Journey\QuestieJourney.lua @@ -228,6 +229,7 @@ Modules\Options\QuestieOptions.lua Modules\Options\QuestieOptionsDefaults.lua Modules\Options\QuestieOptionsUtils.lua Modules\Options\AdvancedTab\QuestieOptionsAdvanced.lua +Modules\Options\DatabaseTab\QuestieOptionsDatabase.lua Modules\Options\CreditsTab\QuestieOptionsCredits.lua Modules\Options\AutoTab\QuestieOptionsAuto.lua Modules\Options\DBMTab\QuestieOptionsDBM.lua @@ -244,3 +246,5 @@ Modules\QuestieProfiler.lua # Main Questie.lua + +