Update to 85fcda93b21460bd22830e1a72f6b416566d9132
I unfortunately forgot to update this repository with the latest commits.
This commit is contained in:
parent
4aa2943d29
commit
e73b2d849d
13 changed files with 564 additions and 111 deletions
|
@ -24,10 +24,8 @@ class_name CoreBaseModule
|
|||
|
||||
## Contains a reference to the CORE Object.
|
||||
var core: Core
|
||||
## Set to CORE's logger implementation.
|
||||
@onready var logger: CoreBaseModule = core.logger
|
||||
## Set to a [class CoreLoggerInstance] with the path you supplied to [method Core.register_custom_module]. You should use this over [code]logger[/code].
|
||||
var loggeri: CoreLoggerInstance
|
||||
## Set to a [class CoreLoggerInstance] with the path you supplied to [method Core.register_custom_module].
|
||||
var logger: CoreLoggerInstance
|
||||
## Marks a module as fully initialized and ready. **Don't forget to set this!**
|
||||
var initialized: bool = false
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@ func _init() -> void:
|
|||
# Global
|
||||
headless = false
|
||||
development = false
|
||||
custom_modules = false
|
||||
custom_modules = true
|
||||
automatic_shutdown = true
|
||||
hide_window_on_shutdown = true
|
||||
|
||||
|
|
|
@ -23,9 +23,11 @@ class_name CoreTypes
|
|||
|
||||
## Available version types, following the StarOpenSource Versioning Specification (SOSVS) version 1.
|
||||
enum VersionType { RELEASE, RELEASECANDIDATE, BETA, ALPHA }
|
||||
## Available log levels, followingthe StarOpenSource Logging Specification (SOSLS) version 1.
|
||||
## Available log levels, following the StarOpenSource Logging Specification (SOSLS) version 1.
|
||||
enum LoggerLevel { NONE, SPECIAL, ERROR, WARN, INFO, VERB, DIAG }
|
||||
## Available scene types.
|
||||
enum SceneType { NONE, DEBUG, CUTSCENE, MENU, MAIN, BACKGROUND }
|
||||
## To what degree [i]something[/i] should be blocked.
|
||||
enum BlockadeLevel { IGNORE, WARN, BLOCK }
|
||||
## All validation rules some data can be checked against.
|
||||
enum ValidationType { MATCHES_TYPE, MATCHES_CLASS, IN_RANGE, HAS_MINIMUM, HAS_MAXIMUM, HAS_VALUES, CONTAINS, MATCHES_REGEX, IS_NOT_EMPTY, IS_NOT_NULL, IS_NORMALIZED, IS_ORTHONORMALIZED }
|
||||
|
|
51
src/classes/validationschema.gd
Normal file
51
src/classes/validationschema.gd
Normal file
|
@ -0,0 +1,51 @@
|
|||
# CORE FRAMEWORK SOURCE FILE
|
||||
# Copyright (c) 2024 The StarOpenSource Project & Contributors
|
||||
# Licensed under the GNU Affero General Public License v3
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
## Validates multiple [CoreValidationSingle]s at once.
|
||||
extends Node
|
||||
class_name CoreValidationSchema
|
||||
|
||||
## Internal, don't modify.
|
||||
var core: Core
|
||||
## Internal, don't modify.
|
||||
var logger: CoreLoggerInstance
|
||||
## Internal, don't modify.
|
||||
var parent: Node
|
||||
## Contains the schema to validate.[br]
|
||||
## [b]Note: [i]Don't modify.[/i][/b]
|
||||
var schema: Dictionary = {}
|
||||
|
||||
func _init(core_new: Core, schema_new: Dictionary, parent_new: Node) -> void:
|
||||
core = core_new
|
||||
logger = core.logger.get_instance(core.basepath.replace("res://", "") + "src/classes/validationschema.gd", self)
|
||||
parent = parent_new
|
||||
schema = schema_new
|
||||
|
||||
# Check Dictionary
|
||||
for key in schema:
|
||||
if typeof(key) != TYPE_STRING: logger.error(core.stringify_variables("Could not parse schema: Schema key %key% is not of type String", { "key": key }))
|
||||
elif typeof(schema[key]) != TYPE_OBJECT: logger.error(core.stringify_variables("Could not parse schema: Schema value of %key% is not of type Object", { "key": key }))
|
||||
elif schema[key].get_class() != "Node": logger.error(core.stringify_variables("Could not parse schema: Schema value of %key% is not of type Node", { "key": key }))
|
||||
|
||||
func evaluate() -> Array[String]:
|
||||
var failed: Array[String] = []
|
||||
for single in schema:
|
||||
if !schema[single].evaluate():
|
||||
logger.error(core.stringify_variables("Single %single% failed", { "single": single }))
|
||||
failed.append(single)
|
||||
|
||||
return failed
|
312
src/classes/validationsingle.gd
Normal file
312
src/classes/validationsingle.gd
Normal file
|
@ -0,0 +1,312 @@
|
|||
# CORE FRAMEWORK SOURCE FILE
|
||||
# Copyright (c) 2024 The StarOpenSource Project & Contributors
|
||||
# Licensed under the GNU Affero General Public License v3
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
## Validates some [param data] against a set of predefined rules.
|
||||
extends Node
|
||||
class_name CoreValidationSingle
|
||||
|
||||
## Internal, don't modify.
|
||||
var core: Core
|
||||
## Internal, don't modify.
|
||||
var logger: CoreLoggerInstance
|
||||
## Is set to the parent owning the validation single.[br]
|
||||
## [b]Note: [i]Don't modify.[/i][/b]
|
||||
var parent: Node
|
||||
## Contains the data to validate.[br]
|
||||
## [b]Note: [i]Don't modify.[/i][/b]
|
||||
var data # This is the only instance where we don't want to define what type this variable should have
|
||||
|
||||
## All rules to evaluate in [method evaluate].[br]
|
||||
## [b]Note: [i]Don't modify.[/i][/b]
|
||||
var rules: Array[Dictionary]
|
||||
## Contains error messages for failed rules.[br]
|
||||
## [b]Note: [i]Don't modify.[/i][/b]
|
||||
var failures: Array[String] = []
|
||||
|
||||
# +++ constructor +++
|
||||
func _init(core_new: Core, data_new, parent_new: Node) -> void:
|
||||
core = core_new
|
||||
logger = core.logger.get_instance(core.basepath.replace("res://", "") + "src/classes/validationsingle.gd", self)
|
||||
parent = parent_new
|
||||
data = data_new
|
||||
|
||||
# +++ evaluation +++
|
||||
## Evaluates all set rules and returns [code]true[/code] if all rules passed, or [code]false[/code] if at least one failed.
|
||||
func evaluate() -> bool:
|
||||
# Reset failures
|
||||
failures = []
|
||||
|
||||
# Iterate through all rules
|
||||
for rule in rules:
|
||||
match(rule["type"]):
|
||||
CoreTypes.ValidationType.MATCHES_TYPE:
|
||||
var success: bool = false
|
||||
for type in rule["types"]:
|
||||
if typeof(data) == type:
|
||||
success = true
|
||||
|
||||
if !success:
|
||||
var types_string: String = ""
|
||||
for type in rule["types"]:
|
||||
if types_string == "": types_string = type_string(type)
|
||||
else: types_string = types_string + "|" + type_string(type)
|
||||
failures.append(core.stringify_variables("Data is not of expected type(s) %expected% (is %got%)", { "expected": types_string, "got": type_string(typeof(data)) }))
|
||||
CoreTypes.ValidationType.MATCHES_CLASS:
|
||||
# If not an object, skip
|
||||
if typeof(data) != Variant.Type.TYPE_OBJECT:
|
||||
logger.warn("Can't match class as data is not of type Object")
|
||||
continue
|
||||
|
||||
if rule["exact"]:
|
||||
# Only check if class name matches specified class
|
||||
if data.get_class() != rule["class"]:
|
||||
failures.append(core.stringify_variables("Expected exact class %expected% but data is of class %got%", { "expected": rule["class"], "got": data.get_class() }))
|
||||
else:
|
||||
# Check if class is or inherits specified class
|
||||
if !data.is_class(rule["class"]):
|
||||
failures.append(core.stringify_variables("Expected class %expected% but data is of class %got%", { "expected": rule["class"], "got": data.get_class() }))
|
||||
CoreTypes.ValidationType.IN_RANGE:
|
||||
# If not an int or float, skip
|
||||
match(rule["matched_against"]):
|
||||
"integer":
|
||||
if typeof(data) != Variant.Type.TYPE_INT:
|
||||
logger.warn("Can't match class as data is not of type int")
|
||||
continue
|
||||
"float":
|
||||
if typeof(data) != Variant.Type.TYPE_FLOAT:
|
||||
logger.warn("Can't match class as data is not of type float")
|
||||
continue
|
||||
_: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for IN_RANGE", { "value": rule["matched_against"] }))
|
||||
|
||||
# Check if from is bigger than to
|
||||
if rule["from"] > rule["to"]:
|
||||
logger.error(core.stringify_variables("Can't match invalid %type% range %from% to %to%", { "type": rule["matched_against"], "from": rule["from"], "to": rule["to"] }))
|
||||
continue
|
||||
|
||||
# Perform checks
|
||||
if data < rule["from"]: failures.append(core.stringify_variables("Data is smaller than %type% range start %expected%", { "type": rule["matched_against"], "expected": rule["from"] }))
|
||||
if data > rule["to"]: failures.append(core.stringify_variables("Data is larger than %type% range end %expected%", { "type": rule["matched_against"], "expected": rule["to"] }))
|
||||
CoreTypes.ValidationType.HAS_MINIMUM:
|
||||
# If not an int or float, skip
|
||||
match(rule["matched_against"]):
|
||||
"integer":
|
||||
if typeof(data) != Variant.Type.TYPE_INT:
|
||||
logger.warn("Can't determine if data has minimum value as data is not of type int")
|
||||
continue
|
||||
"float":
|
||||
if typeof(data) != Variant.Type.TYPE_FLOAT:
|
||||
logger.warn("Can't determine if data has minimum value as data is not of type float")
|
||||
continue
|
||||
_: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for HAS_MINIMUM", { "value": rule["matched_against"] }))
|
||||
|
||||
# Perform check
|
||||
if data < rule["minimum"]: failures.append(core.stringify_variables("Data is smaller than minimum %type% value %expected%", { "type": rule["matched_against"], "expected": rule["minimum"] }))
|
||||
CoreTypes.ValidationType.HAS_MAXIMUM:
|
||||
# If not an int or float, skip
|
||||
match(rule["matched_against"]):
|
||||
"integer":
|
||||
if typeof(data) != Variant.Type.TYPE_INT:
|
||||
logger.warn("Can't determine if data has maximum value as data is not of type int")
|
||||
continue
|
||||
"float":
|
||||
if typeof(data) != Variant.Type.TYPE_FLOAT:
|
||||
logger.warn("Can't determine if data has maximum value as data is not of type float")
|
||||
continue
|
||||
_: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for HAS_MINIMUM", { "value": rule["matched_against"] }))
|
||||
|
||||
# Perform check
|
||||
if data > rule["maximum"]: failures.append(core.stringify_variables("Data is smaller than minimum %type% value %expected%", { "type": rule["matched_against"], "expected": rule["maximum"] }))
|
||||
CoreTypes.ValidationType.HAS_VALUES:
|
||||
var success: bool = false
|
||||
|
||||
for value in rule["values"]:
|
||||
if data == value:
|
||||
success = true
|
||||
|
||||
if !success: failures.append("Data did not match a single provided value")
|
||||
CoreTypes.ValidationType.CONTAINS:
|
||||
# If not a String or StringName, skip
|
||||
if typeof(data) != Variant.Type.TYPE_STRING or typeof(data) != Variant.Type.TYPE_STRING_NAME:
|
||||
logger.warn("Can't determine if data contains values as data is not of type String or StringName")
|
||||
continue
|
||||
|
||||
var successes: int = 0
|
||||
for value in rule["values"]:
|
||||
if data.contains(value):
|
||||
successes += 1
|
||||
|
||||
# Success if equals or bigger than 'minimum_matches'
|
||||
if successes < rule["minimum_matches"]: failures.append(core.stringify_variables("Data did matched %got% out of %expected% expected strings", { "got": successes, "expected": rule["minimum_matches"] }))
|
||||
CoreTypes.ValidationType.MATCHES_REGEX:
|
||||
# If not a String or StringName, skip
|
||||
if typeof(data) != Variant.Type.TYPE_STRING or typeof(data) != Variant.Type.TYPE_STRING_NAME:
|
||||
logger.warn("Can't determine if data matches regex as data is not of type String or StringName")
|
||||
continue
|
||||
|
||||
var regex: RegEx = RegEx.new()
|
||||
# Compile regex
|
||||
regex.compile(rule["regex_string"])
|
||||
# Get result
|
||||
var result: RegExMatch = regex.search(data)
|
||||
# If result yielded no result, fail
|
||||
if !result: failures.append(core.stringify_variables("Data doesn't match regex %regex%", { "regex": rule["regex_string"] }))
|
||||
CoreTypes.ValidationType.IS_NOT_EMPTY:
|
||||
match(typeof(data)):
|
||||
Variant.Type.TYPE_STRING: if data == "": failures.append("Data string is empty")
|
||||
Variant.Type.TYPE_STRING_NAME: if data == "": failures.append("Data string name is empty")
|
||||
Variant.Type.TYPE_INT: if data == 0: failures.append("Data integer is zero")
|
||||
Variant.Type.TYPE_FLOAT: if data == 0.0: failures.append("Data float is zero")
|
||||
# If not a String, StringName, int or float, skip
|
||||
_: logger.warn("Can't determine if data is null as data is not of type String, StringName, int or float")
|
||||
CoreTypes.ValidationType.IS_NOT_NULL:
|
||||
# ⡴⠑⡄⠀⠀⠀⠀⠀⠀⠀ ⣀⣀⣤⣤⣤⣀⡀
|
||||
# ⠸⡇⠀⠿⡀⠀⠀⠀⣀⡴⢿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀
|
||||
# ⠀⠀⠀⠀⠑⢄⣠⠾⠁⣀⣄⡈⠙⣿⣿⣿⣿⣿⣿⣿⣿⣆
|
||||
# ⠀⠀⠀⠀⢀⡀⠁⠀⠀⠈⠙⠛⠂⠈⣿⣿⣿⣿⣿⠿⡿⢿⣆
|
||||
# ⠀⠀⠀⢀⡾⣁⣀⠀⠴⠂⠙⣗⡀⠀⢻⣿⣿⠭⢤⣴⣦⣤⣹⠀⠀⠀⢀⢴⣶⣆
|
||||
# ⠀⠀⢀⣾⣿⣿⣿⣷⣮⣽⣾⣿⣥⣴⣿⣿⡿⢂⠔⢚⡿⢿⣿⣦⣴⣾⠸⣼⡿
|
||||
# ⠀⢀⡞⠁⠙⠻⠿⠟⠉⠀⠛⢹⣿⣿⣿⣿⣿⣌⢤⣼⣿⣾⣿⡟⠉
|
||||
# ⠀⣾⣷⣶⠇⠀⠀⣤⣄⣀⡀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
|
||||
# ⠀⠉⠈⠉⠀⠀⢦⡈⢻⣿⣿⣿⣶⣶⣶⣶⣤⣽⡹⣿⣿⣿⣿⡇
|
||||
# ⠀⠀⠀⠀⠀⠀⠀⠉⠲⣽⡻⢿⣿⣿⣿⣿⣿⣿⣷⣜⣿⣿⣿⡇
|
||||
# ⠀⠀ ⠀⠀⠀⠀⠀⢸⣿⣿⣷⣶⣮⣭⣽⣿⣿⣿⣿⣿⣿⣿⠇
|
||||
# ⠀⠀⠀⠀⠀⠀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇
|
||||
# ⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃
|
||||
# https://steamcommunity.com/sharedfiles/filedetails/?id=2902061979
|
||||
if data == null: failures.append("Data is null")
|
||||
CoreTypes.ValidationType.IS_NORMALIZED:
|
||||
# If not a Vector2, Vector3, Vector4, Plane or Quaternion, skip
|
||||
match(typeof(data)):
|
||||
Variant.Type.TYPE_VECTOR2: pass
|
||||
Variant.Type.TYPE_VECTOR3: pass
|
||||
Variant.Type.TYPE_VECTOR4: pass
|
||||
Variant.Type.TYPE_PLANE: pass
|
||||
Variant.Type.TYPE_QUATERNION: pass
|
||||
_:
|
||||
logger.warn("Can't determine if data is normalized as data is not of type Vector2, Vector3, Vector4, Plane or Quaternion")
|
||||
continue
|
||||
|
||||
# Perform check
|
||||
if !data.is_normalized(): failures.append("Data is not normalized")
|
||||
CoreTypes.ValidationType.IS_ORTHONORMALIZED:
|
||||
# If not a Transform2D, Transform3D, or Basis, skip
|
||||
match(typeof(data)):
|
||||
Variant.Type.TYPE_TRANSFORM2D: pass
|
||||
Variant.Type.TYPE_TRANSFORM3D: pass
|
||||
Variant.Type.TYPE_BASIS: pass
|
||||
_:
|
||||
logger.warn("Can't determine if data is orthonormalized as data is not of type Transform2D, Transform3D or Basis")
|
||||
continue
|
||||
|
||||
# Perform check
|
||||
if !data.is_orthonormalized(): failures.append("Data is not orthonormalized")
|
||||
_: await logger.crash(core.stringify_variables("Invalid validation rule type %type%", { "type": rule["type"] }))
|
||||
|
||||
return failures.size() == 0
|
||||
|
||||
# +++ types and classes +++
|
||||
## Validates if [param data] matches some [enum Variant.Type].[br]
|
||||
## Applies to all data types (obviously).
|
||||
func matches_type(types: Array[Variant.Type]) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.MATCHES_TYPE, "types": types })
|
||||
return self
|
||||
|
||||
## Validates if [param data] matches some class.[br]
|
||||
## Applies to [Object].
|
||||
func matches_class(clazz: StringName, exact: bool) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.MATCHES_CLASS, "class": clazz, "exact": exact })
|
||||
return self
|
||||
|
||||
# +++ ranges +++
|
||||
## Validates if [param data] contains the specified integer range.[br]
|
||||
## Applies to [int].
|
||||
func in_range_int(from: int, to: int) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.IN_RANGE, "matched_against": "integer", "from": from, "to": to })
|
||||
return self
|
||||
|
||||
## Validates if [param data] contains the specified float range.[br]
|
||||
## Applies to [float].
|
||||
func in_range_float(from: float, to: float) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.IN_RANGE, "matched_against": "float", "from": from, "to": to })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is equal to or exceeds the specified integer.[br]
|
||||
## Applies to [int].
|
||||
func has_minimum_int(minimum: int) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.HAS_MINIMUM, "matched_against": "integer", "minimum": minimum })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is under the specified integer.[br]
|
||||
## Applies to [int].
|
||||
func has_maximum_int(maximum: int) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.HAS_MAXIMUM, "matched_against": "integer", "maximum": maximum })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is equal to or exceeds the specified float.[br]
|
||||
## Applies to [float].
|
||||
func has_minimum_float(minimum: float) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.HAS_MINIMUM, "matched_against": "float", "minimum": minimum })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is under the specified float.[br]
|
||||
## Applies to [float].
|
||||
func has_maximum_float(maximum: float) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.HAS_MAXIMUM, "matched_against": "float", "maximum": maximum })
|
||||
return self
|
||||
|
||||
# +++ values +++
|
||||
## Checks whenether at least one value matches [param data].[br]
|
||||
## Applies to all data types.
|
||||
func has_values(values: Array) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.HAS_VALUES, "values": values })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] contains at least <[code]minimum_matches[/code]> values.[br]
|
||||
## Applies to [String] & [StringName].
|
||||
func contains(values: Array[String], minimum_matches: int = 1) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.HAS_VALUES, "values": values, "minimum_matches": minimum_matches })
|
||||
return self
|
||||
|
||||
## Matches a regular expression against [param data].[br]
|
||||
## Applies to [String] & [StringName].
|
||||
func matches_regex(regex_string: String) -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.MATCHES_REGEX, "regex_string": regex_string })
|
||||
return self
|
||||
|
||||
# +++ empty/null and booleans +++
|
||||
## Ensures that [param data] is not empty.[br]
|
||||
## Applies to [String] & [StringName] ([code]!= ""[/code]), [int] ([code]!= 0[/code]) and [float] ([code]!= 0.0[/code]).
|
||||
func is_not_empty() -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.IS_NOT_EMPTY })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is not [code]null[/code].
|
||||
func is_not_null() -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.IS_NOT_NULL })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is normalized.[br]
|
||||
## Applies to [Vector2], [Vector3], [Vector4], [Plane] and [Quaternion].
|
||||
func is_normalized() -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.IS_NORMALIZED })
|
||||
return self
|
||||
|
||||
## Ensures that [param data] is orthonormalized.[br]
|
||||
## Applies to [Transform2D], [Transform3D] and [Basis].
|
||||
func is_orthonormalized() -> CoreValidationSingle:
|
||||
rules.append({ "type": CoreTypes.ValidationType.IS_ORTHONORMALIZED })
|
||||
return self
|
64
src/core.gd
64
src/core.gd
|
@ -29,10 +29,12 @@ const version_version: int = 1
|
|||
const version_type: CoreTypes.VersionType = CoreTypes.VersionType.RELEASE
|
||||
## The version type number. Resets on every new version and version type.
|
||||
const version_typerelease: int = 1
|
||||
## The fork indicator. Update this if you intend on soft or hard forking this framework.
|
||||
const version_fork: String = ""
|
||||
|
||||
# Modules
|
||||
## Used internally for loading, managing and unloading modules.
|
||||
const modules: Array[String] = [ "logger", "misc", "sms", "logui", "erm", "storage" ]
|
||||
const modules: Array[String] = [ "logger", "misc", "sms", "logui", "erm", "storage", "validation" ]
|
||||
## CORE's configuration object.[br]
|
||||
## [b]NEVER access this yourself! To change the configuration use [method reload_configuration] instead.[/b]
|
||||
var config: CoreConfiguration
|
||||
|
@ -52,6 +54,8 @@ var logui: CoreBaseModule
|
|||
var erm: CoreBaseModule
|
||||
## The 'Storage' module
|
||||
var storage: CoreBaseModule
|
||||
## The 'Data Validation' module
|
||||
var validation: CoreBaseModule
|
||||
|
||||
# /etc/
|
||||
## Stores the path to CORE's installation directory.[br]
|
||||
|
@ -89,6 +93,7 @@ var initduration_initialization: int = 0
|
|||
var initduration_complete_initialization: int = 0
|
||||
|
||||
# +++ initialization +++
|
||||
# Preinitialization
|
||||
func _init(new_config: CoreConfiguration = CoreConfiguration.new()) -> void:
|
||||
var inittime: int = Time.get_ticks_msec()
|
||||
name = "CORE"
|
||||
|
@ -101,6 +106,7 @@ func _init(new_config: CoreConfiguration = CoreConfiguration.new()) -> void:
|
|||
initialize_scheduler()
|
||||
initduration_preinitialization = Time.get_ticks_msec() - inittime
|
||||
|
||||
# Initialization
|
||||
func _ready() -> void:
|
||||
var inittime: int = Time.get_ticks_msec()
|
||||
inject_modules()
|
||||
|
@ -119,7 +125,7 @@ func initialize_modules() -> void:
|
|||
get(module).name = module
|
||||
get(module).set_script(load(basepath + "src/" + module + ".gd"))
|
||||
get(module).core = self
|
||||
get(module).loggeri = logger.get_instance(basepath.replace("res://", "") + "src/" + module + ".gd", get(module))
|
||||
get(module).logger = logger.get_instance(basepath.replace("res://", "") + "src/" + module + ".gd", get(module))
|
||||
get(module)._initialize()
|
||||
|
||||
# Injects CORE's builtin modules into the SceneTree.
|
||||
|
@ -148,8 +154,8 @@ func initialize_scheduler() -> void:
|
|||
## [i][b]Not calling this function during startup may lead to runtime issues.[/b][/i]
|
||||
func complete_init() -> void:
|
||||
var inittime: int = Time.get_ticks_msec()
|
||||
var modsinit_builtin: Array[String] = ["workaround"]
|
||||
var modsinit_custom: Array[String] = ["workaround"]
|
||||
var modsinit_builtin: Array[String] = [ "workaround" ]
|
||||
var modsinit_custom: Array[String] = [ "workaround" ]
|
||||
|
||||
while modsinit_builtin.size() != 0 and modsinit_custom.size() != 0:
|
||||
# Clear arrays
|
||||
|
@ -170,13 +176,16 @@ func complete_init() -> void:
|
|||
if modsinit_custom.size() != 0: print(" Custom: " + str(modsinit_custom))
|
||||
await get_tree().create_timer(1).timeout
|
||||
|
||||
initduration_complete_initialization = Time.get_ticks_msec() - inittime
|
||||
|
||||
# Initialization complete
|
||||
await get_tree().process_frame
|
||||
if !welcomed:
|
||||
welcomed = true
|
||||
logger._log(CoreTypes.LoggerLevel.SPECIAL, basepath.replace("res://", "") + "src/core.gd", """_________________________________ __________ ______
|
||||
var version_welcome: String = await get_formatted_string("v%version%-%version_type_technical%%version_typerelease%%version_fork%")
|
||||
if version_welcome.length() > 15:
|
||||
await logger.crash("Invalid version size of 15")
|
||||
elif version_welcome.length() < 15:
|
||||
version_welcome = " ".repeat(15-version_welcome.length()) + version_welcome
|
||||
logger._log(CoreTypes.LoggerLevel.SPECIAL, basepath.replace("res://", "") + "src/core.gd", """_________________________________ __________ %version% ______
|
||||
__ ____/_ __ \\__ __ \\__ ____/ ___ ____/____________ _______ ___________ _________________ /__
|
||||
_ / _ / / /_ /_/ /_ __/ __ /_ __ ___/ __ `/_ __ `__ \\ _ \\_ | /| / / __ \\_ ___/_ //_/
|
||||
/ /___ / /_/ /_ _, _/_ /___ _ __/ _ / / /_/ /_ / / / / / __/_ |/ |/ // /_/ / / _ ,<
|
||||
|
@ -187,10 +196,12 @@ You should have recieved a copy of the GNU Affero General Public License
|
|||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Consider contributing to the CORE Framework to make it even better... and remember: #TransRightsAreHumanRights
|
||||
Thank you for using the CORE Framework to develop your application or game!""")
|
||||
loggeri.info("Framework initialization took " + str(initduration_preinitialization + initduration_initialization + initduration_complete_initialization) + "ms (pre " + str(initduration_preinitialization) + "ms, init " + str(initduration_initialization) + "ms, complete " + str(initduration_complete_initialization) + "ms)")
|
||||
Thank you for using the CORE Framework to develop your application or game!""".replace("%version%", version_welcome))
|
||||
if is_devmode(): loggeri.warn("The CORE Framework is running in development mode.\nThis may cause bugs and issues inside the framework. Here be dragons!")
|
||||
if logger.verbose_mode: loggeri.warn("Godot is running in verbose stdout mode.\nDue to a bug in the engine that prevents displaying truecolor ANSI escape\nsequences CORE changed the color of all diagnostic log messages.\nTo prevent this, set 'logger_detect_verbose_mode' in the configuration to 'false'.")
|
||||
|
||||
initduration_complete_initialization = Time.get_ticks_msec() - inittime
|
||||
loggeri.info("Framework initialization took " + str(initduration_preinitialization + initduration_initialization + initduration_complete_initialization) + "ms (pre " + str(initduration_preinitialization) + "ms, init " + str(initduration_initialization) + "ms, complete " + str(initduration_complete_initialization) + "ms)")
|
||||
|
||||
# +++ configuration +++
|
||||
## Loads a (new) configuration object and applies it to all modules.
|
||||
|
@ -228,13 +239,13 @@ func register_custom_module(module_name: String, module_origin: String, module_c
|
|||
loggeri.error("Registering module failed: Custom module support is disabled.")
|
||||
return false
|
||||
if custom_modules.has(module_name):
|
||||
loggeri.error("Registering module failed: A custom module with the name \"" + module_name + "\" already exists.")
|
||||
loggeri.error(stringify_variables("Registering module failed: A custom module with the name %name% already exists.", { "name": module_name }))
|
||||
return false
|
||||
loggeri.diag("Updating variables")
|
||||
module_class.name = module_name
|
||||
module_class.core = self
|
||||
module_class.loggeri = logger.get_instance(module_origin, module_class)
|
||||
module_class.loggeri.framework = true
|
||||
module_class.logger = logger.get_instance(module_origin, module_class)
|
||||
module_class.logger.framework = true
|
||||
loggeri.diag("Adding module to SceneTree")
|
||||
custom_modules_node.add_child(module_class)
|
||||
loggeri.diag("Merging module with custom_modules")
|
||||
|
@ -249,11 +260,11 @@ func register_custom_module(module_name: String, module_origin: String, module_c
|
|||
func unregister_custom_module(module_name: String) -> void:
|
||||
loggeri.verb("Unregistering custom module \"" + module_name + "\"")
|
||||
if !custom_modules.has(module_name):
|
||||
loggeri.error("Unregistering module failed: A custom module with the name \"" + module_name + "\" does not exist.")
|
||||
loggeri.error(stringify_variables("Unregistering module failed: A custom module with the name %name% does not exist.", { "name": module_name }))
|
||||
return
|
||||
var module: Node = custom_modules[module_name]
|
||||
await module._cleanup()
|
||||
module.loggeri.queue_free()
|
||||
module.logger.queue_free()
|
||||
custom_modules_node.remove_child(module)
|
||||
custom_modules.erase(module_name)
|
||||
module.queue_free()
|
||||
|
@ -263,7 +274,7 @@ func unregister_custom_module(module_name: String) -> void:
|
|||
func get_custom_module(module_name: String) -> CoreBaseModule:
|
||||
loggeri.diag("Getting custom module \"" + module_name + "\"")
|
||||
if !custom_modules.has(module_name):
|
||||
loggeri.error("Getting module failed: A custom module with the name \"" + module_name + "\" does not exist.")
|
||||
loggeri.error(stringify_variables("Getting module failed: A custom module with the name %name% does not exist.", { "name": module_name }))
|
||||
return null
|
||||
return custom_modules[module_name]
|
||||
|
||||
|
@ -276,7 +287,7 @@ func cleanup() -> void:
|
|||
loggeri.verb("Calling cleanup hooks")
|
||||
for hook in cleanup_hooks:
|
||||
if !cleanup_hooks[hook].is_valid():
|
||||
loggeri.error("Cleanup hook #" + str(hook) + " is invalid")
|
||||
loggeri.error(stringify_variables("Cleanup hook %id% is invalid", { "id": hook }))
|
||||
else:
|
||||
loggeri.diag("Calling cleanup hook #" + str(hook))
|
||||
await cleanup_hooks[hook].call()
|
||||
|
@ -302,10 +313,10 @@ func cleanup() -> void:
|
|||
func _generate_hook_id() -> int:
|
||||
var id = randi()
|
||||
if cleanup_hooks.has(id):
|
||||
loggeri.warn("New cleanup hook id #" + str(id) + " is already taken")
|
||||
loggeri.warn(stringify_variables("New cleanup hook id %id% is already taken", { "id": id }))
|
||||
return _generate_hook_id()
|
||||
elif id == -1:
|
||||
loggeri.warn("Invalid cleanup hook id '-1'")
|
||||
loggeri.warn(stringify_variables("Invalid cleanup hook id %id%", { "id": id }))
|
||||
return _generate_hook_id()
|
||||
return id
|
||||
|
||||
|
@ -324,9 +335,9 @@ func register_cleanup_hook(callable: Callable) -> int:
|
|||
## Unregisters a cleanup hook by it's id.
|
||||
func unregister_cleanup_hook_by_id(id: int) -> bool:
|
||||
if !cleanup_hooks.has(id):
|
||||
loggeri.error("Could not remove cleanup hook (id): Hook #" + str(id) + " does not exist")
|
||||
loggeri.error(stringify_variables("Could not remove cleanup hook (id): Hook %id% does not exist", { "id": id }))
|
||||
return false
|
||||
loggeri.verb("Removed cleanup hook #" + str(id) + " (id)")
|
||||
loggeri.verb(stringify_variables("Removed cleanup hook %id%", { "id": id }))
|
||||
cleanup_hooks.erase(id)
|
||||
return true
|
||||
|
||||
|
@ -336,9 +347,9 @@ func unregister_cleanup_hook_by_ref(callable: Callable) -> bool:
|
|||
if id == null:
|
||||
loggeri.error("Could not remove cleanup hook (ref): Could not find a matching hook")
|
||||
return false
|
||||
if typeof(id) != TYPE_INT: await loggeri.crash("Could not remove cleanup hook (ref): find_key did not return an integer (returned '" + str(id) + "')")
|
||||
loggeri.verb("Removed cleanup hook #" + str(id) + " (ref)")
|
||||
if typeof(id) != TYPE_INT: await loggeri.crash(stringify_variables("Could not remove cleanup hook (ref): find_key did not return an integer (returned %id%)", { "id": id }))
|
||||
cleanup_hooks.erase(id)
|
||||
loggeri.verb(stringify_variables("Removed cleanup hook %id% (ref)", { "id": id }))
|
||||
return true
|
||||
|
||||
# +++ etc +++
|
||||
|
@ -360,6 +371,7 @@ func get_formatted_string(string: String) -> String:
|
|||
# Version strings
|
||||
string = string.replace("%version%", str(version_version))
|
||||
string = string.replace("%version_typerelease%", str(version_typerelease))
|
||||
string = string.replace("%version_fork%", "" if version_fork == "" else "-" + version_fork)
|
||||
var semantic_version: Array[int] = get_version_semantic()
|
||||
string = string.replace("%version_semantic%", str(semantic_version[0]) + "." + str(semantic_version[1]) + "." + str(semantic_version[2]))
|
||||
match(version_type):
|
||||
|
@ -455,10 +467,16 @@ func quit_safely(exitcode: int = 0) -> void:
|
|||
await cleanup()
|
||||
get_tree().quit(exitcode)
|
||||
|
||||
# Just ignore this.
|
||||
## Internal, don't call
|
||||
# Makes misc.stringify_variables() calls shorter
|
||||
func stringify_variables(template: String, arguments: Dictionary, no_quotes: bool = false) -> String:
|
||||
return misc.stringify_variables(template, arguments, no_quotes, true)
|
||||
|
||||
## Internal, don't call.
|
||||
# Just ignore this.
|
||||
func _notification(what) -> void:
|
||||
match(what):
|
||||
NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
if config.automatic_shutdown:
|
||||
loggeri.diag("Got close request, shutting down")
|
||||
await quit_safely(0)
|
||||
|
|
34
src/erm.gd
34
src/erm.gd
|
@ -54,11 +54,12 @@ func _cleanup() -> void:
|
|||
## [/codeblock]
|
||||
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b]
|
||||
func awaited_request(url: String, parse_utf8: bool, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), data: String = "") -> Dictionary:
|
||||
loggeri.verb("Creating awaited request")
|
||||
logger.verb("Creating awaited request")
|
||||
if !await is_url_allowed(url): return {}
|
||||
var id: int = create_request(url, method, headers, data)
|
||||
start_request(id, parse_utf8)
|
||||
loggeri.diag("Waiting for request " + str(id) + " to finish")
|
||||
logger.diag("Waiting for request " + str(id) + " to finish")
|
||||
logger.diag(core.stringify_variables("Waiting for request %id% to finish", { "id": id }))
|
||||
while !is_request_completed(id): await get_tree().create_timer(0.1, true).timeout
|
||||
var dldata: Dictionary = list_complete[id]
|
||||
list_complete.erase(id)
|
||||
|
@ -93,7 +94,7 @@ func oneline_awaited_request(url: String, return_utf8: bool = true, ignore_http_
|
|||
## [/codeblock]
|
||||
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b]
|
||||
func batch_awaited_request(urls: PackedStringArray, parse_utf8: bool, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), data: String = "") -> Array[Dictionary]:
|
||||
loggeri.verb("Creating " + str(urls.size()) + " awaited request(s)")
|
||||
logger.verb("Creating " + str(urls.size()) + " awaited request(s)")
|
||||
var dldata: Array[Dictionary] = []
|
||||
for url in urls:
|
||||
if !await is_url_allowed(url): continue
|
||||
|
@ -108,7 +109,7 @@ func batch_awaited_request(urls: PackedStringArray, parse_utf8: bool, method: HT
|
|||
func _batch_awaited_request(url: String, parse_utf8: bool, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), data: String = "") -> int:
|
||||
var id: int = create_request(url, method, headers, data)
|
||||
start_request(id, parse_utf8)
|
||||
loggeri.diag("Waiting for request " + str(id) + " to finish")
|
||||
logger.diag(core.stringify_variables("Waiting for request %id% to finish", { "id": id }))
|
||||
while !is_request_completed(id): await get_tree().create_timer(0.1, true).timeout
|
||||
return id
|
||||
|
||||
|
@ -117,15 +118,15 @@ func _batch_awaited_request(url: String, parse_utf8: bool, method: HTTPClient.Me
|
|||
func generate_id() -> int:
|
||||
var id = randi()
|
||||
if list_queue.has(id) or list_active.has(id):
|
||||
loggeri.warn("New download id '" + str(id) + "' is already taken")
|
||||
logger.warn(core.stringify_variables("New download id %id% already taken", { "id": id }))
|
||||
return generate_id()
|
||||
loggeri.diag("Generated new download id " + str(id))
|
||||
logger.diag(core.stringify_variables("Generated new download id %id%", { "id": id }))
|
||||
return id
|
||||
|
||||
## Creates a new request and stores it in the queue. Returns the download id.[br]
|
||||
## [b]Warning: [i]You'll probably not need this. Only use this function when implementing your own downloading method.[/i][/b]
|
||||
func create_request(url: String, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), body: String = "") -> int:
|
||||
loggeri.verb("Creating new request\n-> URL: " + url + "\n-> Method: " + str(method) + "\nHeaders: " + str(headers.size()) + "\nBody size: " + str(body.length()) + " Characters")
|
||||
logger.verb("Creating new request\n-> URL: " + url + "\n-> Method: " + str(method) + "\nHeaders: " + str(headers.size()) + "\nBody size: " + str(body.length()) + " Characters")
|
||||
var id = generate_id()
|
||||
list_queue.merge({ id: { "url": url, "method": method, "headers": headers, "body": body } })
|
||||
return id
|
||||
|
@ -134,16 +135,17 @@ func create_request(url: String, method: HTTPClient.Method = HTTPClient.Method.M
|
|||
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b][br]
|
||||
## [b]Warning: [i]You'll probably not need this. Only use this function when implementing your own downloading method.[/i][/b]
|
||||
func start_request(id: int, parse_utf8: bool) -> void:
|
||||
loggeri.verb("Starting request " + str(id))
|
||||
logger.verb("Starting request " + str(id))
|
||||
logger.verb(core.stringify_variables("Starting request %id%", { "id": id }))
|
||||
list_active.merge({ id: list_queue[id] })
|
||||
list_queue.erase(id)
|
||||
loggeri.diag("Creating new HTTPRequest \"Request #" + str(id) + "\"")
|
||||
logger.diag("Creating new HTTPRequest \"Request #" + str(id) + "\"")
|
||||
var download: HTTPRequest = HTTPRequest.new()
|
||||
download.name = "Request #" + str(id)
|
||||
download.accept_gzip = true
|
||||
download.use_threads = true
|
||||
var lambda: Callable = func(result: int, http_code: int, headers: PackedStringArray, body: PackedByteArray, httprequest: HTTPRequest) -> void:
|
||||
loggeri.verb("Request " + str(id) + " completed\nResult: " + str(result) + "\nHTTP response code: " + str(http_code) + "\nHeaders: " + str(headers.size()) + "\nBody size: " + str(body.size()) + " Bytes // " + str(core.misc.byte2mib(body.size(), true)) + " MiB\nParse body as UTF-8: " + str(parse_utf8))
|
||||
logger.verb(core.stringify_variables("Request %id% completed\nResult: %result%\nHTTP response code: %http_code%\nHeaders: %headers%\nBody size: %body_size_bytes% Bytes // %body_size_mib% MiB\nParsed body as UTF-8: %parse_utf8%", { "result": result, "http_code": http_code, "headers": headers.size(), "body_size_bytes": body.size(), "body_size_mib": core.misc.byte2mib(body.size(), true), "parse_utf8": parse_utf8 }, true))
|
||||
list_complete.merge({ id: { "result": result, "http_code": http_code, "headers": headers, "body": body, "body_utf8": body.get_string_from_utf8() if parse_utf8 else "" } })
|
||||
list_active.erase(id)
|
||||
remove_child(httprequest)
|
||||
|
@ -157,14 +159,14 @@ func is_url_allowed(url: String) -> bool:
|
|||
if url.begins_with("http://"):
|
||||
match(config_unsecure_requests):
|
||||
CoreTypes.BlockadeLevel.BLOCK:
|
||||
loggeri.error("Blocked unsecure url '" + url + "'")
|
||||
logger.error(core.stringify_variables("Blocked unsecure url %url%", { "url": url }))
|
||||
return false
|
||||
CoreTypes.BlockadeLevel.WARN: loggeri.warn("Requesting unsecure url '" + url + "'")
|
||||
CoreTypes.BlockadeLevel.WARN: logger.warn(core.stringify_variables("Requesting unsecure url %url%", { "url": url }))
|
||||
CoreTypes.BlockadeLevel.IGNORE: pass
|
||||
_: await loggeri.crash("Invalid BlockadeLevel '" + str(config_unsecure_requests) + "'")
|
||||
_: await logger.crash(core.stringify_variables("Invalid BlockadeLevel %level%", { "level": config_unsecure_requests }))
|
||||
elif url.begins_with("https://"): pass
|
||||
else:
|
||||
loggeri.error("Invalid url '" + url + "'")
|
||||
logger.error(core.stringify_variables("Invalid url %url%", { "url": url }))
|
||||
return false
|
||||
return true
|
||||
|
||||
|
@ -173,10 +175,10 @@ func is_request_completed(id: int) -> bool: return list_complete.has(id)
|
|||
|
||||
## Cleans the request queue.
|
||||
func clean_queue() -> void:
|
||||
loggeri.verb("Cleaning request queue")
|
||||
logger.verb("Cleaning request queue")
|
||||
list_queue.clear()
|
||||
|
||||
## Cleans the completed requests list.
|
||||
func clean_completed() -> void:
|
||||
loggeri.verb("Cleaning completed requests")
|
||||
logger.verb("Cleaning completed requests")
|
||||
list_complete.clear()
|
||||
|
|
|
@ -46,17 +46,17 @@ var config_newlines_sizelimit: int
|
|||
func _cleanup() -> void:
|
||||
for instance in instances:
|
||||
if is_instance_valid(instance):
|
||||
loggeri.diag("Removing instance '" + instance.name + "'")
|
||||
logger.diag("Removing instance '" + instance.name + "'")
|
||||
instance.queue_free()
|
||||
|
||||
func _schedule() -> void:
|
||||
var instances_remove_enty: Array[CoreLoggerInstance] = []
|
||||
for instance in instances:
|
||||
instances_remove_enty.append(instance)
|
||||
if !is_instance_valid(instance): continue
|
||||
if !is_instance_valid(instance.parent):
|
||||
loggeri.diag("Removing instance '" + instance.name + "'")
|
||||
logger.diag("Removing instance '" + instance.name + "'")
|
||||
instance.queue_free()
|
||||
instances_remove_enty.append(instance)
|
||||
for instance in instances_remove_enty:
|
||||
instances.remove_at(instances.find(instance))
|
||||
|
||||
|
|
|
@ -90,7 +90,7 @@ func _ready() -> void:
|
|||
vsbar.add_theme_stylebox_override("grabber_pressed", StyleBoxEmpty.new())
|
||||
|
||||
# Connect log_event
|
||||
logger.connect("log_event", func(allowed: bool, _level: CoreTypes.LoggerLevel, _origin: String, _message: String, format: String) -> void: if allowed: logrtl.text = logrtl.text + format + "\n")
|
||||
core.logger.connect("log_event", func(allowed: bool, _level: CoreTypes.LoggerLevel, _origin: String, _message: String, format: String) -> void: if allowed: logrtl.text = logrtl.text + format + "\n")
|
||||
|
||||
# +++ process +++
|
||||
func _process(_delta: float) -> void:
|
||||
|
|
28
src/misc.gd
28
src/misc.gd
|
@ -40,29 +40,29 @@ func _pull_config() -> void:
|
|||
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
|
||||
@warning_ignore("integer_division")
|
||||
func byte2mib(bytes: int, flatten: bool = true) -> float:
|
||||
if flatten: return bytes/1048576
|
||||
return bytes/float(1048576)
|
||||
if flatten: return float(int(float(bytes)/1048576.0))
|
||||
return float(bytes)/1048576.0
|
||||
|
||||
## Converts a number of mebibytes into bytes.[br]
|
||||
## [br]
|
||||
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
|
||||
func mib2byte(mib: float, flatten: bool = true) -> float:
|
||||
if flatten: return int(mib*1048576)
|
||||
return mib*1048576
|
||||
if flatten: return float(int(mib*1048576.0))
|
||||
return mib*1048576.0
|
||||
|
||||
## Converts a number of mebibytes into gibibytes.[br]
|
||||
## [br]
|
||||
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
|
||||
func mib2gib(mib: float, flatten: bool = true) -> float:
|
||||
if flatten: return int(mib/1024)
|
||||
return mib/1024
|
||||
if flatten: return float(int(mib/1024.0))
|
||||
return mib/1024.0
|
||||
|
||||
## Converts a number of gebibytes into mebibytes.[br]
|
||||
## [br]
|
||||
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
|
||||
func gib2mib(gib: float, flatten: bool = true) -> float:
|
||||
if flatten: return int(gib*1024)
|
||||
return gib*1024
|
||||
if flatten: return float(int(gib*1024.0))
|
||||
return gib*1024.0
|
||||
|
||||
# +++ type formatting +++
|
||||
## Converts a string array into a normal, nicely formatted string.[br]
|
||||
|
@ -85,10 +85,10 @@ func format_stringarray(array: Array[String], item_before: String = "", item_aft
|
|||
var output: String = ""
|
||||
|
||||
if array.size() == 0:
|
||||
loggeri.warn("Unable to format a string with a size of 0")
|
||||
logger.warn("Unable to format a string with a size of 0")
|
||||
return ""
|
||||
elif array.size() == 1:
|
||||
loggeri.warn("Unable to format a string with a size of 1")
|
||||
logger.warn("Unable to format a string with a size of 1")
|
||||
return array[0]
|
||||
|
||||
for item in array:
|
||||
|
@ -107,7 +107,7 @@ func array_to_stringarray(array: Array) -> Array[String]:
|
|||
|
||||
for item in array:
|
||||
if typeof(item) != TYPE_STRING:
|
||||
logger.error("Cannot convert Array to Array[String]: Item '" + str(item) + "' is not of type String")
|
||||
logger.error(core.stringify_string("Cannot convert Array to Array[String]: Item %item% is not of type String", { "item": item }))
|
||||
return []
|
||||
output.append(item)
|
||||
|
||||
|
@ -151,7 +151,7 @@ func get_center(parent_size: Vector2, child_size: Vector2) -> Vector2:
|
|||
## Output:
|
||||
## [16:44:35] [DIAG Test.gd] Triggered '"shoot"' (pos='x=5156.149 y=581.69', successful='true')
|
||||
## [/codeblock]
|
||||
func stringify_variables(template: String, variables: Dictionary, no_quotes: bool = false) -> String:
|
||||
func stringify_variables(template: String, variables: Dictionary, no_quotes: bool = false, force_no_type: bool = false) -> String:
|
||||
# To decrease allocations
|
||||
var value
|
||||
var type: String = ""
|
||||
|
@ -286,7 +286,7 @@ func stringify_variables(template: String, variables: Dictionary, no_quotes: boo
|
|||
type = "unknown"
|
||||
|
||||
# Replace
|
||||
if config_stringify_show_type:
|
||||
if config_stringify_show_type and !force_no_type:
|
||||
if type != "": type = "(" + type.to_lower() + ") "
|
||||
else:
|
||||
type = ""
|
||||
|
@ -294,7 +294,7 @@ func stringify_variables(template: String, variables: Dictionary, no_quotes: boo
|
|||
template = template.replace("%" + placeholder + "%", quote + type + replacement + quote)
|
||||
return template
|
||||
|
||||
# Makes calls shorter
|
||||
# Makes internal calls shorter
|
||||
func _sa(value) -> String:
|
||||
return stringify_variables("%var%", { "var": value }, true)
|
||||
|
||||
|
|
28
src/sms.gd
28
src/sms.gd
|
@ -49,19 +49,19 @@ func _cleanup() -> void:
|
|||
func _pull_config() -> void:
|
||||
if core.config.headless:
|
||||
# Remove all scenes
|
||||
if is_inside_tree(): loggeri.verb("Removing all scenes (triggered by headless mode)")
|
||||
if is_inside_tree(): logger.verb("Removing all scenes (triggered by headless mode)")
|
||||
for scene in scenes: remove_scene(scene, true)
|
||||
|
||||
# +++ scene management +++
|
||||
## Adds a scene to some scene collection.
|
||||
func add_scene(scene_name: String, scene_class: Node, scene_type: CoreTypes.SceneType) -> bool:
|
||||
if core.config.headless: return false
|
||||
loggeri.verb("Adding scene \"" + scene_name + "\" of type " + str(scene_type))
|
||||
logger.verb(core.stringify_variables("Adding scene %name% of type %type%", { "name": scene_name, "type": scene_type }))
|
||||
if exists(scene_name) != CoreTypes.SceneType.NONE:
|
||||
loggeri.error("Scene with name \"" + scene_name + "\" already exists")
|
||||
logger.error(core.stringify_variables("A scene named %name% already exists", { "name": scene_name }))
|
||||
return false
|
||||
if typeof(scene_class) != TYPE_OBJECT or !scene_class.is_class("Node"):
|
||||
loggeri.error("Scene class \"" + scene_name + "\" is not of type Node")
|
||||
logger.error(core.stringify_variables("Scene class %name% is not of type Node", { "name": scene_name }))
|
||||
return false
|
||||
scene_class.name = scene_name
|
||||
match(scene_type):
|
||||
|
@ -71,9 +71,9 @@ func add_scene(scene_name: String, scene_class: Node, scene_type: CoreTypes.Scen
|
|||
CoreTypes.SceneType.MAIN: scenes_main.add_child(scene_class)
|
||||
CoreTypes.SceneType.BACKGROUND: scenes_background.add_child(scene_class)
|
||||
CoreTypes.SceneType.NONE:
|
||||
loggeri.error("CoreTypes.SceneType.NONE is not a valid scene type")
|
||||
logger.error("CoreTypes.SceneType.NONE is not a valid scene type")
|
||||
return false
|
||||
_: await loggeri.crash("Invalid SceneType " + str(scene_type))
|
||||
_: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": scene_type }))
|
||||
scenes.merge({ scene_name: { "type": scene_type, "class": scene_class } })
|
||||
return true
|
||||
|
||||
|
@ -81,8 +81,8 @@ func add_scene(scene_name: String, scene_class: Node, scene_type: CoreTypes.Scen
|
|||
## [b]Danger: [i]Don't set [code]force_remove[/code] to [code]true[/code], thanks![/i][/b]
|
||||
func remove_scene(scene_name: String, force_remove: bool = false) -> bool:
|
||||
if core.config.headless and !force_remove: return false
|
||||
if force_remove: await loggeri.crash("force_remove = true is not allowed")
|
||||
loggeri.verb("Removing scene \"" + scene_name + "\"")
|
||||
if force_remove: await logger.crash("force_remove is not allowed to be true")
|
||||
logger.verb(core.stringify_variables("Removing scene %name%", { "name": scene_name }))
|
||||
match(exists(scene_name)):
|
||||
CoreTypes.SceneType.DEBUG:
|
||||
scenes_debug.remove_child(scenes[scene_name]["class"])
|
||||
|
@ -100,9 +100,9 @@ func remove_scene(scene_name: String, force_remove: bool = false) -> bool:
|
|||
scenes_background.remove_child(scenes[scene_name]["class"])
|
||||
scenes[scene_name]["class"].queue_free()
|
||||
CoreTypes.SceneType.NONE:
|
||||
loggeri.error("Scene \"" + scene_name + "\" does not exist")
|
||||
logger.error(core.stringify_variables("Scene %name% does not exist", { "name": scene_name }))
|
||||
return false
|
||||
_: await loggeri.crash("Invalid SceneType " + str(exists(scene_name)))
|
||||
_: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": exists(scene_name) }))
|
||||
scenes.erase(scene_name)
|
||||
return true
|
||||
|
||||
|
@ -118,8 +118,8 @@ func get_scene(scene_name: String) -> Node:
|
|||
CoreTypes.SceneType.MENU: return scenes[scene_name]["class"]
|
||||
CoreTypes.SceneType.MAIN: return scenes[scene_name]["class"]
|
||||
CoreTypes.SceneType.BACKGROUND: return scenes[scene_name]["class"]
|
||||
CoreTypes.SceneType.NONE: loggeri.error("Scene \"" + scene_name + "\" does not exist")
|
||||
_: await loggeri.crash("Invalid SceneType " + str(exists(scene_name)))
|
||||
CoreTypes.SceneType.NONE: logger.error(core.stringify_variables("Scene %name% does not exist", { "name": scene_name }))
|
||||
_: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": exists(scene_name) }))
|
||||
return null
|
||||
|
||||
## Returns a scene collection node.[br]
|
||||
|
@ -133,8 +133,8 @@ func get_scene_collection(scene_type: CoreTypes.SceneType) -> Node:
|
|||
CoreTypes.SceneType.MENU: return scenes_menu
|
||||
CoreTypes.SceneType.MAIN: return scenes_main
|
||||
CoreTypes.SceneType.BACKGROUND: return scenes_background
|
||||
CoreTypes.SceneType.NONE: loggeri.error("No scene collection was found for CoreTypes.SceneType.NONE")
|
||||
_: await loggeri.crash("Invalid SceneType " + str(scene_type))
|
||||
CoreTypes.SceneType.NONE: logger.error("No scene collection was found for CoreTypes.SceneType.NONE")
|
||||
_: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": scene_type }))
|
||||
return null
|
||||
|
||||
## Returns a list of all loaded scenes in some scene collection.
|
||||
|
|
|
@ -32,40 +32,40 @@ var storage_location: String = ""
|
|||
## Opens a storage file and loads it into memory.
|
||||
func open_storage(location: String, create_new: bool = true, sanity_check: bool = true, fail_on_sanity_check: bool = false) -> bool:
|
||||
if is_open:
|
||||
loggeri.error("Failed to open storage: A storage file is already open")
|
||||
logger.error("Failed to open storage: A storage file is already open")
|
||||
return false
|
||||
loggeri.verb("Opening storage file at \"" + location + "\"")
|
||||
logger.verb(core.stringify_variables("Opening storage file at %location%", { "location": location }))
|
||||
var file: FileAccess
|
||||
if !FileAccess.file_exists(location):
|
||||
if create_new:
|
||||
file = FileAccess.open(location, FileAccess.WRITE)
|
||||
if file == null:
|
||||
await loggeri.crash("Could not open storage file at \"" + location + "\": Failed with code " + str(FileAccess.get_open_error()))
|
||||
await logger.crash(core.stringify_variables("Could not open storage file at %location%: Failed with error %error%", { "location": location, "error": error_string(FileAccess.get_open_error()) }))
|
||||
return false
|
||||
file.store_string("{}")
|
||||
file.close()
|
||||
else:
|
||||
loggeri.error("Failed to open storage: create_new is set to false")
|
||||
logger.error("Failed to open storage: create_new is set to false")
|
||||
return false
|
||||
file = FileAccess.open(location, FileAccess.READ)
|
||||
var storage_temp: Variant = file.get_as_text()
|
||||
file.close()
|
||||
storage_temp = JSON.parse_string(storage_temp)
|
||||
if typeof(storage_temp) != TYPE_DICTIONARY:
|
||||
loggeri.error("Failed to open storage: Parsed storage file is of type " + str(typeof(storage_temp)))
|
||||
logger.error(core.stringify_variables("Failed to open storage: Parsed storage file is of type %type%", { "type": type_string(typeof(storage_temp)) }))
|
||||
return false
|
||||
if sanity_check:
|
||||
var check_result: Array[String] = perform_sanity_check(storage_temp)
|
||||
if check_result.size() != 0:
|
||||
if fail_on_sanity_check:
|
||||
loggeri.error("Sanity check failed (stopping):")
|
||||
logger.error("Sanity check failed (stopping):")
|
||||
for error in check_result:
|
||||
loggeri.error("-> " + error)
|
||||
logger.error("-> " + error)
|
||||
return false
|
||||
else:
|
||||
loggeri.warn("Sanity check failed (continuing anyway):")
|
||||
logger.warn("Sanity check failed (continuing anyway):")
|
||||
for error in check_result:
|
||||
loggeri.warn("-> " + error)
|
||||
logger.warn("-> " + error)
|
||||
storage = storage_temp
|
||||
storage_location = location
|
||||
is_open = true
|
||||
|
@ -74,9 +74,9 @@ func open_storage(location: String, create_new: bool = true, sanity_check: bool
|
|||
## Closes the active storage file.
|
||||
func close_storage() -> bool:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to close storage: No storage file is open")
|
||||
logger.error("Failed to close storage: No storage file is open")
|
||||
return false
|
||||
loggeri.verb("Closing storage file")
|
||||
logger.verb("Closing storage file")
|
||||
storage = {}
|
||||
is_open = false
|
||||
return true
|
||||
|
@ -84,13 +84,13 @@ func close_storage() -> bool:
|
|||
## Saves the active storage file to disk.
|
||||
func save_storage() -> bool:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to save storage: No storage file is open")
|
||||
logger.error("Failed to save storage: No storage file is open")
|
||||
return false
|
||||
var file: FileAccess = FileAccess.open(storage_location, FileAccess.WRITE)
|
||||
if file == null:
|
||||
await loggeri.crash("Could not open storage file at \"" + storage_location + "\": Failed with code " + str(FileAccess.get_open_error()))
|
||||
await logger.crash(core.stringify_variables("Could not open storage file at %location%: Failed with error %error%", { "location": storage_location, "error": error_string(FileAccess.get_open_error()) }))
|
||||
return false
|
||||
loggeri.diag("Writing storage file to disk")
|
||||
logger.diag("Writing storage file to disk")
|
||||
file.store_string(JSON.stringify(storage))
|
||||
file.close()
|
||||
return true
|
||||
|
@ -99,9 +99,9 @@ func save_storage() -> bool:
|
|||
## Removes all keys from the active storage file. The nuclear option basically.
|
||||
func nuke_storage(autosave: bool = true) -> bool:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to nuke storage: No storage file is open")
|
||||
logger.error("Failed to nuke storage: No storage file is open")
|
||||
return false
|
||||
loggeri.warn("Nuking storage")
|
||||
logger.warn("Nuking storage")
|
||||
storage = {}
|
||||
if autosave: save_storage()
|
||||
return true
|
||||
|
@ -109,17 +109,17 @@ func nuke_storage(autosave: bool = true) -> bool:
|
|||
## Returns a storage key. Can also return a default value if unset.
|
||||
func get_key(key: String, default: Variant = null) -> Variant:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to get key: No storage file is open")
|
||||
logger.error("Failed to get key: No storage file is open")
|
||||
return NAN
|
||||
loggeri.diag("Returning storage key \"" + key + "\" (default='" + str(default) + "')")
|
||||
logger.diag(core.stringify_variables("Returning storage key %key% (default=%default%)", { "key": key, "default": default }))
|
||||
return storage.get(key, default)
|
||||
|
||||
## Updates a storage key with the specified value.
|
||||
func set_key(key: String, value: Variant, overwrite: bool = true, autosave: bool = true) -> bool:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to set key: No storage file is open")
|
||||
logger.error("Failed to set key: No storage file is open")
|
||||
return false
|
||||
loggeri.diag("Updating storage key \"" + key + "\" with value '" + str(value) + "' (overwrite='" + str(overwrite) + "' autosave='" + str(autosave) + "'")
|
||||
logger.diag(core.stringify_variables("Updating storage key %key% with value %value% (overwrite=%overwrite% autosave=%autosave%)", { "key": key, "value": value, "overwrite": overwrite, "autosave": autosave }))
|
||||
storage.merge({key: value}, overwrite)
|
||||
if autosave: save_storage()
|
||||
return true
|
||||
|
@ -129,7 +129,7 @@ func del_key(key: String, autosave: bool = true) -> bool:
|
|||
if !is_open:
|
||||
logger.errof("storage", "Failed to delete key: No storage file is open")
|
||||
return false
|
||||
loggeri.diag("Deleting storage key \"" + key + "\" (autosave='" + str(autosave) + "')")
|
||||
logger.diag(core.stringify_variables("Deleting storage key %key% (autosave=%autosave%)", { "key": key, "autosave": autosave }))
|
||||
storage.erase(key)
|
||||
if autosave: save_storage()
|
||||
return true
|
||||
|
@ -139,30 +139,30 @@ func del_key(key: String, autosave: bool = true) -> bool:
|
|||
## pass your modified [class Dictionary to [method safe_dict].
|
||||
func get_dict() -> Dictionary:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to get dictionary: No storage file is open")
|
||||
logger.error("Failed to get dictionary: No storage file is open")
|
||||
return {}
|
||||
loggeri.verb("Returning storage dictionary")
|
||||
logger.verb("Returning storage dictionary")
|
||||
return storage
|
||||
|
||||
# +++ raw manipulation +++
|
||||
## Saves a arbitrary dictionary as a [param storage] [class Dictionary] with sanity checking ([code]sanity_check[/code] and [code]fail_on_sanity_check[/code]).
|
||||
func save_dict(dict: Dictionary, sanity_check: bool = true, fail_on_sanity_check: bool = false, autosave: bool = true) -> bool:
|
||||
if !is_open:
|
||||
loggeri.error("Failed to save dictionary: No storage file is open")
|
||||
logger.error("Failed to save dictionary: No storage file is open")
|
||||
return false
|
||||
loggeri.verb("Saving custom dictionary as storage")
|
||||
logger.verb("Saving custom dictionary as storage")
|
||||
if sanity_check:
|
||||
var check_result: Array[String] = perform_sanity_check(dict)
|
||||
if check_result.size() != 0:
|
||||
if fail_on_sanity_check:
|
||||
loggeri.error("Sanity check failed (stopping):")
|
||||
logger.error("Sanity check failed (stopping):")
|
||||
for error in check_result:
|
||||
loggeri.error("-> " + error)
|
||||
logger.error("-> " + error)
|
||||
return false
|
||||
else:
|
||||
loggeri.warn("Sanity check failed (continuing anyway):")
|
||||
logger.warn("Sanity check failed (continuing anyway):")
|
||||
for error in check_result:
|
||||
loggeri.warn("-> " + error)
|
||||
logger.warn("-> " + error)
|
||||
storage = dict
|
||||
if autosave: save_storage()
|
||||
return true
|
||||
|
@ -170,14 +170,14 @@ func save_dict(dict: Dictionary, sanity_check: bool = true, fail_on_sanity_check
|
|||
# +++ etc +++
|
||||
## Performs sanity checks on a [class Dictionary] to determine if it can be saved and loaded using this module.
|
||||
func perform_sanity_check(storage_check: Dictionary) -> Array[String]:
|
||||
loggeri.verb("Performing a sanity check on some storage dictionary")
|
||||
logger.verb("Performing a sanity check on some storage dictionary")
|
||||
var errors: Array[String] = []
|
||||
for key in storage_check:
|
||||
if typeof(key) != TYPE_STRING:
|
||||
errors.append("Key \"" + str(key) + "\" is not of type String (type '" + type_string(typeof(key)) + "')")
|
||||
errors.append(core.stringify_variables("Key %key% is not of type String (is of type %type%)", { "key": key, "type": type_string(typeof(key)) }))
|
||||
continue
|
||||
if typeof(storage_check[key]) != TYPE_NIL and typeof(storage_check[key]) != TYPE_STRING and typeof(storage_check[key]) != TYPE_INT and typeof(storage_check[key]) != TYPE_FLOAT and typeof(storage_check[key]) != TYPE_BOOL and typeof(storage_check[key]) != TYPE_ARRAY and typeof(storage_check[key]) != TYPE_DICTIONARY:
|
||||
errors.append("The value of \"" + key + "\" is not null, a string, an integer, a float, boolean, array or dictionary (type '" + type_string(typeof(key)) + "')")
|
||||
|
||||
loggeri.verb("Completed sanity check with " + str(errors.size()) + " errors")
|
||||
errors.append(core.stringify_variables("The value of %key% is not null, a String, an int, a float, boolean, an Array or a Dictionary (is of type %type%)", { "key": key, "type": type_string(typeof(key)) }))
|
||||
|
||||
logger.verb(core.stringify_variables("Completed sanity check with %errors% errors", { "errors": errors.size() }))
|
||||
return errors
|
||||
|
|
70
src/validation.gd
Normal file
70
src/validation.gd
Normal file
|
@ -0,0 +1,70 @@
|
|||
# CORE FRAMEWORK SOURCE FILE
|
||||
# Copyright (c) 2024 The StarOpenSource Project & Contributors
|
||||
# Licensed under the GNU Affero General Public License v3
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
## Allows for data validation.
|
||||
extends CoreBaseModule
|
||||
|
||||
var schemas: Array[CoreValidationSchema]
|
||||
var singles: Array[CoreValidationSingle]
|
||||
|
||||
# +++ module +++
|
||||
func _cleanup() -> void:
|
||||
# Singles
|
||||
var singles_remove_enty: Array[CoreLoggerInstance] = []
|
||||
for single in singles:
|
||||
singles_remove_enty.append(single)
|
||||
if !is_instance_valid(single): continue
|
||||
if !is_instance_valid(single.parent):
|
||||
logger.diag("Removing single '" + single.name + "'")
|
||||
single.queue_free()
|
||||
for single in singles_remove_enty:
|
||||
singles.remove_at(singles.find(single))
|
||||
|
||||
# Schemas
|
||||
var schemas_remove_enty: Array[CoreLoggerInstance] = []
|
||||
for schema in schemas:
|
||||
singles_remove_enty.append(schema)
|
||||
if !is_instance_valid(schema): continue
|
||||
if !is_instance_valid(schema.parent):
|
||||
logger.diag("Removing schema '" + schema.name + "'")
|
||||
schema.queue_free()
|
||||
for schema in schemas_remove_enty:
|
||||
schemas.remove_at(schemas.find(schema))
|
||||
|
||||
func _schedule() -> void:
|
||||
# Singles
|
||||
for single in singles:
|
||||
if is_instance_valid(single):
|
||||
logger.diag("Removing single '" + single.name + "'")
|
||||
single.queue_free()
|
||||
|
||||
# Schemas
|
||||
for schema in schemas:
|
||||
if is_instance_valid(schema):
|
||||
logger.diag("Removing schema '" + schema.name + "'")
|
||||
schema.queue_free()
|
||||
|
||||
# +++ data validation +++
|
||||
## Returns a new [CoreValidationSingle]
|
||||
func get_single(data, parent: Node) -> CoreValidationSingle:
|
||||
singles.append(parent)
|
||||
return CoreValidationSingle.new(core, data, parent)
|
||||
|
||||
## Returns a new [CoreValidationSchema]
|
||||
func get_schema(schema: Dictionary, parent: Node) -> CoreValidationSchema:
|
||||
schemas.append(parent)
|
||||
return CoreValidationSchema.new(core, schema, parent)
|
Loading…
Reference in a new issue