lotrointerface.com
Search Downloads

LoTROInterface SVN Reminders

[/] [trunk/] [Thurallor/] [Reminders/] [Window.lua] - Rev 10

Compare with Previous | Blame | View Log

Window = class(Turbine.UI.Lotro.Window);

local importPath = getfenv(1)._.Name;
local imagePath = string.gsub(string.gsub(importPath, "%.Window$", ""), "%.", "/") .. "/Images/";

function Window:Constructor(plugin)
    Turbine.UI.Lotro.Window.Constructor(self);
    self:SetResizable(true);
    self:SetMinimumSize(200, 150);
    self.plugin = plugin;
    self:SetText(L:GetText("/Defaults/WindowTitle"));

    local b = Thurallor.UI.RoundButton();
    self.newEventButton = b;
    b:SetParent(self);
    b:SetBackground(imagePath .. "plus.tga");
    b:SetHighlightedBackground(imagePath .. "plus_highlight.tga");
    b:SetMouseDownBackground(imagePath .. "plus_click.tga");
    Thurallor.UI.Tooltip(function() return L:GetText("/MainWindow/AddNewReminder"); end):Attach(b);
    b.Click = function() self:CreateNewEvent() end;

    local b = Thurallor.UI.RoundButton();
    self.scopeButton = b;
    b:SetParent(self);
    self.scopeTooltip = Thurallor.UI.Tooltip(function() return L:GetText("/MainWindow/CurrentScope/" .. self.settings.viewingScope); end);
    self.scopeTooltip:Attach(b);
    b.Click = function() self:NextScope() end;

    b = Thurallor.UI.RoundButton();
    self.settingsButton = b;
    b:SetParent(self);
    b:SetBackground(0x41101833);
    b:SetDisabledBackground(0x41101833);
    b:SetHighlightedBackground(0x41101832);
    b:SetMouseDownBackground(0x41101834);
    Thurallor.UI.Tooltip(function() return L:GetText("/MainWindow/OpenOptionsPanel"); end):Attach(b);
    b.Click = function() self:ShowOptionsPanel() end;

    -- Display a message "(no tabs displayed)" when all tabs are hidden
    label = Turbine.UI.Label();
    self.noTabsDisplayed = label;
    label:SetSize(self);
    label:SetTextAlignment(Turbine.UI.ContentAlignment.MiddleCenter);
    label:SetForeColor(Turbine.UI.Color.PaleGoldenrod);
    label:SetBackColor(Turbine.UI.Color.Black);
    label:SetFontStyle(Turbine.UI.FontStyle.Outline);
    label:SetOutlineColor(Turbine.UI.Color.Black);
    label:SetFont(Turbine.UI.Lotro.Font.Verdana16);
    label:SetText(L:GetText("/MainWindow/NoTabsDisplayed"));
    label:SetPosition(14, 55);
    label:SetMouseVisible(false);

    -- Hide window border when mouse is absent
    self.backgroundHider = Turbine.UI.Control();
    self.backgroundHider:SetParent(self);
    self.backgroundHider:SetBackColor(Turbine.UI.Color(0, 0, 0, 0));
    self.backgroundHider:SetSize(0, 0);
    
    self.MouseClick = function()
        self:Deselect();
    end

    self:CreateIcon();

    self.player = Turbine.Gameplay.LocalPlayer.GetInstance();
    self.alerts = {};
    self.shimmering = Shimmering();
    self.shimmering:SetWantsUpdates(true);

    local gameTime = Turbine.Engine.GetGameTime();
    self.tickFuncs = {}; -- for event:Tick() functions
    self.numTickFuncs = 0;
    self.loadTime = gameTime;
    self.lastTick = gameTime;
    self.nextServerResetTime = Time():GetNextServerResetTime(true):GetGameTime();
    self.lastSave = gameTime;
    self.chatPaused = true;
    self.chatQueue = {};
    self.itemRemovedQueue = {};
    self.recipes = {
        loaded = false;
        cooldown = {};
        profession = {};
    }

    self.serverID = Turbine.Engine.GetServerID();
    self.charID = self.serverID .. "." .. self.player:GetName();
    self.hudVisible = true;
end

function Window:Localize()
    _G.PutsPrefix = L:GetText("/PutsPrefix");
    self:RegisterShellCommand();
    self.icon:Localize();
    self:SetText(L:GetText("/Defaults/WindowTitle"));
    self:RebuildTabs(true);
end

function Window:ChangeLanguage(oldLanguage, newLanguage)
    if (oldLanguage == newLanguage) then
        return;
    end

    -- Rename any categories with default names
    local prevContext = L:SetContext("/Defaults/Categories");
    local oldCatNames, newCatNames = {}, {};
    for _, cat in pairs(L:GetItems()) do
        oldCatNames[cat] = L:GetText(cat);
    end
    L:SetLanguage(newLanguage);
    for _, cat in pairs(L:GetItems()) do
        newCatNames[cat] = L:GetText(cat);
    end
    L:SetContext(prevContext);
    for cat, oldName in pairs(oldCatNames) do
        local newName = newCatNames[cat];
        self:RenameCategory(oldName, newName);
    end

    self.settings.language = newLanguage;
    self:SaveSettings();
end

function Window:SetOpacity(opacity)
    Turbine.UI.Window.SetOpacity(self, opacity);
    for _, tab in pairs(self.tabsByName) do
        tab:SetOpacity(opacity);
    end
end

function Window:SetVisible(visible)
    if (visible and not self.visible) then
        EnsureOnScreen(self, true);
    end
    self.visible = visible;
    Turbine.UI.Lotro.Window.SetVisible(self, visible and self.hudVisible);
end

function Window:IsVisible()
    return self.visible;
end

function Window:ShellCommand(cmd, args)
--Puts(Serialize({cmd = cmd; args = args}));
    subcmd, remainder = string.match(args, "^ *([^ ]+) +(.*)$");
    if (subcmd == L:GetText("/ShellCommand/SetLoc")) then
        self.location = string.gsub(remainder, "\n", ": ");
        if (self.LocationChanged) then
            -- Can only be one such callback at a time, for simplicity
            self.LocationChanged();
        end
        return true;
    elseif (subcmd == L:GetText("/ShellCommand/Toggle")) then
        if (remainder == L:GetText("/ShellCommand/Window")) then
            self:SetVisible(not self:IsVisible());
            self:SaveSettings();
            return true;
        elseif (remainder == L:GetText("/ShellCommand/Icon")) then
            local visible = not self.icon:IsVisible();
            self.icon:SetVisible(visible);
            self.settings.icon.hidden = not visible;
            self:SaveSettings();
            return true;
        else
            return false;
        end
    end
    return false;
end

function Window:RegisterShellCommand()
    if (self.shellCmd) then
        Turbine.Shell.RemoveCommand(self.shellCmd);
    end
    self.shellCmd = Turbine.ShellCommand();
    self.shellCmd.window = self;
    function self.shellCmd:Execute(cmd, args)
        if ((args == L:GetText("/ShellCommand/Help")) or (not self.window:ShellCommand(cmd, args))) then
            Turbine.Shell.WriteLine(self:GetHelp());
        end
    end
    self.shellCmd.GetHelp = function()
        return L:GetText("/ShellCommand/LongHelp");
    end
    self.shellCmd.GetShortHelp = function()
        return L:GetText("/ShellCommand/ShortHelp");
    end
    Turbine.Shell.AddCommand(L:GetText("/ShellCommand/Command"), self.shellCmd);
end

function Window:CreateIcon()
    self.icon = Icon(self);
    self.icon:SetTooltip(function() return self:GetIconTooltip() end);

    local function saveIconSettings()
        self.settings.icon.position = {self.icon:GetFractionalPosition()};
        self.settings.icon.state = self.icon:GetIconState();
        self:SaveSettings();
    end
    AddCallback(self.icon, "IconStateChanged", function(_, state)
        self:IconStateChanged(state);
        saveIconSettings();
    end);
    AddCallback(self.icon, "IconMoved", saveIconSettings);
end

function Window:SetIconSize(size)
    self.settings.icon.size = { size, size };
    self.icon:SetSize(unpack(self.settings.icon.size));
    self.icon:SetFractionalPosition(unpack(self.settings.icon.position));
    self:SaveSettings();
end

function Window:IconStateChanged(state)
    if (state == "icon") then
    
        -- Window is being minimized
        self.shimmering:SetWantsUpdates(false);
        for name, tab in pairs(self.tabsByName) do
            if (tab.table.marquee) then
                tab.table.marquee:SetWantsUpdates(false);
            end
        end
    else -- (state == "window")
    
        -- Window is being restored
        self.shimmering:SetWantsUpdates(true);
        for name, tab in pairs(self.tabsByName) do
            if (tab.table.marquee) then
                tab.table.marquee:SetWantsUpdates(true);
            end
        end
    end
