main
jeffi 5 months ago
parent 1a796811bc
commit 6a87afca61
  1. 17
      .editorconfig
  2. 20
      src/Aura/Aura.lua
  3. 39
      src/Bastion/Bastion.lua
  4. 4
      src/Item/Item.lua
  5. 127
      src/Object/Object.lua
  6. 4
      src/Spell/Spell.lua
  7. 31
      src/TimeToDie/TimeToDie.lua
  8. 22
      src/Unit/Unit.lua
  9. 61
      src/_bastion.lua

@ -4,14 +4,15 @@
root = true root = true
[*.lua] [*.lua]
call_parentheses = Always
charset = utf-8
collapse_simple_statement = Never
end_of_line = lf
indent_size = 4
indent_style = space
indent_type = Spaces indent_type = Spaces
column_width = 180
indent_width = 4 indent_width = 4
insert_final_newline = true
max_line_length = 1000
quote_style = AutoPreferDouble quote_style = AutoPreferDouble
call_parentheses = Always trim_trailing_whitespace = true
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

@ -45,7 +45,6 @@ end
-- Equals -- Equals
---@param other Bastion.Aura | Bastion.Spell ---@param other Bastion.Aura | Bastion.Spell
---@return boolean
function Aura:__eq(other) function Aura:__eq(other)
if getmetatable(other) == Aura then if getmetatable(other) == Aura then
return self:GetSpell():GetID() == other:GetSpell():GetID() return self:GetSpell():GetID() == other:GetSpell():GetID()
@ -59,7 +58,6 @@ function Aura:__eq(other)
end end
-- tostring -- tostring
---@return string
function Aura:__tostring() function Aura:__tostring()
return "Bastion.__Aura(" .. self:GetSpell():GetID() .. ")" .. " - " .. (self:GetName() or "''") return "Bastion.__Aura(" .. self:GetSpell():GetID() .. ")" .. " - " .. (self:GetName() or "''")
end end
@ -105,8 +103,8 @@ function Aura:New(unit, index, type)
return Aura:CreateFromUnitAuraInfo(unitAuraInfo, index, type) return Aura:CreateFromUnitAuraInfo(unitAuraInfo, index, type)
end end
local name, icon, count, dispelType, duration, expirationTime, source, isStealable, nameplateShowPersonal, spellId, canApplyAura, isBossDebuff, castByPlayer, nameplateShowAll, timeMod, v1, v2, v3, v4 = ---@diagnostic disable-next-line: deprecated
UnitAura(unit:GetOMToken(), index, type) local name, icon, count, dispelType, duration, expirationTime, source, isStealable, nameplateShowPersonal, spellId, canApplyAura, isBossDebuff, castByPlayer, nameplateShowAll, timeMod, v1, v2, v3, v4 = UnitAura(unit:GetOMToken(), index, type)
---@class Bastion.Aura ---@class Bastion.Aura
local self = setmetatable({}, Aura) local self = setmetatable({}, Aura)
self.aura = { self.aura = {
@ -174,7 +172,6 @@ end
---@param unitAuraInfo? AuraData ---@param unitAuraInfo? AuraData
---@param index? number ---@param index? number
---@param type? "HELPFUL"|"HARMFUL" ---@param type? "HELPFUL"|"HARMFUL"
---@return Bastion.Aura
function Aura:CreateFromUnitAuraInfo(unitAuraInfo, index, type) function Aura:CreateFromUnitAuraInfo(unitAuraInfo, index, type)
if unitAuraInfo then if unitAuraInfo then
---@class Bastion.Aura ---@class Bastion.Aura
@ -208,67 +205,56 @@ function Aura:CreateFromUnitAuraInfo(unitAuraInfo, index, type)
end end
-- Check if the aura is valid -- Check if the aura is valid
---@return boolean
function Aura:IsValid() function Aura:IsValid()
return self.aura.name ~= nil return self.aura.name ~= nil
end end
-- Check if the aura is up -- Check if the aura is up
---@return boolean
function Aura:IsUp() function Aura:IsUp()
return self:IsValid() and (self:GetDuration() == 0 or self:GetRemainingTime() > 0) return self:IsValid() and (self:GetDuration() == 0 or self:GetRemainingTime() > 0)
end end
-- Check if the aura is down -- Check if the aura is down
---@return boolean
function Aura:IsDown() function Aura:IsDown()
return not self:IsUp() return not self:IsUp()
end end
-- Get the auras index -- Get the auras index
---@return number
function Aura:GetIndex() function Aura:GetIndex()
return self.aura.index return self.aura.index
end end
-- Get the auras type -- Get the auras type
---@return string
function Aura:GetType() function Aura:GetType()
return self.aura.type return self.aura.type
end end
-- Get the auras name -- Get the auras name
---@return string
function Aura:GetName() function Aura:GetName()
return self.aura.name return self.aura.name
end end
-- Get the auras icon -- Get the auras icon
---@return number
function Aura:GetIcon() function Aura:GetIcon()
return self.aura.icon return self.aura.icon
end end
-- Get the auras count -- Get the auras count
---@return number
function Aura:GetCount() function Aura:GetCount()
return self.aura.count return self.aura.count
end end
-- Get the auras dispel type -- Get the auras dispel type
---@return string
function Aura:GetDispelType() function Aura:GetDispelType()
return self.aura.dispelType return self.aura.dispelType
end end
-- Get the auras duration -- Get the auras duration
---@return number
function Aura:GetDuration() function Aura:GetDuration()
return self.aura.duration return self.aura.duration
end end
-- Get the auras refresh status -- Get the auras refresh status
---@return boolean
function Aura:Refreshable() function Aura:Refreshable()
if not self:IsUp() then if not self:IsUp() then
return true return true
@ -277,7 +263,6 @@ function Aura:Refreshable()
end end
-- Get the auras remaining time -- Get the auras remaining time
---@return number
function Aura:GetRemainingTime() function Aura:GetRemainingTime()
local remainingTime = self:GetExpirationTime() - GetTime() local remainingTime = self:GetExpirationTime() - GetTime()
@ -293,7 +278,6 @@ function Aura:GetElapsedPercent()
end end
-- Get the auras expiration time -- Get the auras expiration time
---@return number
function Aura:GetExpirationTime() function Aura:GetExpirationTime()
return self.aura.expirationTime return self.aura.expirationTime
end end

@ -119,7 +119,7 @@ local function CheckFileExtensions(path, extensions)
newPath = AppendExtension(newPath, extension) newPath = AppendExtension(newPath, extension)
end end
if FileExists(newPath) then if FileExists(newPath) then
return newPath:sub(1, (extension:len() + 2) * -1), true return newPath:sub(1, (extension:len() + 2) * -1), true, extension
end end
end end
return path, false return path, false
@ -136,6 +136,7 @@ function Bastion:Require(filePath, ...)
loadedPath = filePath.filePath or filePath, loadedPath = filePath.filePath or filePath,
reloadable = not filePath.reloadable and false or false, reloadable = not filePath.reloadable and false or false,
order = #Bastion.LoadedFiles + 1, order = #Bastion.LoadedFiles + 1,
ext = false,
} }
local pathResolutionShortcut = loadedFile.originalPath:sub(1, 1) == "@" and Bastion.Paths.BastionScripts or local pathResolutionShortcut = loadedFile.originalPath:sub(1, 1) == "@" and Bastion.Paths.BastionScripts or
@ -158,27 +159,51 @@ function Bastion:Require(filePath, ...)
end end
return 1, loadResults return 1, loadResults
end end
--Log(string.format("Bastion:Require - No files found in directory: %s", loadedFile.newPath))
return 0, SafePack(nil) return 0, SafePack(nil)
end end
local found = false local found = false
-- Check if file path has a .lua or .luac extension. If not, try to add one and check if the file exists -- Check if file path has a .lua or .luac extension. If not, try to add one and check if the file exists
loadedFile.loadedPath, found = CheckFileExtensions(loadedFile.newPath, { "lua", "luac" }) loadedFile.loadedPath, found, loadedFile.ext = CheckFileExtensions(loadedFile.newPath, { "lua", "luac" })
if not found then if not found then
--Log(string.format("Bastion:Require - Not Found: %s (%s)", loadedFile.newPath, loadedFile.originalPath))
return 0, SafePack(nil) return 0, SafePack(nil)
end end
if not loadedFile.reloadable then if not loadedFile.reloadable then
if Bastion:CheckIfLoaded(loadedFile.loadedPath) then if Bastion:CheckIfLoaded(loadedFile.loadedPath) then
--Log(string.format("Bastion:Require - Already loaded: %s (%s)", loadedFile.newPath, loadedFile.originalPath))
return 2, SafePack(nil) return 2, SafePack(nil)
end end
end end
table.insert(Bastion.LoadedFiles, loadedFile) table.insert(Bastion.LoadedFiles, loadedFile)
--[[ local fileToRead = loadedFile.loadedPath .. (loadedFile.ext and "." .. loadedFile.ext or "")
local luaText = ReadFile(fileToRead)
if luaText then
local func, err = loadstringsecure(luaText, loadedFile.loadedPath)
if type(func) ~= "function" then
Bastion.Util:Print("Error loading file", loadedFile.loadedPath, err)
return 0, SafePack(nil)
end
setfenv(func, setmetatable({ ["_T"] = _T }, {
__index = function(t, v)
if v == "require" then
print("require")
end
return Tinkr.__ENV(t, v)
end
}))
local call = { pcall(func, Tinkr, Bastion, ...) }
if not call[1] then
Bastion.Util:Print("Error executing file", loadedFile.loadedPath, call[2])
return 0, SafePack(nil)
end
table.remove(call, 1)
return 1, unpack(call)
else
return 0, SafePack(nil)
end ]]
return 1, SafePack(require(loadedFile.loadedPath, Bastion, ...)) return 1, SafePack(require(loadedFile.loadedPath, Bastion, ...))
end end
@ -414,13 +439,13 @@ function Bastion:Load()
end end
local sourceUnit = Bastion.Globals.UnitManager:GetObject(sourceGUID) local sourceUnit = Bastion.Globals.UnitManager:GetObject(sourceGUID)
if sourceUnit and sourceUnit:Exists() then if sourceUnit and sourceUnit:Exists() and sourceUnit:IsAffectingCombat() then
sourceUnit:SetLastCombatTime(currTime) sourceUnit:SetLastCombatTime(currTime)
end end
local destUnit = Bastion.Globals.UnitManager:GetObject(destGUID) local destUnit = Bastion.Globals.UnitManager:GetObject(destGUID)
if destUnit and destUnit:Exists() then if destUnit and destUnit:Exists() and (destUnit:IsAffectingCombat() or (sourceUnit and sourceUnit:IsAffectingCombat())) then
destUnit:SetLastCombatTime(currTime) destUnit:SetLastCombatTime(currTime)
end end