end

function Window:LoadSettings()
--Puts("Loading settings...");
    -- Check for (obsolete) server-specific settings
    local serverSettings, accountSettings;
    Turbine.PluginData.Load(Turbine.DataScope.Server, "Reminders", function(serverData)
        if (serverData) then
            serverSettings = ImportTable(serverData);
        end
    end);
    Turbine.PluginData.Load(Turbine.DataScope.Account, "Reminders", function(accountData)
        if (accountData) then
            -- Save backup
            accountData.backupTime = "#" .. Turbine.Engine.GetGameTime();
            local dateInfo = Turbine.Engine.GetDate();
            Turbine.PluginData.Save(Turbine.DataScope.Account, "Reminders_backup" .. dateInfo.DayOfWeek, accountData);
        
            -- Workaround for Turbine localization bug
            accountSettings = ImportTable(accountData);
        end
    end);

    if (serverSettings) then
        -- Upgraded the plugin from a version prior to 2.00.  Need to migrate to new settings file format.
        return MigrateDialog(self, accountSettings, serverSettings);
    elseif (not Turbine.Engine.GetServerName()) then
        -- First time the plugin has run on this server.  Need to get the server name from the user.
        MigrateDialog(self, nil, nil);
    end

    self.settings = accountSettings or {};
    self.settings = self:UpdateSettings(self.settings);

    self:SetSize(unpack(self.settings.size));
    self:SetPosition(unpack(self.settings.position));
    self.icon:SetSize(unpack(self.settings.icon.size));
    self.icon:SetFractionalPosition(unpack(self.settings.icon.position));
    self.icon:SetIconState(self.settings.icon.state);
    self.icon:SetMouseLeaveOpacity(self.settings.icon.mouseLeaveOpacity);
    self.icon:SetVisible(not self.settings.icon.hidden);
    self.icon:SetZOrder(self.settings.icon.zOrder);

    -- If first login on this character, add character name to list
    if (not self.settings.characters[self.charID]) then
        self.settings.characters[self.charID] = self:UpdateCharSettings({});
    end
    self.charSettings = self:UpdateCharSettings(self.settings.characters[self.charID]);
   
    -- If no tabs exist, create a default one
    if (#self.settings.tabs == 0) then
        table.insert(self.settings.tabs, { name = self.player:GetName() });
    end

    -- Register to receive combat state changes and chat messages
    AddCallback(self.player, "InCombatChanged", function()
        self:InCombatChanged();
    end);
    self.chatCallback = self:CreateChatParser();
    AddCallback(Turbine.Chat, "Received", self.chatCallback);
    AddCallback(L, "LanguageChanged", function()
        self:Localize(false); -- also calls self:RebuildTabs();
        if (L:GetLanguage() ~= Turbine.Language.English) then
            Puts(L:GetText("/TranslationHelpWanted"));
        end
    end);
    self:RegisterShellCommand();

    self.optionsPanel = OptionsPanel(self, self.settings);
    L:SetShowMarkers(self.settings.showTranslationMarkers);
    L:SetLanguage(self.settings.language); -- results in a call to self:RebuildTabs();

    -- Start chat parsing, updates, etc.
    self:InCombatChanged();
end

-- Fill in any new settings that have been added if the plugin has been updated
function Window:UpdateSettings(settings)
    settings.language = settings.language or L:GetLanguage();
    if (settings.showTranslationMarkers == nil) then
        settings.showTranslationMarkers = true;
    end
    settings.tabs = settings.tabs or {};
    settings.size = settings.size or { 600, 150 };
    settings.optionsPanel = settings.optionsPanel or {
        size = { 600, 600 };
        position = { 50, 50 };
    };
    settings.questRepeatedDialog = settings.questRepeatedDialog or {};
    settings.raidLockDialog = settings.raidLockDialog or {};
    settings.position = settings.position or { 100, 100 };
    settings.characters = settings.characters or {};
    local prevContext = L:SetContext("/Defaults/Categories");
    settings.categories = settings.categories or {
        { name = L:GetText("Other");     color = { 1, 192/255, 192/255, 192/255 } }; -- Silver
        { name = L:GetText("Auctions");  color = { 1, 128/255,       0,       0 } }; -- Maroon
        { name = L:GetText("Crafting");  color = { 1,       0, 184/255,  92/255 } }; -- Greenish
        { name = L:GetText("Crates");    color = { 1,       0,       0, 255/255 } }; -- Blue
        { name = L:GetText("Instances"); color = { 1, 255/255,  69/255,       0 } }; -- Orange Red
        { name = L:GetText("Quests");    color = { 1,       0, 206/255, 209/255 } }; -- Dark Turquoise
        { name = L:GetText("Tasks");     color = { 1, 162/255, 105/255,       0 } }; -- Brown
    };
    settings.defaultCategory = settings.defaultCategory or L:GetText("Other");
    L:SetContext(prevContext);
    if (not settings.defaultDelay) then
        settings.defaultDelay = 0;
        settings.defaultDelayMethod = "atNextLogin";
    elseif (not settings.defaultDelayMethod) then
        self.settings.defaultDelay = self.settings.defaultDelay * 60 * 60; -- in previous versions, stored as a number of hours
        self.settings.defaultDelayMethod = "delayFromNow";
    end
    settings.icon = settings.icon or { size = { 64, 64 }; position = { 0.5, 0.5 }; state = "window" };
    settings.icon.state = settings.icon.state or "window";
    settings.icon.mouseLeaveOpacity = settings.icon.mouseLeaveOpacity or 0.8;
    if (self.settings.icon.zOrder == nil) then
        self.settings.icon.zOrder = 1;
    end
    settings.mouseLeaveWindowOpacity = settings.mouseLeaveWindowOpacity or 0.8;
    settings.mouseLeaveHideControls = (settings.mouseLeaveHideControls == true);
    settings.postpone = settings.postpone or {
        method = "AtNextLogin";
        delay = 0;
    };
        settings.postponeMethod = nil; -- obsolete
        settings.postponeDelay = nil; -- obsolete
    settings.allowNonemptyTabsDeletion = nil; --obsolete
    if (settings.pauseDuringCombat == nil) then
        settings.pauseDuringCombat = true;
    end
    if (settings.reuseExpiredReminders == nil) then
        settings.reuseExpiredReminders = true;
    end
    if (settings.showPopupNotifications ~= nil) then
        for n, tabSettings in pairs(settings.tabs) do
            tabSettings.showPopupNotifications = settings.showPopupNotifications;
        end
        settings.showPopupNotifications = nil; -- obsolete
    end
    if (settings.showCascade == nil) then
        settings.showCascade = true;
    end
    if (settings.showNegativeTimes == nil) then
        settings.showNegativeTimes = false;
    end
    if (settings.askBeforeDeleting == nil) then
        settings.askBeforeDeleting = true;
    end
    if (settings.viewingScope == nil) then
        settings.viewingScope = "account";
    end
    self.settings.localTimeOffset = self.settings.localTimeOffset or 0.0;
    if (settings.marqueeAntsEnabled == nil) then
        settings.marqueeAntsEnabled = true;
    end

    self:SetLocalTimeOffset(self.settings.localTimeOffset);

    -- The gameTime clock differs between servers and can even differ day-to-day.  But localTime is predictable.
    -- Readjust all expTime fields based on the current (localTime - gameTime) offset.
    local gameTimeZero = Turbine.Engine.GetLocalTime() - Turbine.Engine.GetGameTime();
    self.settings.gameTimeZero = self.settings.gameTimeZero or gameTimeZero;
    local function adjustTime(oldTime)
        return oldTime + self.settings.gameTimeZero - gameTimeZero;
    end
    for _, charSettings in pairs(self.settings.characters) do
        local autoReminders = charSettings.autoReminders;
        if (autoReminders) then
            local tasks = autoReminders.tasks;
            if (tasks) then
                if (tasks.lastRemindTime) then
                    tasks.lastRemindTime = adjustTime(tasks.lastRemindTime);
                end
            end
            local quests = autoReminders.quests;
            if (quests) then
                local questInfo = quests.questInfo;
                if (questInfo) then
                    for questName, info in pairs(questInfo) do
                        if (info.lastCompleted) then
                            info.lastCompleted = adjustTime(info.lastCompleted);
                        end
                        -- 'resetTime' renamed to 'resetTimes' in v1.51
                        if (info.resetTime) then
                            info.resetTimes = info.resetTime;
                            info.resetTime = nil;
                        end
                    end
                end
            end
        end
    end
    for n, tabSettings in pairs(self.settings.tabs) do
        local events = tabSettings.events;
        if (events) then
            for eventNum, eventInfo in pairs(events) do
                if ((eventInfo.expTime) and (eventInfo.expTime > 1000) and (eventInfo.expTime < 10000000000000)) then
                    eventInfo.expTime = adjustTime(eventInfo.expTime);
                end
            end
        end
    end
    self.settings.gameTimeZero = gameTimeZero;
    
    return settings;
end

function Window:SetLocalTimeOffset(offsetHours)
    self.settings.localTimeOffset = offsetHours;
    Time.SetLocalTimeOffset(offsetHours);
    self.nextServerResetTime = Time():GetNextServerResetTime():GetGameTime();
end

-- Fill in any new character settings that have been added if the plugin has been updated
function Window:UpdateCharSettings(settings)
    local prevContext = L:SetContext("/Defaults/Categories");
    settings.autoReminders = settings.autoReminders or {};
    settings.autoReminders.raidlocks = settings.autoReminders.raidlocks or
        { enabled = true;  tab = nil; category = L:GetText("Instances");             };
    settings.autoReminders.raidlocks.lockInfo = settings.autoReminders.raidlocks.lockInfo or {};
    if (settings.autoReminders.raidlocks.notesEnabled == nil) then
        settings.autoReminders.raidlocks.notesEnabled = true;
    end
    settings.autoReminders.crafting = settings.autoReminders.crafting or
        { enabled = true;  tab = nil; category = L:GetText("Crafting");              };
    settings.autoReminders.crates = settings.autoReminders.crates or
        { enabled = true;  tab = nil; category = L:GetText("Crates");                };
    settings.autoReminders.tasks = settings.autoReminders.tasks or
        { enabled = true;  tab = nil; category = L:GetText("Tasks");                 };
    settings.autoReminders.quests = settings.autoReminders.quests or
        { enabled = true;  tab = nil; category = L:GetText("Quests"); questInfo = {} };
    L:SetContext(prevContext);
    return settings;
end

--function Window:FindMouseClicks(ctl)
--    AddCallback(ctl, "MouseClick", function()
--        Puts("MouseClick found:" .. tostring(ctl));
--    end);
--    local children = ctl:GetControls();
--    Puts(tostring(ctl) .. " has " .. children:GetCount() .. " children:");
--    for c = 1, children:GetCount() do
--        self:FindMouseClicks(children:Get(c));
--    end
--end

function Window:CreateSavedTabs(login)    
    for t = 1, #self.settings.tabs do
        local settings = self.settings.tabs[t];
        local tab = self:CreateTab(settings);
        if (login) then
            tab:Login();
        end
    end
    self:BringTabToFront(self:GetFrontTabName());
    self.tabStack:RedistributeTabs();
end

function Window:BringTabToFront(tabName)
    if (tabName) then
        local tab = self.tabsByName[tabName];
        if (tab) then
            tab:BringToFront();
        end
    end
end

function Window:GetFrontTabName()
    -- Return the currently-selected tab for this character.
    local tabName = self.charSettings.frontTab;

    -- If this character never previously logged in, or the character's selected tab was deleted since last login, just use the first tab.
    if ((not tabName) or (not self.tabsByName[tabName])) then
        tabName = self.settings.tabs[1].name;
    end

    self.charSettings.frontTab = tabName;
    return tabName;
end

function Window:Closing(args)
    self:Iconify();
    args.Cancel = true;
end

function Window:Iconify()
    if (not self.icon:IsIcon()) then
        self.icon:Iconify();
    end
end

function Window:SaveSettings(now)
    if (now) then
--Puts("Saving...");
        self.settings.pluginVersion = self.plugin:GetVersion();
        -- Workaround for Turbine localization bug
        local saveData = ExportTable(self.settings);
        Turbine.PluginData.Save(Turbine.DataScope.Account, "Reminders", saveData);
        -- , function()
        --      Puts("Save complete.");
        --   end);
        self.lastSave = Turbine.Engine.GetGameTime();
    else
--Puts("Deferred save");
        self.saveNeeded = true;
    end
end

function Window:ShowOptionsPanel()
    if (self.optionsPanel:IsVisible()) then
        self.optionsPanel:SetVisible(false);
        self.zoomer = Thurallor.UI.Zoomer(self.optionsPanel, self.settingsButton);
        self.zoomer.ZoomComplete = function()
            self.zoomer = nil;
        end
    else
        self.zoomer = Thurallor.UI.Zoomer(self.settingsButton, self.optionsPanel);
        self.zoomer.ZoomComplete = function()
            self.optionsPanel:Show();
            self.zoomer = nil;
        end
    end
end

function Window:UpdateOptionsPanel()
    self.optionsPanel:UpdateDisplay();
end

function Window:Deselect()
    -- Disable the marquee if the user clicks in the border area
    local tab = self.tabsByName[self:GetFrontTabName()];
    tab:Deselect();
end

function Window:GetTab(tabName)
    return self.tabsByName[tabName];
end

function Window:GetCharNamesWithServers(full)
    -- Create a sorted list of all known character names (with server) and a lookup table to check for existence
    local names, exists, name = {}, {};
    local charIDs = self:GetCharIDs();
    for _, charID in pairs(charIDs) do
        local name = self:GetCharNameWithServer(charID, full);
        table.insert(names, name);
        exists[name] = charID;
    end
    return names, exists;
end

-- If 'full' is false, the server name will be omitted if it's the currently logged-in server.
-- If 'full' is true, the server name will always be included.
function Window:GetCharNameWithServer(charID, full)
    local serverID, charName = charID:match("^(.*)%.(.*)$");
    local name;
    if ((not full) and (serverID == self.serverID)) then
        name = charName;
    else
        local serverName = Turbine.Engine:GetServerName(serverID) or serverID;
        name = L:GetText("/MainWindow/CharacterOnServer"):gsub("<name>", charName):gsub("<server>", serverName);
    end
    return name;
end

function Window:GetCharIDs()
    -- Create a sorted list of character IDs and a lookup table to check for existence
    local IDs, exists = {}, {};
    for ID, _ in pairs(self.settings.characters) do
        table.insert(IDs, ID);
        exists[ID] = true;
    end
    table.sort(IDs);
    return IDs, exists;
end

function Window:GetUserTabs()
    -- Create a sorted list of tab names and a lookup table to check for existence
    local names, exists = {}, {};
    for n, s in ipairs(self.settings.tabs) do
        table.insert(names, s.name);
        exists[s.name] = n;
    end
    table.sort(names);
    return names, exists;
end

function Window:GetNumUserTabs()
    return #self.settings.tabs;
end

function Window:GetUserCategories()
    -- Create a sorted list of category names and a lookup table to check for existence
    local names, exists = {}, {};
    for n, s in ipairs(self.settings.categories) do
        table.insert(names, s.name);
        exists[s.name] = n;
    end
    table.sort(names);
    return names, exists;
end

function Window:RenameCategory(oldName, newName)
    local catName, catExists = self:GetUserCategories();
    local n, collision = catExists[oldName], catExists[newName];
    if ((oldName == newName) or (not n) or collision) then
        return;
    end

    self.settings.categories[n].name = newName;
        
    -- Update default category for new reminders
    if (self.settings.defaultCategory == oldName) then
        self.settings.defaultCategory = newName;
    end
        
    -- Update category for automatic reminders
    for _, charSettings in pairs(self.settings.characters) do
        for _, reminderTypeSettings in pairs(charSettings.autoReminders) do
            if (reminderTypeSettings.category == oldName) then
                reminderTypeSettings.category = newName;
            end
            local individualRules = reminderTypeSettings.questInfo or reminderTypeSettings.lockInfo or {};
            for _, individualSettings in pairs(individualRules) do
                if (individualSettings.category == oldName) then
                    individualSettings.category = newName;
                end
            end
        end
    end
        
    -- Update category name in existing events
    self:ForEachEventDo(function(event)
        local cat = event:GetCategory();
        if (cat == oldName) then
            event:SetCategory(newName);
        end
    end);

    self:SaveSettings();
end

function Window:GetCategoryColor(category)
    local catNames, catExists = self:GetUserCategories();
    n = catExists[category];
    if (not n) then
        return Turbine.UI.Color(0.75, 0.75, 0.75);
    end
    local color = self.settings.categories[n].color;
    return Turbine.UI.Color(unpack(color));
end

-- Function must accept a single 'event' argument.
-- Returning nil will continue the loop.
-- Returning 'true' aborts the loop and returns the event object.
function Window:ForEachEventDo(func)
    for _, tab in pairs(self.tabsByName) do
        local event = tab:ForEachEventDo(func);
        if (event) then
            return event;
        end
    end