@ -180,6 +180,7 @@ function Item:GetItemSpell()
if C_Item.GetItemSpell then if C_Item.GetItemSpell then
return C_Item.GetItemSpell(self:GetID()) return C_Item.GetItemSpell(self:GetID())
end end
---@diagnostic disable-next-line: deprecated
return GetItemSpell(self:GetID()) return GetItemSpell(self:GetID())
end end
@ -200,6 +201,7 @@ function Item:GetItemInfo()
if C_Item.GetItemInfo then if C_Item.GetItemInfo then
return C_Item.GetItemInfo(self:GetID()) return C_Item.GetItemInfo(self:GetID())
end end
---@diagnostic disable-next-line: deprecated
return GetItemInfo(self:GetID()) return GetItemInfo(self:GetID())
end end
@ -385,6 +387,7 @@ function Item:IsUsable()
local usable, noMana = C_Item.IsUsableItem(self:GetID()) local usable, noMana = C_Item.IsUsableItem(self:GetID())
return usable or usableExcludes[self:GetID()] return usable or usableExcludes[self:GetID()]
end end
---@diagnostic disable-next-line: deprecated
local usable, noMana = IsUsableItem(self:GetID()) local usable, noMana = IsUsableItem(self:GetID())
return usable or usableExcludes[self:GetID()] return usable or usableExcludes[self:GetID()]
end end
@ -400,6 +403,7 @@ function Item:IsEquippable()
if C_Item.IsEquippableItem then if C_Item.IsEquippableItem then
return C_Item.IsEquippableItem(self:GetID()) return C_Item.IsEquippableItem(self:GetID())
end end
---@diagnostic disable-next-line: deprecated
return IsEquippableItem(self:GetID()) return IsEquippableItem(self:GetID())
end end