end

function Window:CreateTab(settings)
    local tab = Tab(self, settings);
    self.tabStack:AddTabCard(tab);
    self.tabsByName[settings.name] = tab;
    return tab;
end

function Window:CreateNewTab()
    local num = 1;
    local name = L:GetText("/Defaults/TabName");
    while self.tabsByName[name] do
        num = num + 1;
        name = L:GetText("/Defaults/TabName") .. " (" .. num .. ")";
    end
    local settings = { name = name; color = self:GetUniqueColor(); };
    table.insert(self.settings.tabs, settings);
    local tab = self:CreateTab(settings);
    self:BringTabToFront(name);
    DoCallbacks(tab, "TabLeftChanged", 0); -- includes RedistributeTabs() and save
    self:UpdateOptionsPanel();
    tab:OpenRenameDialog();
end

function Window:CloneTab(name)
    local oldTab = self.tabsByName[name];
    local newSettings = {};
    DeepTableCopy(oldTab.settings, newSettings);
    newSettings.tabLeft = 0;
    local nameStem = string.gsub(name, " %([0-9]+%)$", "");
    local num = 1, newName;
    repeat
        num = num + 1;
        newName = nameStem .. " (" .. num .. ")";
    until (not self.tabsByName[newName]);
    newSettings.name = newName;
    table.insert(self.settings.tabs, newSettings);
    local newTab = self:CreateTab(newSettings);
    self:BringTabToFront(newName);
    DoCallbacks(newTab, "TabLeftChanged", 0); -- includes RedistributeTabs() and save
    self:SaveSettings();
    self:UpdateOptionsPanel();
    newTab:OpenRenameDialog();
end

function Window:CreateNewItinerary(events)
    -- Fill in the remaining fields
    for _, event in pairs(events) do
        event.expTime = -1;
        event.checked = false;
        event.category = self.settings.defaultCategory;
        event.charID = self.charID;
    end
        
    -- Create a new tab
    local num = 1;
    local name = L:GetText("/Defaults/ItineraryTabName");
    while self.tabsByName[name] do
        num = num + 1;
        name = L:GetText("/Defaults/ItineraryTabName") .. " (" .. num .. ")";
    end
    local settings = { name = name; color = self:GetUniqueColor(); events = events };
    local tableWidth = self:GetWidth() - 38;
    local checkBoxWeight = 43 / tableWidth;
    local remain = 1 - checkBoxWeight;
    settings.presentation = { columns = {
        { "checkbox", checkBoxWeight }, { "desc", remain * .6 }, { "location", remain * .2, true }, { "distance", remain * .2 }
    }};
    table.insert(self.settings.tabs, settings);
    local tab = self:CreateTab(settings);
    self:BringTabToFront(name);
    DoCallbacks(tab, "TabLeftChanged", 0); -- includes RedistributeTabs() and save
    self:SaveSettings();
    self:UpdateOptionsPanel();
end

-- Tries to select a color for a new tab that will contrast with existing tabs
function Window:GetUniqueColor()

    -- Strategy:
    --    1. Make a list of the hues of existing tabs.
    --    2. Find the two adjacent hues that are the farthest apart.
    --    3. Use the midpoint between those two hues.
    
    -- Make a sorted list of hues
    local hues = {};
    for n, tab in ipairs(self.settings.tabs) do
        local hue = Thurallor.Utils.Color(unpack(tab.color)):Get("H");
        table.insert(hues, hue);
    end
    table.sort(hues);
    table.insert(hues, hues[1] + 1); -- search also needs to wrap around back to the first hue

    -- Find the two adjacent hues that are the farthest apart
    local maxDistance = 0;
    local bestHue = 0;
    for n = 1, #hues - 1 do
        local a, b = hues[n], hues[n + 1];
        local distance = b - a;
        if (distance >= maxDistance) then
            bestHue = (a + b) / 2;
            maxDistance = distance;
        end
    end
    if (bestHue > 1) then
        bestHue = bestHue - 1;
    end
    
    -- Create color with full saturation and value
    return {Thurallor.Utils.Color(0, 0, 0):SetHSV(bestHue, 1, 1):Unpack()};
end

function Window:CreateTabStack()
    if (self.tabStack) then
        self.tabStack:SetParent(nil);
    end
    self.tabStack = Thurallor.UI.TabCardStack();
    self.tabStack:SetParent(self);
    self.tabStack:SetPosition(14, 35);
    self.tabsByName = {};
    self.tabStack:SetAddTabButtonEnabled(true, L:GetText("/MainWindow/TabContextMenu/CreateTab"));
    AddCallback(self.tabStack, "AddTabButtonClicked", function(_, args)
        if (args.Button == Turbine.UI.MouseButton.Left) then
            self:CreateNewTab();
        end
    end);
    self.tabStack:SetMoreTabsButtonEnabled(true, L:GetText("/MainWindow/MoreTabs"));
    AddCallback(self.tabStack, "FrontTabChanged", function(_, tab)
        self:_FrontTabChanged(tab);
    end);
end

-- Gets called when the user switches tabs, either by clicking it, or with the Filter button
function Window:_FrontTabChanged(tab)
    if (tab) then
        self.shimmering:Remove(tab.tabText);
        self.charSettings.frontTab = tab:GetTabText();
        self:SaveSettings();
        self.newEventButton:SetVisible(true);
        self.noTabsDisplayed:SetParent(nil);
    else
        self.charSettings.frontTab = nil;
        self.newEventButton:SetVisible(false);
        self.noTabsDisplayed:SetParent(self);
    end
end

function Window:MergeTabs(sourceName, destName)
    local sourceTabNum, destTabNum;
    for n, settings in ipairs(self.settings.tabs) do
        if (settings.name == sourceName) then
            sourceTabNum = n;
        elseif (settings.name == destName) then
            destTabNum = n;
        end
    end
    if (sourceTabNum and destTabNum) then
        local sourceEvents = self.settings.tabs[sourceTabNum].events;
        local destEvents = self.settings.tabs[destTabNum].events;
        for _, eventInfo in ipairs(sourceEvents) do
            table.insert(destEvents, eventInfo);
        end
        self:GetTab(destName):ReapplySort();
        self:DeleteTab(sourceName); -- calls RebuildTabs()

        -- Update tab targets for automatic reminders
        for _, charSettings in pairs(self.settings.characters) do
            local autoReminders = charSettings.autoReminders or {};
            for _, catSettings in pairs(autoReminders) do
                if (catSettings.tab == sourceName) then
                    catSettings.tab = destName;
                end
                local itemInfo = catSettings.questInfo or catSettings.raidInfo or {};
                for _, itemSettings in pairs(itemInfo) do
                    if (itemSettings.tab == sourceName) then
                        itemSettings.tab = destName;
                    end
                end
            end
        end
        self:UpdateOptionsPanel();
    end
end

function Window:DeleteTab(name)
    local tab = self.tabsByName[name];
    tab:ForEachEventDo(function(event)
        event:Destroy();
    end);
    for t = 1, #self.settings.tabs do
        if (self.settings.tabs[t].name == name) then
            table.remove(self.settings.tabs, t);
            break;
        end
    end
    self:RebuildTabs();
    self:UpdateOptionsPanel();
    self:SaveSettings();
end

function Window:RenameTab(oldName, newName)
    for t = 1, #self.settings.tabs do
        if (self.settings.tabs[t].name == oldName) then
            self.settings.tabs[t].name = newName;
            break;
        end
    end
    self:RebuildTabs();
    self:UpdateOptionsPanel();
    self:SaveSettings();
end

function Window:RebuildTabs(login)
    -- Since the tabs are now windows, we need to explicitly close them.
    if (self.tabsByName) then
        for _, tab in pairs(self.tabsByName) do
            tab:Close();
        end
    end
    self.alerts = {};
    self.icon:SetDisplayedNumber(0);
    self:CreateTabStack();
    self:CreateSavedTabs(login);
    self:SetScope(self.settings.viewingScope);
    self:SizeChanged(); -- includes save
end

function Window:TickAllEvents()
    for _, tab in pairs(self.tabsByName) do
        tab:ForEachEventDo(function(event)
            event:SetWantsTicks(true);
        end);
    end
end

function Window:SetMarqueeAntsEnabled(enable)
    self.settings.marqueeAntsEnabled = enable;
    for _, tab in pairs(self.tabsByName) do
        local style = tab.table:GetStyle();
        style.marquee.AntsEnabled = enable;
        tab.table:SetStyle(style);
        local selectedEvent = tab:GetSelectedEvent();
        if (selectedEvent) then
            -- Reselect to update marquee of popup, if any
            tab:SelectEvent(selectedEvent);
        end
    end
    self:SaveSettings();
end

function Window:SizeChanged(noSave)
    local width, height = self:GetSize();
    if (self.tabStack) then
        self.tabStack:SetSize(width - 28, self:GetHeight() - 57);
        self.tabStack:RedistributeTabs();
    end
    self.noTabsDisplayed:SetSize(width - 28, height - 77);
    self.newEventButton:SetPosition(30, 0);
    self.scopeButton:SetPosition(width - 102, 0);
    self.settingsButton:SetPosition(width - 66, 0);
    self.settings.size = { width, height };
    self:SaveSettings();
end

function Window:PositionChanged(noSave)
    local left, top = self:GetPosition();
    self.settings.position = { left, top };
    self:SaveSettings();
end    

function Window:SetControlsVisible(visible)
    if (visible) then
        self.backgroundHider:SetSize(0, 0);
    else
        self.backgroundHider:SetSize(self:GetSize());
    end
end

function Window:Update()
    local gameTime = Turbine.Engine.GetGameTime();

    if (self:IsVisible()) then
        -- Stuff to do when mouse enters or leaves the window
        local x, y = self:GetMousePosition();
        local w, h = self:GetSize();
        local mouseOutside = ((x < 0) or (y < 0) or (x >= w) or (y >= h));
        if (mouseOutside ~= self.mouseWasOutside) then
            if (self.settings.mouseLeaveHideControls) then
                -- Show/hide all inactive tabs
                for name, tab in pairs(self.tabsByName) do
                    tab:SetVisible(tab:IsInFront() or not mouseOutside);
                end
                self:SetControlsVisible(not mouseOutside);
                self.tabStack:SetAddTabButtonEnabled(not mouseOutside);
                self.tabStack:SetMoreTabsButtonEnabled(not mouseOutside);
            else
                -- Show all inactive tabs
                for name, tab in pairs(self.tabsByName) do
                    tab:SetVisible(true);
                end
                self:SetControlsVisible(true);
                self.tabStack:SetAddTabButtonEnabled(true);
                self.tabStack:SetMoreTabsButtonEnabled(true);
            end
            self:SetOpacity((mouseOutside and self.settings.mouseLeaveWindowOpacity) or 1);
        end
        self.mouseWasOutside = mouseOutside;
    end

    -- Check for server reset
    if (gameTime > self.nextServerResetTime) then
        self:ProcessServerReset();
        self.nextServerResetTime = self.nextServerResetTime + Time.lengthOfDay;
    end

    -- Call Tick() function of each tab, at least once per second, but spread them out over multiple updates.
    local elapsed = gameTime - self.lastTick;
    local workToDo = math.min(self.numTickFuncs, math.ceil(elapsed * self.numTickFuncs));
--self:SetText(workToDo .. "/" .. self.numTickFuncs);
    while (workToDo > 0) do
        local tickFunc = next(self.tickFuncs, self.prevTickFunc);
        self.prevTickFunc = tickFunc;
        if (tickFunc) then
            tickFunc(gameTime);
        end
        workToDo = workToDo - 1;
    end
    self.lastTick = gameTime;

    -- Save settings no more often than once per second.
    if (self.saveNeeded and (gameTime - self.lastSave >= 1)) then
        self:SaveSettings(true);
        self.saveNeeded = false;
    end
end

-- This function is called at server reset time
function Window:ProcessServerReset()
    -- Generate bubble notification
    self:CascadeNotify(L:GetText("/Time/DailyResetTime"), nil, Turbine.UI.Color.Gold);

    -- To do: Update reminders which have a "rotation" reset schedule and are no longer available.
    --        Also do this at startup.
end

function Window:AddTickFunc(func)
    if (func and (not self.tickFuncs[func])) then
        self.numTickFuncs = self.numTickFuncs + 1;
        self.tickFuncs[func] = true;
    end
end

function Window:RemoveTickFunc(func)
    if (func and self.tickFuncs[func]) then
        self.numTickFuncs = self.numTickFuncs - 1;
        self.tickFuncs[func] = nil;
        if (self.prevTickFunc == func) then
            self.prevTickFunc = nil;
        end
    end
end

function Window:InCombatChanged()
    local inCombat = self.player:IsInCombat();
    if (inCombat and self.settings.pauseDuringCombat) then
        self:Iconify();
        -- Don't process chat messages during combat
        self:SetChatParsingPaused(true);
        -- Don't update events or save settings during combat
        self:SetWantsUpdates(false);
    else
        self:SetChatParsingPaused(false);
        self:SetWantsUpdates(true);
    end
    self.itemRemovedQueue = {}; -- crafting can't occur during combat
end

-- Function used to temporarily pause chat parsing for performance reasons (during combat)
-- or while waiting for recipe discovery to complete.  While paused, chat messages are
-- queued for later processing.
function Window:SetChatParsingPaused(paused)
    self.chatPaused = paused;
    if (not paused) then
        -- Process enqueued messages, if any
        for _, args in ipairs(self.chatQueue) do
            self.chatCallback(_, args);
            self.chatQueue = {};
        end
    end
end

function Window:CreateChatParser()
    -- Get localized regular expressions for chat parsing
    self.raidLockDetectRegexp1 = L:GetClientLanguageText("/Chat/RaidLockDetect1");
    self.raidLockDetectRegexp2 = L:GetClientLanguageText("/Chat/RaidLockDetect2");
    self.raidFavoredCompletionsRegexp = L:GetClientLanguageText("/Chat/RaidFavoredCompletionsDetect");
    self.raidFavoredCompletionsNotesRegexp = L:GetClientLanguageText("/Chat/RaidFavoredCompletionsNotes");
    self.raidCommonCompletionsRegexp = L:GetClientLanguageText("/Chat/RaidCommonCompletionsDetect");
    self.craftingDetectRegexp = L:GetClientLanguageText("/Chat/CraftingDetect");
    self.newRecipeRegexp = L:GetClientLanguageText("/Chat/NewRecipeDetect");
    self.crateDetectRegexp = L:GetClientLanguageText("/Chat/CrateDetect");
    self.questBestowDetectRegexp = L:GetClientLanguageText("/Chat/QuestBestowDetect");
    self.questCompleteDetectRegexp = L:GetClientLanguageText("/Chat/QuestCompleteDetect");
    self.identifyTaskRegexp = L:GetClientLanguageText("/Chat/IdentifyTaskQuest");
    self.itemRemovedRegexp = L:GetClientLanguageText("/Chat/ItemRemovedDetect");

    -- Create a Label object for efficiently stripping the "<rgb>" tags from strings
    self.colorStripper = Turbine.UI.Label();

    -- Create specialized chat parsing function for each channel of interest
    local channelParseFunc = {};
    channelParseFunc[Turbine.ChatType.Standard] = function(message)
        local lockName, completions, days, hours, minutes;
        -- "Bratha Tasakh's Chest - Tier 1 resets in: 1d - 4h - 14m."
        lockName, days, hours, minutes = string.match(message, self.raidLockDetectRegexp1);
        if (lockName) then
            return self:RaidLockUpdated(lockName, days, hours, minutes);
        end
        -- "Throne of the Dread Terror - Flag 1 - Resets in 6d - 3h - 3m."
        lockName, days, hours, minutes = string.match(message, self.raidLockDetectRegexp2);
        if (lockName) then
            return self:RaidLockUpdated(lockName, days, hours, minutes);
        end
        -- "Shaktur's Mithril Chest: You have 1 favoured completion remaining."
        lockName, completions = string.match(message, self.raidFavoredCompletionsRegexp);
        if (lockName) then
            local notes = string.match(message, self.raidFavoredCompletionsNotesRegexp);
            return self:RaidLockUpdated(lockName, 0, 0, 0, completions, notes);
        end
        -- "Gast Nûl's Gold Chest: You have 18 completions remaining."
        lockName, completions = string.match(message, self.raidCommonCompletionsRegexp);
        if (lockName) then
            return self:RaidLockUpdated(lockName, nil, nil, nil, completions);
        end
        if (self.charSettings.autoReminders.crafting.enabled) then
            -- Check for "crafting complete" message
            local recipeName = string.match(message, self.craftingDetectRegexp);
            if (recipeName) then
                if (self.recipes.loaded) then
                    -- We have the recipe book.  Create reminder if cooldown info is known.
                    if (self.recipes.cooldown[recipeName]) then
                        return self:AddCraftingReminder(recipeName);
                    end
                else
                    -- Need to load the recipe book before processing this recipe completion message.
                    -- Re-enqueue the current message, so it'll be the first that is processed when chat parsing resumes.
                    table.insert(self.chatQueue, 1, { ChatType = Turbine.ChatType.Standard; Message = message } );
                    return self:DiscoverRecipes();
                end
            end

            -- Check for "new recipe learned" message
            local recipeName, profName = string.match(message, self.newRecipeRegexp);
            if (recipeName and self.recipes.loaded) then
                -- Don't bother rereading the recipes, because they won't be updated.
                Puts(L:GetText("/Chat/NewRecipeWarning"));
                -- self:DiscoverRecipes();
            end
        end
    end
    channelParseFunc[Turbine.ChatType.SelfLoot] = function(message)
        if (self.charSettings.autoReminders.crates.enabled) then
            local crateName = string.match(message, self.crateDetectRegexp);
            if (crateName) then
                return self:AddCrateReminder(crateName);
            end
        end
        if (self.charSettings.autoReminders.crafting.enabled) then
            -- Check for "item removed" messages.
            self.colorStripper:SetText(message);
            message = self.colorStripper:GetText();
            local itemStr = string.match(message, self.itemRemovedRegexp);
            if (itemStr) then
                return self:ItemRemoved(itemStr);
            end
        end
    end
    channelParseFunc[Turbine.ChatType.Quest] = function(message)
        local questName = string.match(message, self.questCompleteDetectRegexp);
        if (questName) then
            return self:QuestCompleted(questName);
        end
        questName = string.match(message, self.questBestowDetectRegexp);
        if (questName) then
            return self:QuestBestowed(questName);
        end
    end
    
    -- Return a function that will be used as a ChatReceived callback
    return function(_, args)
        local parseFunc = channelParseFunc[args.ChatType];
        if (parseFunc) then
            if (self.chatPaused) then
                -- Enqueue message for later parsing
                table.insert(self.chatQueue, args);
            else
                -- Parse message
                parseFunc(args.Message);
            end
        end
    end