@ -0,0 +1,127 @@
---@type Tinkr
local Tinkr,
---@class Bastion
Bastion = ...
local Class = Tinkr.Util.Class
---@class Bastion.Object
---@field unit WowGameObject
local Object = {}
function Object:__index(k)
if k == "unit" then
return rawget(self, k)
end
local response = Bastion.ClassMagic:Resolve(Object, k)
if response == nil then
response = rawget(self, k)
end
if response == nil then
error("Object:__index: " .. k .. " does not exist")
end
return response
end
---@param unit WowGameObject
function Object:New(unit)
---@class Bastion.Object
local self = setmetatable({ unit = unit }, Object)
return self
end
function Object:Creator()
return self.unit:creator()
end
function Object:Distance(target)
return self.unit:distance(target)
end
function Object:Flags()
return self.unit:flags()
end
function Object:GameObjectType()
return self.unit:gameObjectType()
end
function Object:Guid()
return self.unit:guid()
end
function Object:Hash()
return self.unit:hash()
end
function Object:Height()
return self.unit:height()
end
function Object:Id()
return self.unit:id()
end
function Object:IsOutdoors()
return self.unit:isOutdoors()
end
function Object:IsSubmerged()
return self.unit:isSubmerged()
end
function Object:Lootable()
return self.unit:lootable()
end
function Object:ModelID()
return self.unit:modelID()
end
function Object:MovementFlag()
return self.unit:movementFlag()
end
function Object:Mover()
return self.unit:mover()
end
function Object:Name()
return self.unit:name()
end
function Object:RawPosition()
return self.unit:rawPosition()
end
function Object:Position()
return self.unit:position()
end
function Object:WorldPosition()
return self.unit:worldPosition()
end
function Object:RawRotation()
return self.unit:rawRotation()
end
function Object:Rotation()
return self.unit:rotation()
end
function Object:RightClick()
return self.unit:rightClick()
end
function Object:Sparkling()
return self.unit:sparkling()
end
function Object:Type()
return self.unit:type()
end