end        

function Window:QuestCompleted(questName)
    if (self:QuestIsTask(questName)) then
        -- Quest is a task
        local settings = self.charSettings.autoReminders.tasks;
        if (settings.enabled) then
            self:AddTaskReminder();
        end
    else
        -- Not a task, just a normal repeatable quest
        local settings = self.charSettings.autoReminders.quests;
        local info = settings.questInfo[questName];
        if (not info) then
            settings.questInfo[questName] = { lastCompleted = Turbine.Engine.GetGameTime() };
            return self:SaveSettings();
        end
        if (settings.enabled) then
            if (info.enabled) then
                -- Completed this quest before.  User has requested reminders for this quest.  Create one.
                self:AddQuestReminder(questName);
            end
            info.lastCompleted = Turbine.Engine.GetGameTime();
            self:SaveSettings();
        end
    end
end

function Window:FindOldestMatchingEvent(tabName, desc, charID, category)
    tab = self.tabsByName[tabName];
    if (tab) then
        return tab:FindOldestMatchingEvent(desc, charID, category);
    end
end

function Window:AddQuestReminder(questName, charID, now)
    charID = charID or self.charID;
    local settings = self.settings.characters[charID].autoReminders.quests;
    local info = settings.questInfo[questName];
    local tab = self.tabsByName[info.tab] or self.tabsByName[settings.tab] or self.tabsByName[self:GetFrontTabName()];
    local desc = info.desc or questName;
    local category = info.category or settings.category or self.settings.categories[1].name;
    local location = info.location;
    local event, expTime, resetTimes, rotation;
    if (info.cooldown) then
        expTime = info.cooldown + Turbine.Engine.GetGameTime();
    elseif (info.resetTimes) then
        expTime = Schedule(info.resetTimes):GetNextTime(nil, true):GetGameTime();
        resetTimes = info.resetTimes;
    elseif (info.rotation) then
        expTime = Schedule(info.rotation):GetNextTime(nil, true):GetGameTime();
        rotation = info.rotation;
    else -- default is next server reset time
        expTime = self.nextServerResetTime;
        resetTimes = "*:3";
    end
    if (now) then
        expTime = Turbine.Engine.GetGameTime();
    end
    if (self.settings.reuseExpiredReminders) then
        event = tab:FindOldestMatchingEvent(desc, charID, category);
        if (event) then
            event:SetExpirationTime(expTime);
            event:SetResetTimes(resetTimes);
            event:SetRotation(rotation);
            event:SetLastAutomaticUpdate(charID, "quests", questName);
            self:CascadeNotify(L:GetText("/Cascade/ReminderUpdated"), event);
            tab:ReapplySort("remaining");
            self:SaveSettings();
            return event;
        end
    end
    event = tab:CreateNewEvent2({ desc = desc, charID = charID, expTime = expTime, resetTimes = resetTimes,
        rotation = rotation, category = category, location = location, checked = false });
    if (event) then
        if (info.cooldown) then
            event.settings.postpone.delay = info.cooldown;
        elseif (info.resetTimes or info.rotation) then
            event.settings.postpone.method = "ScheduledResetTime";
        end
        event:SetLastAutomaticUpdate(charID, "quests", questName);
        self:CascadeNotify(L:GetText("/Cascade/ReminderCreated"), event);
        return event;
    end
end

function Window:AddTaskReminder()
    local settings = self.charSettings.autoReminders.tasks;
    settings.lastRemindTime = settings.lastRemindTime or 0;
    local nextResetTime = self.nextServerResetTime;
    if (settings.lastRemindTime < nextResetTime) then
        settings.lastRemindTime = nextResetTime;
        local desc = L:GetText("/Defaults/TaskReminderDesc");
        local tab = self.tabsByName[settings.tab] or self.tabsByName[self:GetFrontTabName()];
        local category = settings.category or self.settings.categories[1].name;
        local location = settings.location;
        local event;
        if (self.settings.reuseExpiredReminders) then
            event = tab:FindOldestMatchingEvent(desc, self.charID, category);
            if (event) then
                event:SetResetTimes("*:3");
                event:SetExpirationTime(nextResetTime);
                event:SetLastAutomaticUpdate(self.charID, "tasks");
                self:CascadeNotify(L:GetText("/Cascade/ReminderUpdated"), event);
                tab:ReapplySort("remaining");
                settings.location = event:GetLocation();
                return self:SaveSettings();
            end
        end
        event = tab:CreateNewEvent(desc, self.charID, nextResetTime, "*:3", category, location, false);
        if (event) then
            event:SetLastAutomaticUpdate(self.charID, "tasks");
            self:CascadeNotify(L:GetText("/Cascade/ReminderCreated"), event);
        end
        self:SaveSettings();
    end

end

function Window:QuestBestowed(questName)
    local settings = self.charSettings.autoReminders.quests;
    if (settings.enabled) then
        if (self:QuestIsTask(questName)) then
            -- Not interested in task quest bestowals.
            return;
        end
        local info = settings.questInfo[questName] or {};
        settings.questInfo[questName] = info;
        if (info.lastCompleted) then
            info.repeated = true;
            if (info.enabled == nil) then
                QuestRepeatedDialog.GetInstance(self, questName, info, self.charSettings, self.charID);
            elseif (info.enabled and info.rotation) then
                -- Verify that this quest is being bestowed on an available day, according to
                -- the rotation schedule.  If not, then the schedule probably changed due to a
                -- server reset.
                if (not Schedule(info.rotation):AvailableInRotation(Time())) then
                    UnexpectedBestowalDialog.GetInstance(self, questName, info, self.charSettings, self.charID);
                end
            end
        end
    end
end

function Window:QuestIsTask(questName)
    if (L:GetClientLanguage() == Turbine.Language.German) then
        -- Every task quest name in the game in the German client has the word "Auftrag" in it except these:
        if (string.match(questName, "^Eroberung von Gorgoroth:")) then
            return true;
        end
    end
    return string.match(questName, self.identifyTaskRegexp);
end

function Window:DiscoverRecipes()
    self:SetChatParsingPaused(true);
    self.freezeWarning = Thurallor.UI.FreezeWarning(L:GetText("/Defaults/WindowTitle"), L:GetText("/ReadingRecipes"), function()
        self.recipes = {
            loaded = true;
            cooldown = {};
            versions = {};
        }
        local attribs = self.player:GetAttributes();
        if (attribs.GetProfessionInfo) then
            -- Monster players can't do crafting
            for profStr, prof in pairs(Turbine.Gameplay.Profession) do
                local profInfo = attribs:GetProfessionInfo(prof);
                if (profInfo) then
                    for r = 1, profInfo:GetRecipeCount() do
                        local recipe = profInfo:GetRecipe(r);
                        local cooldown = recipe:GetCooldown();
                        if (cooldown > 0) then
                            local recipeName = recipe:GetName();
                            local versions = self.recipes.versions[recipeName];
                            if (not versions) then
                                versions = {};
                                self.recipes.versions[recipeName] = versions;
                            end
                            table.insert(versions, recipe);
                            
                            -- See if this recipe appears in multiple tiers; if so, then we need to include that in the reminder description.
                            if (recipe:GetTier() ~= versions[1]:GetTier()) then
                                versions[1].multiTier = true;
                                recipe.multiTier = true;
                            end
                            
                            -- For now, we assume that recipes with the same name in different professions have the same cooldown.
                            self.recipes.cooldown[recipeName] = cooldown;
                        end
                    end
                end
            end
        end
        self:SetChatParsingPaused(false);
        self.freezeWarning = nil;
    end);
end

-- Identifies the profession, tier, and category of a just-completed recipe, based on the ingredients that were recently consumed
function Window:IdentifyRecipeVersion(recipeName)
    local function GetRecipeInfo(recipe)
        local prevContext = L:SetContext("/Defaults/Professions");
        local profName = L:GetClientLanguageText(tostring(recipe:GetProfession()));
        L:SetContext("/Defaults/CraftingTiers");
        local tierName = L:GetClientLanguageText(tostring(recipe:GetTier()));
        local categoryName = recipe:GetCategoryName();
        L:SetContext(prevContext);
        return profName, tierName, categoryName, recipe.multiTier;
    end

    local versions = self.recipes.versions[recipeName];
    if (#versions == 1) then
        return GetRecipeInfo(versions[1]);
    end

    -- Otherwise there are multiple recipes with this name (in different professions or tiers of the same profession).
    -- Identify which one it is by checking the recently-consumed ingredients.
    for _, recipe in pairs(versions) do

        -- Make a table of the ingredients for this recipe
        local ingredients = {};
        for i = 1, recipe:GetIngredientCount() do
            local ingredient = recipe:GetIngredient(i);
            local quantity = ingredient:GetRequiredQuantity();
            local itemInfo = ingredient:GetItemInfo();
            table.insert(ingredients, { quantity, itemInfo:GetName(), itemInfo:GetNameWithQuantity() });
        end
        -- See if the last few items removed match the ingredients for this recipe
        for _, itemRemoved in ipairs(self.itemRemovedQueue) do
            local quantity, itemName = unpack(itemRemoved);
            local continue = false;
            for i = 1, #ingredients do
                local ingredientQuantity, ingredientName, ingredientNamePlural = unpack(ingredients[i]);
                if ((itemName == ingredientName) or (itemName == ingredientNamePlural)) then
                    ingredientQuantity = ingredientQuantity - quantity;
                    if (ingredientQuantity < 0) then
                        -- Used too many ingredients; must be some other recipe.
                        continue = false;
                        break;
                    else
                        if (ingredientQuantity > 0) then
                            ingredients[i] = { ingredientQuantity, ingredientName, ingredientNamePlural };
                        else
                            table.remove(ingredients, i);
                        end
                        -- Found and removed ingredient from the list.
                        continue = true;
                        break;
                    end
                end
            end
            -- Didn't find the item in the ingredient list.  Must be some other recipe.
            if (not continue) then
                break;
            end
            if (#ingredients == 0) then
                -- Removed all ingredients from the list.  Recipe found!
                self.itemRemovedQueue = {};
                return GetRecipeInfo(recipe);
            end
        end
    end

    -- Didn't find a matching recipe, probably because Universal Ingredient Packs were used.  No way to disambiguate these, so punt.
    self.itemRemovedQueue = {};
    return nil;
end

function Window:ItemRemoved(itemStr)
    local quantity, itemName = string.match(itemStr, "^([0-9]+) (.*)$");
    if (quantity) then
        quantity = tostring(quantity);
    else
        quantity = 1;
        itemName = itemStr;
    end
    table.insert(self.itemRemovedQueue, 1, { quantity, itemName });
end

function Window:NextScope()
    local scope = self.settings.viewingScope;
    if (scope == "server") then
        scope = "character";
    elseif (scope == "character") then
        scope = "account";
    else -- (scope == "account")
        scope = "server";
    end
    self.settings.viewingScope = scope;
    self.scopeTooltip:Update();
    self:RebuildTabs(); -- results in a call to SetScope()
end

function Window:SetScope(scope)
    self.settings.viewingScope = scope;
    self.scopeButton:SetBackground(imagePath .. "scope_" .. scope .. ".tga");
    self.scopeButton:SetHighlightedBackground(imagePath .. "scope_" .. scope .. "_highlight.tga");
    self.scopeButton:SetMouseDownBackground(imagePath .. "scope_" .. scope .. "_click.tga");
    local frontTabName, newFrontTab = self:GetFrontTabName(), nil;
    for name, tab in pairs(self.tabsByName) do
        tab:SendToBack();
        local hide = self.settings.hideEmptyTabsWhenFiltering and tab:IsEmpty();
        tab:SetHidden(hide);
        if (not hide) then
            newFrontTab = tab;
        end
    end
    local frontTab = self.tabsByName[frontTabName];
    if (not frontTab:IsHidden()) then
        frontTab:BringToFront();
    elseif (newFrontTab) then
        newFrontTab:BringToFront();
    else
        -- No tabs to display; disable the (+) button
        DoCallbacks(self.tabStack, "FrontTabChanged", nil);
    end
    self.tabStack:RedistributeTabs();
end

function Window:CreateNewEvent()
    local frontTab = self.tabsByName[self:GetFrontTabName()];
    local expTime;
    if (self.settings.defaultDelayMethod == "now") then
        expTime = Turbine.Engine.GetGameTime();
    elseif (self.settings.defaultDelayMethod == "dailyResetTime") then
        expTime = self.nextServerResetTime;
    elseif (self.settings.defaultDelayMethod == "atNextLogin") then
        expTime = 0;
    elseif (self.settings.defaultDelayMethod == "never") then
        expTime = -1;
    else -- (self.settings.defaultDelayMethod == "delayFromNow")
        expTime = Turbine.Engine.GetGameTime() + self.settings.defaultDelay;
    end
    if (not frontTab:IsColumnShowing("remaining")) then
        expTime = -1; -- never
    end
    local catNames, catExists = self:GetUserCategories();
    local category = self.settings.defaultCategory;
    local location = nil;
    local checked = false;
    if (not catExists[category]) then
        category = self.settings.categories[1].name;
    end
    local event = frontTab:CreateNewEvent(L:GetText("/Defaults/ReminderDesc"), self.charID, expTime, nil, category, location, checked);
    if (event) then
        frontTab:SelectEvent(event);
    end
    self:SaveSettings();
end

function Window:RaidLockUpdated(lockName, days, hours, minutes, completions, notes)
    local settings = self.charSettings.autoReminders.raidlocks;
    local info = settings.lockInfo[lockName];
    if (not info) then
        -- Newly discovered raid lock.
        info = { resetTimes = "*:3" };
        settings.lockInfo[lockName] = info;
        if (settings.enabled) then
            RaidLockDialog.GetInstance(self, lockName, info, self.charSettings, self.charID, notes);
        end
    elseif (settings.enabled and info.enabled) then
        -- User has requested reminders for this lock.  Create one.
        self:AddRaidLockReminder(lockName, days, hours, minutes, completions, self.charID, notes);
    end
    self:SaveSettings();
end

function Window:AddRaidLockReminder(lockName, days, hours, minutes, completions, charID, notes)
    charID = charID or self.charID;
    local settings = self.settings.characters[charID].autoReminders.raidlocks;
    local info = settings.lockInfo[lockName];
    local tab = self.tabsByName[info.tab] or self.tabsByName[settings.tab] or self.tabsByName[self:GetFrontTabName()];
    local desc = info.desc or lockName;
    local category = info.category or settings.category or self.settings.categories[1].name;
    local location = info.location;
    local resetTimes = info.resetTimes;
    local event, expTime;
    if (days) then
        -- "Bratha Tasakh's Chest - Tier 1 resets in: 1d - 4h - 14m."
        -- "Shaktur's Mithril Chest: You have 1 favoured completion remaining."
        expTime = Turbine.Engine.GetGameTime() + days * (24 * 60 * 60) + hours * (60 * 60) + minutes * 60;
    else
        -- "Gast Nûl's Gold Chest: You have 18 completions remaining."
        expTime = Schedule(resetTimes):GetNextTime(nil, true):GetGameTime();
    end
    if (self.settings.reuseExpiredReminders) then
        event = tab:FindOldestMatchingEvent(desc, charID, category);
        if (event) then
            self:UpdateRaidLockNotes(event, notes);
            event:SetExpirationTime(expTime);
            event:SetResetTimes(resetTimes);
            event:SetLastAutomaticUpdate(charID, "raidlocks", lockName);
            self:CascadeNotify(L:GetText("/Cascade/ReminderUpdated"), event);
            tab:ReapplySort("remaining");
            return event;
        end
    end
    event = tab:CreateNewEvent(desc, charID, expTime, resetTimes, category, location, false);
    if (event) then
        self:UpdateRaidLockNotes(event, notes);
        event.settings.postpone.method = "ScheduledResetTime";
        event:SetLastAutomaticUpdate(charID, "raidlocks", lockName);
        self:CascadeNotify(L:GetText("/Cascade/ReminderCreated"), event);
        return event;
    end
end

function Window:UpdateRaidLockNotes(event, notes)
    if (self.charSettings.autoReminders.raidlocks.notesEnabled) then
        local prevNotes = event:GetNotes();
        if (prevNotes) then
            local regexp = L:GetClientLanguageText("/Chat/RaidFavoredCompletionsNotes") .. "\n?";
            if (prevNotes:match(regexp)) then
                notes = prevNotes:gsub(regexp, notes or "");
            elseif (notes) then
                notes = notes .. "\n" .. prevNotes;
            else
                notes = prevNotes;
            end
        end
        event:SetNotes(notes);
    end
end

function Window:AddCraftingReminder(recipeName)
    local settings = self.charSettings.autoReminders.crafting;
    local tab = self.tabsByName[settings.tab];
    if (not tab) then
        tab = self.tabsByName[self:GetFrontTabName()];
    end
    local profName, tierName, categoryName, multiTier = self:IdentifyRecipeVersion(recipeName);
    if (profName) then
        local desc = profName;
        if (multiTier) then
            desc = desc .. " (" .. tierName .. ")";
        end
        if (L:GetClientLanguage() == Turbine.Language.French) then
            -- Not using L:GetLanguage() above so desc won't change when user selects a new language.
            desc = desc .. " ";
        end
        local desc = desc .. ": " .. recipeName;
        local notes = profName .. " > " .. tierName .. " > " .. categoryName .. " > " .. recipeName;
        local expTime = self.recipes.cooldown[recipeName] + Turbine.Engine.GetGameTime();
        local category = settings.category or self.settings.categories[1].name;
        local location, event = settings.location;
        if (self.settings.reuseExpiredReminders) then
            event = tab:FindOldestMatchingEvent(desc, self.charID, category);
            if (event) then
                event:SetExpirationTime(expTime);
                event:SetLastAutomaticUpdate(self.charID, "crafting", recipeName);
                self:CascadeNotify(L:GetText("/Cascade/ReminderUpdated"), event);
                tab:ReapplySort("remaining");
                settings.location = event:GetLocation();
                return self:SaveSettings();
            end
        end
        event = tab:CreateNewEvent(desc, self.charID, expTime, nil, category, location, false);
        if (event) then
            event:SetNotes(notes);
            event:SetLastAutomaticUpdate(self.charID, "crafting",recipeName);
            self:CascadeNotify(L:GetText("/Cascade/ReminderCreated"), event);
        end
        self:SaveSettings();
    end
end

function Window:AddCrateReminder(crateName)
    local settings = self.charSettings.autoReminders.crates;
    local tab = self.tabsByName[settings.tab];
    if (not tab) then
        tab = self.tabsByName[self:GetFrontTabName()];
    end
    local cooldown = 22 * 60 * 60;
    local expTime = cooldown + Turbine.Engine.GetGameTime();
    local category = settings.category or self.settings.categories[1].name;
    local location = settings.location;
    local desc, event = L:GetText("/Defaults/CrateReminderDesc");
    desc = string.gsub(desc, "<cratename>", crateName);
    if (self.settings.reuseExpiredReminders) then
        event = tab:FindOldestMatchingEvent(desc, self.charID, category);
        if (event) then
            event:SetExpirationTime(expTime);
            event:SetLastAutomaticUpdate(self.charID, "crates", crateName);
            self:CascadeNotify(L:GetText("/Cascade/ReminderUpdated"), event);
            tab:ReapplySort("remaining");
            settings.location = event:GetLocation();
            return self:SaveSettings();
        end
    end
    event = tab:CreateNewEvent(desc, self.charID, expTime, nil, category, location, false);
    if (event) then
        event:SetLastAutomaticUpdate(self.charID, "crates", crateName);
        self:CascadeNotify(L:GetText("/Cascade/ReminderCreated"), event);
    end
    self:SaveSettings();
end

-- If 'startup' is true, then no bubble notification will be generated
function Window:AddAlert(event, startup)
    self:RemoveAlert(event); -- guard against duplicates
    local tabSettings = event.tab.settings;
    if (tabSettings.showPopupNotifications) then
        event:ShowPopUp();
    end
    if (tabSettings.updateIcon) then
        table.insert(self.alerts, event);
        self.icon:SetDisplayedNumber(#self.alerts);
        if (not self:IsVisible()) then
            self.icon:DoShockwave();
        end
    end
    if (not startup) then
        self:CascadeNotify("", event, event:GetColor());
    end
end

function Window:CascadeNotify(prefix, event, color)
    if (not self.settings.showCascade) then
        return;
    end
    if (not color) then
        color = Turbine.UI.Color.White;
    end
    local text = prefix;
    if (event) then
        if (not event:IsMine()) then
            text = text .. "[" .. self:GetCharNameWithServer(event:GetCharacterID()) .. "] ";
        end
        text = text .. event:GetDescription();
    end
    self.icon:AddCascadeMessage(text, color);
end

function Window:RemoveAlert(event)
    for n, e in ipairs(self.alerts) do
        if (e == event) then
            table.remove(self.alerts, n);
            break;
        end
    end
    event:HidePopUp();
    self.icon:SetDisplayedNumber(#self.alerts);
end

function Window:GetIconTooltip()
    if (#self.alerts == 0) then
        return self:GetText();
    else
        local function AddLabel(parent, top, prevMaxWidth, text, color)
            local label = Turbine.UI.Label();
            label:SetFont(Turbine.UI.Lotro.Font.Verdana14);
            label:SetForeColor(color);
            label:SetText(text);
            label:AutoSize();
            label:SetParent(parent);
            label:SetPosition(3, top);
            return top + 14, math.max(prevMaxWidth, label:GetWidth());
        end
    
        -- Make a list of up to ten expired events.
        local control, label = Turbine.UI.Control();
        local top, width = 3, 0;
        for n = #self.alerts, math.max(1, #self.alerts - 9), -1 do
            local event = self.alerts[n];
            local color = event:GetColor();
            local desc = event:GetDescription();
            local charID = event:GetCharacterID();
            if (not event:IsMine()) then
                desc = "[" .. self:GetCharNameWithServer(charID) .. "] " .. desc;
            end
            top, width = AddLabel(control, top, width, desc, color);
        end
        if (#self.alerts > 10) then
            top, width = AddLabel(control, top, width, "(+" .. (#self.alerts - 10) .. ")", Turbine.UI.Color.White);
        end
        control:SetSize(width, top + 3);
        control:SetBackColor(Turbine.UI.Color(0.9, 0, 0, 0));
        return control;
    end
end

function Window:AddShimmer(controls)
    for c, ctl in pairs(controls) do
        self.shimmering:Add(ctl);
    end
end

-- Dims the window and disables it until the specified child window closes.
function Window:WaitForChild(child)
    if (child and self.mask) then
        -- Child changed.  This will probably never happen, but whatever.
        -- Remove previous child.
        self:WaitForChild(nil);
    end
    if (child) then
        self:SetOpacity(0.75);
        self.mask = Turbine.UI.Control();
        self.mask:SetParent(self);
        self.mask:Focus();
        self.mask:SetSize(self:GetSize());
        self.mask:SetZOrder(2147483647);
        self.mask:SetBackColor(Turbine.UI.Color(0.25, 0, 0, 0));
        self.mask:SetBackColorBlendMode(Turbine.UI.BlendMode.Overlay);
        self.mask.child = child;
        self.mask.MouseClick = function(ctl)
            local x, y = Turbine.UI.Display.GetMousePosition();
            local width, height = ctl.child:GetSize();
            local left = math.max(0, x - width / 2);
            local top = math.max(0, y - height/ 2);
            ctl.child:SetPosition(left, top);
        end
        self.mask.callback = function()
            self:WaitForChild(nil);
        end
        AddCallback(child, "Closing", self.mask.callback);
    else
        RemoveCallback(self.mask.child, "Closing", self.mask.callback);
        self:SetOpacity(1);
        self.mask:SetParent(nil);
        self.mask = nil;
    end
end

Compare with Previous | Blame


All times are GMT -5. The time now is 09:35 PM.


Our Network
EQInterface | EQ2Interface | Minion | WoWInterface | ESOUI | LoTROInterface | MMOUI | Swtorui