@ -121,6 +121,8 @@ function Spell:New(id)
local spellInfo = C_Spell.GetSpellInfo(id) local spellInfo = C_Spell.GetSpellInfo(id)
local maxRange = spellInfo and spellInfo.maxRange or 0 local maxRange = spellInfo and spellInfo.maxRange or 0
local minRange = spellInfo and spellInfo.minRange or 0 local minRange = spellInfo and spellInfo.minRange or 0
self.harmful = C_Spell.IsSpellHarmful(id)
self.helpful = C_Spell.IsSpellHelpful(id)
self.auras = {} self.auras = {}
self.overrides = {} self.overrides = {}
self.conditions = {} self.conditions = {}
@ -479,7 +481,7 @@ function Spell:EvaluateTraits(target)
if self.traits.target.exists then if self.traits.target.exists then
if (self.traits.target.player and not player:Exists()) then if (self.traits.target.player and not player:Exists()) then
return false return false
elseif (target and (target == Bastion.Globals.UnitManager:Get("none") or not target:Exists())) or (not target and not self:TargetExists()) then elseif type(target) ~= "nil" and not target:Exists() or not self:TargetExists() then
return false return false
end end
end end

@ -10,7 +10,7 @@ local TimeToDie = {
Refreshing = false, Refreshing = false,
Settings = { Settings = {
-- Refresh time (seconds) : min=0.1, max=2, default = 0.1 -- Refresh time (seconds) : min=0.1, max=2, default = 0.1
Refresh = 0.2, Refresh = 0.1,
-- History time (seconds) : min=5, max=120, default = 10+0.4 -- History time (seconds) : min=5, max=120, default = 10+0.4
HistoryTime = 10 + 0.4, HistoryTime = 10 + 0.4,
-- Max history count : min=20, max=500, default = 100 -- Max history count : min=20, max=500, default = 100
@ -65,7 +65,7 @@ function TimeToDie:Init()
if not Bastion.Globals.UnitInfo then if not Bastion.Globals.UnitInfo then
Bastion.Globals.UnitInfo = Bastion.Cache:New() Bastion.Globals.UnitInfo = Bastion.Cache:New()
Bastion.Globals.EventManager:RegisterWoWEvent("UNIT_HEALTH", function(unitId) Bastion.Globals.EventManager:RegisterWoWEvent("UNIT_HEALTH", function(unitId)
self:UNIT_HEALTH(unitId) --self:UNIT_HEALTH(unitId)
end) end)
end end
end end
@ -74,6 +74,22 @@ function TimeToDie:IterableUnits2()
return Bastion.ObjectManager.activeEnemies return Bastion.ObjectManager.activeEnemies
end end
function TimeToDie:Update()
local currentTime = GetTime()
for key, _ in pairs(self.Units) do
local unit = self.Units[key]
local obj = Bastion.Globals.UnitManager:Get(key)
if not obj:Exists() or (unit.history and unit.history[1] and (currentTime - (unit.history[1].time + unit.time) > self.Settings.InactiveTime and obj:InCombatOdds() < 80)) or (obj:IsDead() or not UnitCanAttack("player", obj:GetOMToken()) or (obj:GetHealth() <= 1 and obj:GetMaxHealth() > 1)) then
self:UNIT_DIED(key)
end
end
---@param unit Bastion.Unit
Bastion.ObjectManager.enemies:each(function(unit)
self:UNIT_HEALTH(unit:GetOMToken())
return false
end)
end
---@param force? boolean ---@param force? boolean
function TimeToDie:Refresh(force) function TimeToDie:Refresh(force)
if self:IsRefreshing() or (self.NextUpdate > GetTime() and not force) or (not Bastion.Enabled and not force) then if self:IsRefreshing() or (self.NextUpdate > GetTime() and not force) or (not Bastion.Enabled and not force) then
@ -83,18 +99,19 @@ function TimeToDie:Refresh(force)
self.Refreshing = true self.Refreshing = true
self.LastStart = currentTime self.LastStart = currentTime
self.NextUpdate = self.LastStart + 2 self.NextUpdate = self.LastStart + .5
local units = TimeToDie.Units --[[ local units = TimeToDie.Units
for key, _ in pairs(units) do for key, _ in pairs(units) do
local unit = units[key] local unit = units[key]
if unit.history and unit.history[1] then if unit.history and unit.history[1] then
local lastTime = unit.history[1].time + unit.time local lastTime = unit.history[1].time + unit.time
if currentTime - lastTime > self.Settings.InactiveTime and Bastion.Globals.UnitManager:Get(key):InCombatOdds() < 80 then if currentTime - lastTime > self.Settings.InactiveTime and Bastion.Globals.UnitManager:Get(key):InCombatOdds() < 80 then
units[key] = nil self:UNIT_DIED(key)
end end
end end
end end ]]
self:Update()
self.Refreshing = false self.Refreshing = false
end end
@ -108,7 +125,7 @@ end
---@param unitId UnitId ---@param unitId UnitId
function TimeToDie:UNIT_HEALTH(unitId) function TimeToDie:UNIT_HEALTH(unitId)
local currentTime = GetTime() local currentTime = GetTime()
if UnitExists(unitId) and UnitCanAttack("player", unitId) then if UnitExists(unitId) and UnitCanAttack("player", unitId) and not UnitIsDeadOrGhost(unitId) then
local unitGUID = UnitGUID(unitId) local unitGUID = UnitGUID(unitId)
if unitGUID then if unitGUID then
local unitTable = self.Units[unitGUID] local unitTable = self.Units[unitGUID]

@ -7,7 +7,7 @@ Bastion = ...
---@class Bastion.Unit ---@class Bastion.Unit
---@field cache Bastion.Cache ---@field cache Bastion.Cache
---@field id false | number ---@field id false | number
---@field ttd_ticker false | cbObject ---@field ttd_ticker false | FunctionContainer
---@field unit? WowGameObject ---@field unit? WowGameObject
---@field aura_table Bastion.AuraTable | nil ---@field aura_table Bastion.AuraTable | nil
local Unit = { local Unit = {
@ -41,12 +41,12 @@ function Unit:UpdateHealth()
end end
function Unit:__index(k) function Unit:__index(k)
local response = Bastion.ClassMagic:Resolve(Unit, k)
if k == "unit" then if k == "unit" then
return rawget(self, k) return rawget(self, k)
end end
local response = Bastion.ClassMagic:Resolve(Unit, k)
if response == nil then if response == nil then
response = rawget(self, k) response = rawget(self, k)
end end
@ -523,8 +523,6 @@ function Unit:GetRawUnit()
return self:GetOMToken() return self:GetOMToken()
end end
local isClassicWow = select(4, GetBuildInfo()) < 40000
-- Check if two units are in melee -- Check if two units are in melee
-- function Unit:InMelee(unit) -- function Unit:InMelee(unit)
-- return UnitInMelee(self:GetOMToken(), unit:GetOMToken()) -- return UnitInMelee(self:GetOMToken(), unit:GetOMToken())
@ -542,8 +540,6 @@ local flags = {
Unknown = 0x200000, Unknown = 0x200000,
} }
local losFlag = bit.bor(flags.M2Collision, flags.WMOCollision, flags.Terrain)
---@param attachmentId number ---@param attachmentId number
function Unit:GetAttachmentPosition(attachmentId) function Unit:GetAttachmentPosition(attachmentId)
local x, y, z = GetUnitAttachmentPosition(self:GetOMToken(), attachmentId) local x, y, z = GetUnitAttachmentPosition(self:GetOMToken(), attachmentId)
@ -629,11 +625,21 @@ local attachmentId = {
Unknown17 = 75, Unknown17 = 75,
} }
local losFlag = bit.bor(0x1, 0x10, 0x100000) local losFlag = bit.bor(flags.M2Collision, flags.WMOCollision, flags.EnityCollision)
local ignoreLoS = {
[91005] = true, -- Naraxas
}
-- Check if the unit can see another unit -- Check if the unit can see another unit
---@param unit Bastion.Unit ---@param unit Bastion.Unit
---@return boolean ---@return boolean
function Unit:CanSee(unit) function Unit:CanSee(unit)
local npcId = unit:GetID()
if npcId and ignoreLoS[npcId] then
return true
end
local ax, ay, az = ObjectPosition(self:GetOMToken()) local ax, ay, az = ObjectPosition(self:GetOMToken())
local ah = ObjectHeight(self:GetOMToken()) local ah = ObjectHeight(self:GetOMToken())
local attx, atty, attz = GetUnitAttachmentPosition(unit:GetOMToken(), attachmentId.Chest) local attx, atty, attz = GetUnitAttachmentPosition(unit:GetOMToken(), attachmentId.Chest)

@ -1,4 +1,65 @@
---@type Tinkr ---@type Tinkr
local Tinkr = ... local Tinkr = ...
---@param path string
---@param ...? any
local function require(path, ...)
local func = nil
local newPath = path
local loader = nil
if newPath:sub(-4) ~= ".lua" and newPath:sub(-5) ~= ".luac" then
if FileExists(newPath .. ".lua") then
newPath = newPath .. ".lua"
elseif FileExists(newPath .. ".luac") then
newPath = newPath .. ".luac"
else
Tinkr:OnError(string.format("File not found: path(%s) newPath(%s)", path, newPath), -1)
return
end
end
if newPath:sub(-4) == ".lua" then
func = loadstringsecure(ReadFile(newPath) or "", newPath)
elseif newPath:sub(-5) == ".luac" then
func = RunEncrypted(newPath)
else
Tinkr:OnError(string.format("Loader not found: path(%s) newPath(%s)", path, newPath), -1)
return
end
if type(func) == "string" then
if type(func) == "string" then
Tinkr:OnError(string.format("Loader did not return function: path(%s) newPath(%s) error(%s)", path, newPath, func or ""), -1)
return
end
end
setfenv(func, setmetatable({ ["_T"] = _T }, {
__index = function(t, v)
if v == "require" then
--print(v, t.__file)
return function(...)
return require(...)
end
end
if v == '__file' then
--print(v, newPath)
return path
end
if v == 'TINKR_SECURE' then
--print(v, t.__file, issecure(), Tinkr:GetSecure())
return Tinkr:GetSecure()
end
return Tinkr.__ENV(t, v)
end
}))
local call = { pcall(func, Tinkr, ...) }
-- print('call', call[1], call[2])
if not call[1] then
-- print(self:OnLoadingScreen())
Tinkr:OnError(call[2], -1)
end
table.remove(call, 1)
return unpack(call)
end
require("scripts/bastion/src/Bastion/Bastion"):Load() require("scripts/bastion/src/Bastion/Bastion"):Load()

Loading…
Cancel
Save