diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..75f4272 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,579 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = +ij_wrap_on_typing = false + +[*.css] +ij_css_align_closing_brace_with_properties = false +ij_css_blank_lines_around_nested_selector = 1 +ij_css_blank_lines_between_blocks = 1 +ij_css_block_comment_add_space = false +ij_css_brace_placement = end_of_line +ij_css_enforce_quotes_on_format = false +ij_css_hex_color_long_format = false +ij_css_hex_color_lower_case = false +ij_css_hex_color_short_format = false +ij_css_hex_color_upper_case = false +ij_css_keep_blank_lines_in_code = 2 +ij_css_keep_indents_on_empty_lines = false +ij_css_keep_single_line_blocks = false +ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_css_space_after_colon = true +ij_css_space_before_opening_brace = true +ij_css_use_double_quotes = true +ij_css_value_alignment = do_not_align + +[*.less] +indent_size = 2 +ij_less_align_closing_brace_with_properties = false +ij_less_blank_lines_around_nested_selector = 1 +ij_less_blank_lines_between_blocks = 1 +ij_less_block_comment_add_space = false +ij_less_brace_placement = 0 +ij_less_enforce_quotes_on_format = false +ij_less_hex_color_long_format = false +ij_less_hex_color_lower_case = false +ij_less_hex_color_short_format = false +ij_less_hex_color_upper_case = false +ij_less_keep_blank_lines_in_code = 2 +ij_less_keep_indents_on_empty_lines = false +ij_less_keep_single_line_blocks = false +ij_less_line_comment_add_space = false +ij_less_line_comment_at_first_column = false +ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_less_space_after_colon = true +ij_less_space_before_opening_brace = true +ij_less_use_double_quotes = true +ij_less_value_alignment = 0 + +[*.sass] +indent_size = 2 +ij_sass_align_closing_brace_with_properties = false +ij_sass_blank_lines_around_nested_selector = 1 +ij_sass_blank_lines_between_blocks = 1 +ij_sass_brace_placement = 0 +ij_sass_enforce_quotes_on_format = false +ij_sass_hex_color_long_format = false +ij_sass_hex_color_lower_case = false +ij_sass_hex_color_short_format = false +ij_sass_hex_color_upper_case = false +ij_sass_keep_blank_lines_in_code = 2 +ij_sass_keep_indents_on_empty_lines = false +ij_sass_keep_single_line_blocks = false +ij_sass_line_comment_add_space = false +ij_sass_line_comment_at_first_column = false +ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_sass_space_after_colon = true +ij_sass_space_before_opening_brace = true +ij_sass_use_double_quotes = true +ij_sass_value_alignment = 0 + +[*.scss] +indent_size = 2 +ij_scss_align_closing_brace_with_properties = false +ij_scss_blank_lines_around_nested_selector = 1 +ij_scss_blank_lines_between_blocks = 1 +ij_scss_block_comment_add_space = false +ij_scss_brace_placement = 0 +ij_scss_enforce_quotes_on_format = false +ij_scss_hex_color_long_format = false +ij_scss_hex_color_lower_case = false +ij_scss_hex_color_short_format = false +ij_scss_hex_color_upper_case = false +ij_scss_keep_blank_lines_in_code = 2 +ij_scss_keep_indents_on_empty_lines = false +ij_scss_keep_single_line_blocks = false +ij_scss_line_comment_add_space = false +ij_scss_line_comment_at_first_column = false +ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_scss_space_after_colon = true +ij_scss_space_before_opening_brace = true +ij_scss_use_double_quotes = true +ij_scss_value_alignment = 0 + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +ij_xml_align_attributes = true +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_add_space = false +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = false +ij_xml_keep_line_breaks = true +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = false +ij_xml_text_wrap = normal + +[{*.ats,*.cts,*.mts,*.ts}] +ij_continuation_indent_size = 4 +ij_typescript_align_imports = false +ij_typescript_align_multiline_array_initializer_expression = false +ij_typescript_align_multiline_binary_operation = false +ij_typescript_align_multiline_chained_methods = false +ij_typescript_align_multiline_extends_list = false +ij_typescript_align_multiline_for = true +ij_typescript_align_multiline_parameters = true +ij_typescript_align_multiline_parameters_in_calls = false +ij_typescript_align_multiline_ternary_operation = false +ij_typescript_align_object_properties = 0 +ij_typescript_align_union_types = false +ij_typescript_align_var_statements = 0 +ij_typescript_array_initializer_new_line_after_left_brace = false +ij_typescript_array_initializer_right_brace_on_new_line = false +ij_typescript_array_initializer_wrap = off +ij_typescript_assignment_wrap = off +ij_typescript_binary_operation_sign_on_next_line = false +ij_typescript_binary_operation_wrap = off +ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** +ij_typescript_blank_lines_after_imports = 1 +ij_typescript_blank_lines_around_class = 1 +ij_typescript_blank_lines_around_field = 0 +ij_typescript_blank_lines_around_field_in_interface = 0 +ij_typescript_blank_lines_around_function = 1 +ij_typescript_blank_lines_around_method = 1 +ij_typescript_blank_lines_around_method_in_interface = 1 +ij_typescript_block_brace_style = end_of_line +ij_typescript_block_comment_add_space = false +ij_typescript_block_comment_at_first_column = true +ij_typescript_call_parameters_new_line_after_left_paren = false +ij_typescript_call_parameters_right_paren_on_new_line = false +ij_typescript_call_parameters_wrap = off +ij_typescript_catch_on_new_line = false +ij_typescript_chained_call_dot_on_new_line = true +ij_typescript_class_brace_style = end_of_line +ij_typescript_class_decorator_wrap = split_into_lines +ij_typescript_class_field_decorator_wrap = off +ij_typescript_class_method_decorator_wrap = off +ij_typescript_comma_on_new_line = false +ij_typescript_do_while_brace_force = never +ij_typescript_else_on_new_line = false +ij_typescript_enforce_trailing_comma = keep +ij_typescript_enum_constants_wrap = on_every_item +ij_typescript_extends_keyword_wrap = off +ij_typescript_extends_list_wrap = off +ij_typescript_field_prefix = _ +ij_typescript_file_name_style = relaxed +ij_typescript_finally_on_new_line = false +ij_typescript_for_brace_force = never +ij_typescript_for_statement_new_line_after_left_paren = false +ij_typescript_for_statement_right_paren_on_new_line = false +ij_typescript_for_statement_wrap = off +ij_typescript_force_quote_style = false +ij_typescript_force_semicolon_style = false +ij_typescript_function_expression_brace_style = end_of_line +ij_typescript_function_parameter_decorator_wrap = off +ij_typescript_if_brace_force = never +ij_typescript_import_merge_members = global +ij_typescript_import_prefer_absolute_path = global +ij_typescript_import_sort_members = true +ij_typescript_import_sort_module_name = false +ij_typescript_import_use_node_resolution = true +ij_typescript_imports_wrap = on_every_item +ij_typescript_indent_case_from_switch = true +ij_typescript_indent_chained_calls = true +ij_typescript_indent_package_children = 0 +ij_typescript_jsdoc_include_types = false +ij_typescript_jsx_attribute_value = braces +ij_typescript_keep_blank_lines_in_code = 2 +ij_typescript_keep_first_column_comment = true +ij_typescript_keep_indents_on_empty_lines = false +ij_typescript_keep_line_breaks = true +ij_typescript_keep_simple_blocks_in_one_line = false +ij_typescript_keep_simple_methods_in_one_line = false +ij_typescript_line_comment_add_space = true +ij_typescript_line_comment_at_first_column = false +ij_typescript_method_brace_style = end_of_line +ij_typescript_method_call_chain_wrap = off +ij_typescript_method_parameters_new_line_after_left_paren = false +ij_typescript_method_parameters_right_paren_on_new_line = false +ij_typescript_method_parameters_wrap = off +ij_typescript_object_literal_wrap = on_every_item +ij_typescript_object_types_wrap = on_every_item +ij_typescript_parentheses_expression_new_line_after_left_paren = false +ij_typescript_parentheses_expression_right_paren_on_new_line = false +ij_typescript_place_assignment_sign_on_next_line = false +ij_typescript_prefer_as_type_cast = false +ij_typescript_prefer_explicit_types_function_expression_returns = false +ij_typescript_prefer_explicit_types_function_returns = false +ij_typescript_prefer_explicit_types_vars_fields = false +ij_typescript_prefer_parameters_wrap = false +ij_typescript_property_prefix = +ij_typescript_reformat_c_style_comments = false +ij_typescript_space_after_colon = true +ij_typescript_space_after_comma = true +ij_typescript_space_after_dots_in_rest_parameter = false +ij_typescript_space_after_generator_mult = true +ij_typescript_space_after_property_colon = true +ij_typescript_space_after_quest = true +ij_typescript_space_after_type_colon = true +ij_typescript_space_after_unary_not = false +ij_typescript_space_before_async_arrow_lparen = true +ij_typescript_space_before_catch_keyword = true +ij_typescript_space_before_catch_left_brace = true +ij_typescript_space_before_catch_parentheses = true +ij_typescript_space_before_class_lbrace = true +ij_typescript_space_before_class_left_brace = true +ij_typescript_space_before_colon = true +ij_typescript_space_before_comma = false +ij_typescript_space_before_do_left_brace = true +ij_typescript_space_before_else_keyword = true +ij_typescript_space_before_else_left_brace = true +ij_typescript_space_before_finally_keyword = true +ij_typescript_space_before_finally_left_brace = true +ij_typescript_space_before_for_left_brace = true +ij_typescript_space_before_for_parentheses = true +ij_typescript_space_before_for_semicolon = false +ij_typescript_space_before_function_left_parenth = true +ij_typescript_space_before_generator_mult = false +ij_typescript_space_before_if_left_brace = true +ij_typescript_space_before_if_parentheses = true +ij_typescript_space_before_method_call_parentheses = false +ij_typescript_space_before_method_left_brace = true +ij_typescript_space_before_method_parentheses = false +ij_typescript_space_before_property_colon = false +ij_typescript_space_before_quest = true +ij_typescript_space_before_switch_left_brace = true +ij_typescript_space_before_switch_parentheses = true +ij_typescript_space_before_try_left_brace = true +ij_typescript_space_before_type_colon = false +ij_typescript_space_before_unary_not = false +ij_typescript_space_before_while_keyword = true +ij_typescript_space_before_while_left_brace = true +ij_typescript_space_before_while_parentheses = true +ij_typescript_spaces_around_additive_operators = true +ij_typescript_spaces_around_arrow_function_operator = true +ij_typescript_spaces_around_assignment_operators = true +ij_typescript_spaces_around_bitwise_operators = true +ij_typescript_spaces_around_equality_operators = true +ij_typescript_spaces_around_logical_operators = true +ij_typescript_spaces_around_multiplicative_operators = true +ij_typescript_spaces_around_relational_operators = true +ij_typescript_spaces_around_shift_operators = true +ij_typescript_spaces_around_unary_operator = false +ij_typescript_spaces_within_array_initializer_brackets = false +ij_typescript_spaces_within_brackets = false +ij_typescript_spaces_within_catch_parentheses = false +ij_typescript_spaces_within_for_parentheses = false +ij_typescript_spaces_within_if_parentheses = false +ij_typescript_spaces_within_imports = false +ij_typescript_spaces_within_interpolation_expressions = false +ij_typescript_spaces_within_method_call_parentheses = false +ij_typescript_spaces_within_method_parentheses = false +ij_typescript_spaces_within_object_literal_braces = false +ij_typescript_spaces_within_object_type_braces = true +ij_typescript_spaces_within_parentheses = false +ij_typescript_spaces_within_switch_parentheses = false +ij_typescript_spaces_within_type_assertion = false +ij_typescript_spaces_within_union_types = true +ij_typescript_spaces_within_while_parentheses = false +ij_typescript_special_else_if_treatment = true +ij_typescript_ternary_operation_signs_on_next_line = false +ij_typescript_ternary_operation_wrap = off +ij_typescript_union_types_wrap = on_every_item +ij_typescript_use_chained_calls_group_indents = false +ij_typescript_use_double_quotes = true +ij_typescript_use_explicit_js_extension = auto +ij_typescript_use_import_type = auto +ij_typescript_use_path_mapping = always +ij_typescript_use_public_modifier = false +ij_typescript_use_semicolon_after_statement = true +ij_typescript_var_declaration_wrap = normal +ij_typescript_while_brace_force = never +ij_typescript_while_on_new_line = false +ij_typescript_wrap_comments = false + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 +ij_shell_binary_ops_start_line = false +ij_shell_keep_column_alignment_padding = false +ij_shell_minify_program = false +ij_shell_redirect_followed_by_space = false +ij_shell_switch_cases_indented = false +ij_shell_use_unix_line_separator = true + +[{*.cjs,*.es6,*.js,*.mjs}] +indent_style = tab +ij_continuation_indent_size = 4 +ij_javascript_align_imports = false +ij_javascript_align_multiline_array_initializer_expression = false +ij_javascript_align_multiline_binary_operation = false +ij_javascript_align_multiline_chained_methods = false +ij_javascript_align_multiline_extends_list = false +ij_javascript_align_multiline_for = true +ij_javascript_align_multiline_parameters = true +ij_javascript_align_multiline_parameters_in_calls = false +ij_javascript_align_multiline_ternary_operation = false +ij_javascript_align_object_properties = 0 +ij_javascript_align_union_types = false +ij_javascript_align_var_statements = 0 +ij_javascript_array_initializer_new_line_after_left_brace = true +ij_javascript_array_initializer_right_brace_on_new_line = true +ij_javascript_array_initializer_wrap = on_every_item +ij_javascript_assignment_wrap = off +ij_javascript_binary_operation_sign_on_next_line = false +ij_javascript_binary_operation_wrap = off +ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** +ij_javascript_blank_lines_after_imports = 1 +ij_javascript_blank_lines_around_class = 1 +ij_javascript_blank_lines_around_field = 0 +ij_javascript_blank_lines_around_function = 1 +ij_javascript_blank_lines_around_method = 1 +ij_javascript_block_brace_style = end_of_line +ij_javascript_block_comment_add_space = false +ij_javascript_block_comment_at_first_column = true +ij_javascript_call_parameters_new_line_after_left_paren = false +ij_javascript_call_parameters_right_paren_on_new_line = false +ij_javascript_call_parameters_wrap = off +ij_javascript_catch_on_new_line = false +ij_javascript_chained_call_dot_on_new_line = true +ij_javascript_class_brace_style = end_of_line +ij_javascript_class_decorator_wrap = split_into_lines +ij_javascript_class_field_decorator_wrap = off +ij_javascript_class_method_decorator_wrap = off +ij_javascript_comma_on_new_line = false +ij_javascript_do_while_brace_force = never +ij_javascript_else_on_new_line = false +ij_javascript_enforce_trailing_comma = remove +ij_javascript_extends_keyword_wrap = off +ij_javascript_extends_list_wrap = off +ij_javascript_field_prefix = _ +ij_javascript_file_name_style = relaxed +ij_javascript_finally_on_new_line = false +ij_javascript_for_brace_force = never +ij_javascript_for_statement_new_line_after_left_paren = false +ij_javascript_for_statement_right_paren_on_new_line = false +ij_javascript_for_statement_wrap = off +ij_javascript_force_quote_style = true +ij_javascript_force_semicolon_style = true +ij_javascript_function_expression_brace_style = end_of_line +ij_javascript_function_parameter_decorator_wrap = off +ij_javascript_if_brace_force = never +ij_javascript_import_merge_members = global +ij_javascript_import_prefer_absolute_path = global +ij_javascript_import_sort_members = true +ij_javascript_import_sort_module_name = false +ij_javascript_import_use_node_resolution = true +ij_javascript_imports_wrap = on_every_item +ij_javascript_indent_case_from_switch = true +ij_javascript_indent_chained_calls = true +ij_javascript_indent_package_children = 0 +ij_javascript_jsx_attribute_value = braces +ij_javascript_keep_blank_lines_in_code = 2 +ij_javascript_keep_first_column_comment = true +ij_javascript_keep_indents_on_empty_lines = false +ij_javascript_keep_line_breaks = true +ij_javascript_keep_simple_blocks_in_one_line = false +ij_javascript_keep_simple_methods_in_one_line = false +ij_javascript_line_comment_add_space = true +ij_javascript_line_comment_at_first_column = false +ij_javascript_method_brace_style = end_of_line +ij_javascript_method_call_chain_wrap = off +ij_javascript_method_parameters_new_line_after_left_paren = false +ij_javascript_method_parameters_right_paren_on_new_line = false +ij_javascript_method_parameters_wrap = off +ij_javascript_object_literal_wrap = on_every_item +ij_javascript_object_types_wrap = on_every_item +ij_javascript_parentheses_expression_new_line_after_left_paren = false +ij_javascript_parentheses_expression_right_paren_on_new_line = false +ij_javascript_place_assignment_sign_on_next_line = false +ij_javascript_prefer_as_type_cast = false +ij_javascript_prefer_explicit_types_function_expression_returns = false +ij_javascript_prefer_explicit_types_function_returns = false +ij_javascript_prefer_explicit_types_vars_fields = false +ij_javascript_prefer_parameters_wrap = false +ij_javascript_property_prefix = +ij_javascript_reformat_c_style_comments = false +ij_javascript_space_after_colon = true +ij_javascript_space_after_comma = true +ij_javascript_space_after_dots_in_rest_parameter = false +ij_javascript_space_after_generator_mult = true +ij_javascript_space_after_property_colon = true +ij_javascript_space_after_quest = true +ij_javascript_space_after_type_colon = true +ij_javascript_space_after_unary_not = false +ij_javascript_space_before_async_arrow_lparen = true +ij_javascript_space_before_catch_keyword = true +ij_javascript_space_before_catch_left_brace = true +ij_javascript_space_before_catch_parentheses = true +ij_javascript_space_before_class_lbrace = true +ij_javascript_space_before_class_left_brace = true +ij_javascript_space_before_colon = true +ij_javascript_space_before_comma = false +ij_javascript_space_before_do_left_brace = true +ij_javascript_space_before_else_keyword = true +ij_javascript_space_before_else_left_brace = true +ij_javascript_space_before_finally_keyword = true +ij_javascript_space_before_finally_left_brace = true +ij_javascript_space_before_for_left_brace = true +ij_javascript_space_before_for_parentheses = true +ij_javascript_space_before_for_semicolon = false +ij_javascript_space_before_function_left_parenth = true +ij_javascript_space_before_generator_mult = false +ij_javascript_space_before_if_left_brace = true +ij_javascript_space_before_if_parentheses = true +ij_javascript_space_before_method_call_parentheses = false +ij_javascript_space_before_method_left_brace = true +ij_javascript_space_before_method_parentheses = false +ij_javascript_space_before_property_colon = false +ij_javascript_space_before_quest = true +ij_javascript_space_before_switch_left_brace = true +ij_javascript_space_before_switch_parentheses = true +ij_javascript_space_before_try_left_brace = true +ij_javascript_space_before_type_colon = false +ij_javascript_space_before_unary_not = false +ij_javascript_space_before_while_keyword = true +ij_javascript_space_before_while_left_brace = true +ij_javascript_space_before_while_parentheses = true +ij_javascript_spaces_around_additive_operators = true +ij_javascript_spaces_around_arrow_function_operator = true +ij_javascript_spaces_around_assignment_operators = true +ij_javascript_spaces_around_bitwise_operators = true +ij_javascript_spaces_around_equality_operators = true +ij_javascript_spaces_around_logical_operators = true +ij_javascript_spaces_around_multiplicative_operators = true +ij_javascript_spaces_around_relational_operators = true +ij_javascript_spaces_around_shift_operators = true +ij_javascript_spaces_around_unary_operator = false +ij_javascript_spaces_within_array_initializer_brackets = true +ij_javascript_spaces_within_brackets = false +ij_javascript_spaces_within_catch_parentheses = false +ij_javascript_spaces_within_for_parentheses = false +ij_javascript_spaces_within_if_parentheses = false +ij_javascript_spaces_within_imports = true +ij_javascript_spaces_within_interpolation_expressions = false +ij_javascript_spaces_within_method_call_parentheses = false +ij_javascript_spaces_within_method_parentheses = false +ij_javascript_spaces_within_object_literal_braces = true +ij_javascript_spaces_within_object_type_braces = true +ij_javascript_spaces_within_parentheses = false +ij_javascript_spaces_within_switch_parentheses = false +ij_javascript_spaces_within_type_assertion = false +ij_javascript_spaces_within_union_types = true +ij_javascript_spaces_within_while_parentheses = false +ij_javascript_special_else_if_treatment = true +ij_javascript_ternary_operation_signs_on_next_line = false +ij_javascript_ternary_operation_wrap = off +ij_javascript_union_types_wrap = on_every_item +ij_javascript_use_chained_calls_group_indents = false +ij_javascript_use_double_quotes = true +ij_javascript_use_explicit_js_extension = auto +ij_javascript_use_import_type = auto +ij_javascript_use_path_mapping = always +ij_javascript_use_public_modifier = false +ij_javascript_use_semicolon_after_statement = true +ij_javascript_var_declaration_wrap = normal +ij_javascript_while_brace_force = never +ij_javascript_while_on_new_line = false +ij_javascript_wrap_comments = false + +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] +indent_size = 2 +ij_json_array_wrapping = split_into_lines +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_keep_trailing_comma = false +ij_json_object_wrapping = split_into_lines +ij_json_property_alignment = do_not_align +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = false +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.htm,*.html,*.sht,*.shtm,*.shtml}] +ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 +ij_html_align_attributes = true +ij_html_align_text = false +ij_html_attribute_wrap = normal +ij_html_block_comment_add_space = false +ij_html_block_comment_at_first_column = true +ij_html_do_not_align_children_of_min_lines = 0 +ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p +ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot +ij_html_enforce_quotes = false +ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var +ij_html_keep_blank_lines = 2 +ij_html_keep_indents_on_empty_lines = false +ij_html_keep_line_breaks = true +ij_html_keep_line_breaks_in_text = true +ij_html_keep_whitespaces = false +ij_html_keep_whitespaces_inside = span,pre,textarea +ij_html_line_comment_at_first_column = true +ij_html_new_line_after_last_attribute = never +ij_html_new_line_before_first_attribute = never +ij_html_quote_style = double +ij_html_remove_new_line_before_tags = br +ij_html_space_after_tag_name = false +ij_html_space_around_equality_in_attribute = false +ij_html_space_inside_empty_tag = false +ij_html_text_wrap = normal + +[{*.http,*.rest}] +ij_continuation_indent_size = 4 +ij_http-request_call_parameters_wrap = normal +ij_http-request_method_parameters_wrap = split_into_lines +ij_http-request_space_before_comma = true +ij_http-request_spaces_around_assignment_operators = true + +[{*.markdown,*.md}] +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_format_tables = true +ij_markdown_insert_quote_arrows_on_wrap = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_keep_line_breaks_inside_text_blocks = true +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 +ij_markdown_wrap_text_if_long = true +ij_markdown_wrap_text_inside_blockquotes = true + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_line_comment_add_space = false +ij_yaml_line_comment_add_space_on_reformat = false +ij_yaml_line_comment_at_first_column = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/.env.example b/.env.example index d4e10e0..c563fe3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,14 @@ -DISCORD_WEBHOOK_URL = +MATRIX_BASE_URL = +MATRIX_ACCESS_TOKEN = +MATRIX_ROOM_ID = +DISCORD_WEBHOOK_URL = DATABASE_USER = statsBot DATABASE_PASSWORD = statsBot DATABASE_HOST = 127.0.0.1 -DATABASE_NAME = asteriskcdrdb \ No newline at end of file +DATABASE_NAME = asteriskcdrdb +# Extra debugging options: +#NOOP=true +#RUN_ONCE=true +#DATABASE_LOG_TIMINGS=true +#LOG_MESSAGES=true +#NO_SAVE_RECORDS=true diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..e1f0e73 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/freepbx-stats.iml b/.idea/freepbx-stats.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/freepbx-stats.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..d23208f --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6b9e064 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/callStats.js b/callStats.js new file mode 100644 index 0000000..09c6361 --- /dev/null +++ b/callStats.js @@ -0,0 +1,30 @@ +export class CallStats { + /** + * @param {CallStats} stats + */ + constructor(stats) { + for (const [ key, value ] of Object.entries(stats)) + this[key] = value; + } + + /** + * @type {number} + */ + totalCallsThisMonth; + /** + * @type {number} + */ + totalCallsEverPlaced; + /** + * @type {number} + */ + totalCallsMade; + /** + * @type {CallRecord} + */ + allTimeRecord; + /** + * @type {boolean} + */ + isNewRecord = false; +} \ No newline at end of file diff --git a/dateBuilder.js b/dateBuilder.js new file mode 100644 index 0000000..c9c247a --- /dev/null +++ b/dateBuilder.js @@ -0,0 +1,69 @@ +export class DateBuilder { + // constructors + constructor(date = new Date()) { + if (!(date instanceof Date)) { + throw new Error("Invalid date object."); + } + this.date = new Date(date.getTime()); // Create a copy to avoid mutating the original date + } + + // methods + addYears(years) { + this.date.setFullYear(this.date.getFullYear() + years); + return this; + } + + addMonths(months) { + this.date.setMonth(this.date.getMonth() + months); + return this; + } + + addDays(days) { + this.date.setDate(this.date.getDate() + days); + return this; + } + + addHours(hours) { + this.date.setHours(this.date.getHours() + hours); + return this; + } + + addMinutes(minutes) { + this.date.setMinutes(this.date.getMinutes() + minutes); + return this; + } + + addSeconds(seconds) { + this.date.setSeconds(this.date.getSeconds() + seconds); + return this; + } + + addMillis(millis) { + this.date.setTime(this.date.getTime() + millis); + return this; + } + + withDate(year, month, day) { + this.date.setFullYear(year, month - 1, day); // month is 0-based + return this; + } + + withTime(hour, minute = 0, second = 0, millisecond = 0) { + this.date.setHours(hour, minute, second, millisecond); + return this; + } + + atStartOfDay() { + this.date.setHours(0, 0, 0, 0); + return this; + } + + atEndOfDay() { + this.date.setHours(23, 59, 59, 999); + return this; + } + + build() { + return new Date(this.date.getTime()); // Return a copy to avoid external mutation + } +} \ No newline at end of file diff --git a/dateBuilder.test.js b/dateBuilder.test.js new file mode 100644 index 0000000..506d84b --- /dev/null +++ b/dateBuilder.test.js @@ -0,0 +1,137 @@ +// noinspection ES6ConvertRequireIntoImport - this is CJS, not MJS + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { DateBuilder } = require("./dateBuilder"); + +test("DateBuilder should be able to be initialised", () => { + const db = new DateBuilder(); + assert.equal(db instanceof DateBuilder, true); +}); + +test("DateBuilder should be able to build current date", () => { + const now = new Date(); + const db = new DateBuilder(); + const built = db.build(); + assert.equal(built.getFullYear(), now.getFullYear()); + assert.equal(built.getMonth(), now.getMonth()); + assert.equal(built.getDate(), now.getDate()); + assert.equal(built.getHours(), now.getHours()); + assert.equal(built.getMinutes(), now.getMinutes()); + assert.equal(built.getSeconds(), now.getSeconds()); +}); + +test("DateBuilder should be able to add days", () => { + const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024 + db.addDays(30); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 0); // January + assert.equal(built.getDate(), 31); // January has 31 days +}); + +test("DateBuilder should be able to add months", () => { + const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 31, 2024 + db.addMonths(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 1); // February +}); + +test("DateBuilder should be able to add years", () => { + const db = new DateBuilder(new Date(2020, 1, 1)); + db.addYears(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2021); + assert.equal(built.getMonth(), 1); // February + assert.equal(built.getDate(), 1); +}); + +test("DateBuilder should be able to set date", () => { + const db = new DateBuilder(); + db.withDate(2022, 12, 25); // Dec 25, 2022 + const built = db.build(); + assert.equal(built.getFullYear(), 2022); + assert.equal(built.getMonth(), 11); // December + assert.equal(built.getDate(), 25); +}); + +test("DateBuilder should be able to set time", () => { + const db = new DateBuilder(); + db.withTime(15, 30, 45, 123); // 15:30:45.123 + const built = db.build(); + assert.equal(built.getHours(), 15); + assert.equal(built.getMinutes(), 30); + assert.equal(built.getSeconds(), 45); + assert.equal(built.getMilliseconds(), 123); +}); + +test("DateBuilder should be able to set start of day", () => { + const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456 + db.atStartOfDay(); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 5); // June + assert.equal(built.getDate(), 15); + assert.equal(built.getHours(), 0); + assert.equal(built.getMinutes(), 0); + assert.equal(built.getSeconds(), 0); + assert.equal(built.getMilliseconds(), 0); +}); + +test("DateBuilder should be able to set end of day", () => { + const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456 + db.atEndOfDay(); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 5); // June + assert.equal(built.getDate(), 15); + assert.equal(built.getHours(), 23); + assert.equal(built.getMinutes(), 59); + assert.equal(built.getSeconds(), 59); + assert.equal(built.getMilliseconds(), 999); +}); + +test("DateBuilder should be able to chain methods", () => { + const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024 + db.addDays(1).addMonths(1).addYears(1).withTime(12, 0, 0).atEndOfDay(); + const built = db.build(); + assert.equal(built.getFullYear(), 2025); + assert.equal(built.getMonth(), 1); // March + assert.equal(built.getDate(), 2); + assert.equal(built.getHours(), 23); + assert.equal(built.getMinutes(), 59); + assert.equal(built.getSeconds(), 59); + assert.equal(built.getMilliseconds(), 999); +}); + +test("DateBuilder should not mutate original date", () => { + const original = new Date(2024, 0, 1); // Jan 1, 2024 + const db = new DateBuilder(original); + db.addDays(10); + const built = db.build(); + assert.equal(original.getFullYear(), 2024); + assert.equal(original.getMonth(), 0); // January + assert.equal(original.getDate(), 1); // Original date should remain unchanged + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 0); // January + assert.equal(built.getDate(), 11); // New date should be Jan 11, 2024 +}); + +test("DateBuilder should handle leap years correctly", () => { + const db = new DateBuilder(new Date(2020, 1, 29)); // Feb 29, 2020 (leap year) + db.addYears(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2021); + assert.equal(built.getMonth(), 2); // March + assert.equal(built.getDate(), 1); // March 1, 2021 (not a leap year) +}); + +test("DateBuilder should handle month overflow correctly", () => { + const db = new DateBuilder(new Date(2024, 0, 31)); // Jan 31, 2024 + db.addDays(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 1); // February + assert.equal(built.getDate(), 1); // Feb 29, 2024 (leap year) +}); diff --git a/index.js b/index.js index 0f32cf7..116f6b1 100644 --- a/index.js +++ b/index.js @@ -1,150 +1,228 @@ -require("dotenv").config() +require("dotenv").config(); const cron = require("node-cron"); -const os = require("os") -const Discord = require('discord.js'); -const { connect } = require("http2"); -const mysql = require('mysql'); -const fs = require('fs').promises; +const os = require("os"); +const Discord = require("discord.js"); +const mysql = require("mysql"); +const { TimeSpan } = require("./timeSpan"); +const { DateBuilder } = require("./dateBuilder"); +const { CallStats } = require("./callStats"); +const { CallRecord, Records } = require("./records"); +const fsSync = require("fs"); -const hook = new Discord.WebhookClient({ url: process.env.DISCORD_WEBHOOK_URL }) +const hook = !!process.env.DISCORD_WEBHOOK_URL ? new Discord.WebhookClient({ url: process.env.DISCORD_WEBHOOK_URL }) : null; const JSON_FILE = process.env.JSON_FILE || "records.json"; +const records = fsSync.existsSync(JSON_FILE) ? Records.fromJSONFile(JSON_FILE) : new Records(); -function getStartOfYesterdayTimestamp() { - const today = new Date(); - // Set the date to yesterday - today.setDate(today.getDate() - 1); - // Create a new Date object for the start of yesterday - const startOfYesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - return startOfYesterday.getTime(); // Returns the timestamp in milliseconds +function getYesterday() { + return new TimeSpan(new DateBuilder().addDays(-1).atStartOfDay().build().getTime(), new DateBuilder().addDays(-1).atEndOfDay().build().getTime()); } -async function loadRecords() { - try { - const data = await fs.readFile(JSON_FILE, 'utf-8'); - return JSON.parse(data); - } catch (error) { - if (error.code === 'ENOENT') { - return { records: {} }; // Return empty records if file doesn't exist - } - throw error; - } -} - -async function saveRecords(root) { - const json = JSON.stringify(root, null, 2); - await fs.writeFile(JSON_FILE, json); -} - -async function getPreviousDayData() { - return new Promise(async (resolve, reject) => { - const previousDay = new Date(getStartOfYesterdayTimestamp()) // 24 hours ago - const startTime = new Date(previousDay.setHours(0, 0, 0, 0)); - const endTime = new Date(previousDay.setHours(23, 59, 59, 999)); - const connection = await mysql.createConnection({ - host: process.env.DATABASE_HOST, - user: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - multipleStatements: true - }); - await connection.connect(); - let callsMade; - let recordForToday; - let monthlyTotal; - let totalCalls; - await connection.query(` - SELECT COUNT(DISTINCT uniqueid) AS call_count FROM cdr WHERE calldate BETWEEN ? AND ?; - SELECT COUNT(DISTINCT uniqueid) AS call_count FROM cdr WHERE MONTH(calldate) = MONTH(?) AND YEAR(calldate) = YEAR(?); - SELECT COUNT(DISTINCT uniqueid) AS call_count FROM cdr; - `, [startTime, endTime, previousDay, previousDay], (err, res) => { +/** + * @param {string} query + * @param {any} params + * @returns {Promise} + */ +async function queryScalarAsync(query, ...params) { + const start = Date.now(); + const connection = await mysql.createConnection({ + host: process.env.DATABASE_HOST, + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME + }); + await connection.connect(); + return new Promise((resolve, reject) => { + connection.query(query, params, (err, results) => { if (err) { - reject(err) + reject(err); + } else { + if (process.env.DATABASE_LOG_TIMINGS) console.log(`Query took ${Date.now() - start}ms:`, query, params, "=>", results); + resolve(results[0][Object.keys(results[0])[0]]); } connection.end(); - let output = { - "Calls Made": res[0][0].call_count, - "Monthly Total": res[1][0].call_count, - "Total Calls Ever Placed": res[2][0].call_count, - "System Uptime": getSystemUptime(), - "All Time Record": null, // Placeholder - } - console.log(output) - resolve(output); }); - }) + }); +} + +/** + * Fetch call statistics + * @returns {Promise} + */ +async function getPreviousDayData() { + const [ callsYesterday, callsThisMonth, callsTotal ] = await Promise.all([ + queryScalarAsync(` + SELECT COUNT(DISTINCT uniqueid) AS call_count + FROM cdr + WHERE calldate BETWEEN ? AND ?; + `, getYesterday().startDate, getYesterday().endDate), queryScalarAsync(` + SELECT COUNT(DISTINCT uniqueid) AS call_count + FROM cdr + WHERE MONTH (calldate) = ? AND YEAR (calldate) = ?; + `, getYesterday().startDate.getMonth(), getYesterday().startDate.getFullYear()), queryScalarAsync(` + SELECT COUNT(DISTINCT uniqueid) AS call_count + FROM cdr; + `) + ]); + const stats = new CallStats({ + totalCallsMade: callsYesterday, totalCallsThisMonth: callsThisMonth, totalCallsEverPlaced: callsTotal + }); + console.log("Got stats:", stats, "built from query results:", { callsYesterday, callsThisMonth, callsTotal }); + return stats; } function getSystemUptime() { const uptime = os.uptime(); - const days = Math.floor(uptime / 86400); - const hours = Math.floor((uptime % 86400) / 3600); - const minutes = Math.floor((uptime % 3600) / 60); - const seconds = Math.floor(uptime % 60); - return `${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`; + const now = new Date(); + return new TimeSpan(now - (uptime * 1000), now.getTime()); } -function updateRecords(data, root) { - const currentDate = new Date(getStartOfYesterdayTimestamp()).toISOString().split('T')[0]; - const month = currentDate.slice(0, 7); - let isNewRecord = false; +/** + * Update records with new data + * @param {CallStats} callStats + * @returns {CallStats} + */ +function updateRecords(callStats) { + const yesterday = getYesterday().startDate; + const yesterdayDateString = yesterday.toISOString().split("T")[0]; // Update all-time record - const allTimeRecord = root.records.record_calls || { date: currentDate, count: 0 }; - if (!root.records.record_calls) { - root.records.record_calls = { date: currentDate, count: data["Calls Made"] }; - isNewRecord = true; - } else if (parseInt(allTimeRecord.count) < data["Calls Made"]) { - allTimeRecord.count = data["Calls Made"]; - isNewRecord = true; + const previousRecord = records.callRecord || new CallRecord({ date: yesterdayDateString, count: 0 }); + callStats.isNewRecord = false; + + if (!records.callRecord) { + records.callRecord = new CallRecord({ date: yesterdayDateString, count: callStats.totalCallsMade }); + callStats.isNewRecord = true; + console.warn("No previous call record found, initializing new record."); + } else if (callStats.totalCallsMade > previousRecord.count) { + records.callRecord.count = callStats.totalCallsMade; + records.callRecord.date = yesterdayDateString; + callStats.isNewRecord = true; + console.log(`New all-time record: ${previousRecord.count} calls on ${yesterdayDateString}, previous record was ${records.callRecord.count} calls on ${records.callRecord.date}`); + } else { + console.log(`No new record. Yesterday: ${callStats.totalCallsMade}, Record: ${previousRecord.count} on ${previousRecord.date}`); } - data["All Time Record"] = `${allTimeRecord.count} calls on ${allTimeRecord.date}`; + + // pass record to call stats for reporting + callStats.allTimeRecord = records.callRecord; // Update total calls ever placed - root.records.total_calls_ever_placed = data["Total Calls Ever Placed"]; + records.totalCallsEverPlaced = callStats.totalCallsEverPlaced; - // Update monthly total - root.records[`monthly_total_${month}`] = data["Monthly Total"]; + // Update monthly totals + if (!records.monthlyTotals) records.monthlyTotals = {}; + if (!records.monthlyTotals[yesterday.getFullYear().toString()]) records.monthlyTotals[yesterday.getFullYear().toString()] = {}; + records.monthlyTotals[yesterday.getFullYear().toString()][yesterday.getMonth().toString()] = callStats.totalCallsThisMonth; - if (isNewRecord) { - data["NEW RECORD"] = true; - } - return data; + return callStats; } async function sendSummary() { - console.log("Preparing summary.") + console.log("Preparing summary."); const data = await getPreviousDayData(); - console.log("Loading records.") - const root = await loadRecords(); - console.log("Updating records...") - const updatedData = await updateRecords(data, root); - console.log("Saving.") - await saveRecords(root); + console.log("Updating records..."); + const stats = await updateRecords(data); + if (!process.env.NO_SAVE_RECORDS) { + console.log("Saving."); + await records.toJSONFile(JSON_FILE); + } - const previousDayStart = new Date(getStartOfYesterdayTimestamp()); - const previousDayEnd = new Date(previousDayStart); - previousDayEnd.setHours(23, 59, 59, 999); + const yesterday = getYesterday(); + + await sendSummaryDiscord(yesterday, stats); + await sendSummaryMatrix(yesterday, stats); +} + +async function sendSummaryMatrix(yesterday, stats) { + const message = { + "msgtype": "m.text", + "format": "org.matrix.custom.html", + "body": `Summary from ${new Date(yesterday.start).toDateString()} to ${new Date(yesterday.end).toDateString()}\n +Calls Made: ${stats.totalCallsMade} +Monthly Total: ${stats.totalCallsThisMonth} +Total Calls Ever Placed: ${stats.totalCallsEverPlaced} +System Uptime: ${getSystemUptime().toString(false, false)} +All Time Record: ${stats.allTimeRecord.count} calls on ${stats.allTimeRecord.date} +${stats.isNewRecord ? `🎉 NEW RECORD! 🎉 A new record has been set, at ${stats.totalCallsMade} calls in a day!` : ""}`.trim(), + "formatted_body": ` + + + + + + + + + + + ${stats.isNewRecord ? `` : ""} + +
Summary from ${yesterday.startDate.toString()} to ${yesterday.endDate.toString()}
Calls made${stats.totalCallsMade}
Monthly total${stats.totalCallsThisMonth}
Total calls ever placed${stats.totalCallsEverPlaced}
System uptime${getSystemUptime().toString(false, false)}
All-time record${stats.allTimeRecord.count} calls on ${stats.allTimeRecord.date}
🎉 NEW RECORD! 🎉 A new record has been set, at ${stats.totalCallsMade} calls in a day!
+ `.split("\n").map(s => s.trim()).join(""), + "tel.litenet.call_stats_summary": { + ...stats, date: yesterday, allTimeRecordData: records.callRecord + } + }; + + if (process.env.LOG_MESSAGES) { + console.log("Sending Matrix message:", JSON.stringify(message, null, 2)); + console.log("Plaintext:\n", message.body); + console.log("HTML:\n", message.formatted_body); + } + + if (!process.env.NOOP) { + if (!process.env.MATRIX_BASE_URL || !process.env.MATRIX_ROOM_ID || !process.env.MATRIX_ACCESS_TOKEN) { + console.warn("MATRIX_BASE_URL, MATRIX_ROOM_ID or MATRIX_ACCESS_TOKEN not set, skipping Matrix message."); + return; + } + const resp = await fetch(`${process.env.MATRIX_BASE_URL}/_matrix/client/v3/rooms/${encodeURIComponent(process.env.MATRIX_ROOM_ID)}/send/m.room.message/${Math.random()}`, { + method: "PUT", body: JSON.stringify(message, null, 2), headers: { + "Authorization": `Bearer ${process.env.MATRIX_ACCESS_TOKEN}`, "Content-Type": "application/json" + } + }); + if (!resp.ok) { + console.error("Failed to send Matrix message:", resp.status, await resp.text()); + } else { + console.log("Matrix message sent successfully."); + } + } +} + +async function sendSummaryDiscord(yesterday, stats) { + const makeField = (name, value) => ({ + name, value: value === undefined ? "***ERR: UNDEFINED***" : value.toString(), inline: false + }); let embed = { - title: `Summary from to `, + title: `Summary from to `, color: 0x1E90FF, - fields: [], + fields: [ + makeField("Calls Made", stats.totalCallsMade), + makeField("Monthly Total", stats.totalCallsThisMonth), + makeField("Total Calls Ever Placed", stats.totalCallsEverPlaced), + makeField("System Uptime", getSystemUptime().toString(false, false)), + makeField("All Time Record", `${stats.allTimeRecord.count} calls on ${stats.allTimeRecord.date}`) + ], timestamp: new Date(), - footer: { - - } + footer: {} + }; + if (stats.isNewRecord) { + embed.color = 0xFFD700; // Gold color for new record + embed.fields.push(makeField("🎉 NEW RECORD! 🎉", `A new record has been set, at ${stats.totalCallsMade} calls in a day!`)); } - for (const [key, value] of Object.entries(updatedData)) { - if (key === "NEW RECORD") { - embed.fields.push({ name: "NEW RECORD!", value: "A new record has been set!", inline: false }); - } else { - embed.fields.push({ name: key, value: value, inline: false }); - } - } - console.log("Sending message.") - await hook.send({ embeds: [embed] }); + + const payload = { embeds: [ embed ] }; + + if (process.env.LOG_MESSAGES) console.log("Sending Discord message:", JSON.stringify(payload, null, 2)); + + if (hook && !process.env.NOOP) await hook.send(payload); } -console.log("Scheduling...") -const schedule = cron.schedule("0 1 * * *", sendSummary) \ No newline at end of file + +if (process.env.NOOP || process.env.RUN_ONCE) { + sendSummary(); + return; +} + +console.log("Scheduling..."); +const schedule = cron.schedule("0 1 * * *", sendSummary); diff --git a/records.js b/records.js new file mode 100644 index 0000000..8cbac55 --- /dev/null +++ b/records.js @@ -0,0 +1,67 @@ +import fs from "fs"; + +export class Records { + constructor(records = {}) { + if (typeof records.records === "object") + for (const [ key, value ] of Object.entries(records.records)) { + const oldTableMatches = key.match(/^monthly_total_(\d{4})-(\d{2})$/); + if (oldTableMatches) { + const year = oldTableMatches[1]; + const month = oldTableMatches[2]; + if (!this.monthlyTotals) this.monthlyTotals = {}; + if (!this.monthlyTotals[year]) this.monthlyTotals[year] = {}; + this.monthlyTotals[year][month] = value; + } else if (key === "record_calls" && typeof value === "object" && value !== null) this.callRecord = new CallRecord(value.date, value.count); + else if (key === "total_calls_ever_placed" && typeof value === "number") this.totalCallsEverPlaced = value; + else throw new Error(`Unknown legacy record key: ${key}`); + } else for (const [ key, value ] of Object.entries(records)) this[key] = value; + } + + static fromJSONFile(path) { + const data = fs.readFileSync(path, "utf-8"); + const obj = JSON.parse(data); + + return new Records(obj); + } + + toJSONFile(path) { + const data = JSON.stringify(this, null, 2); + fs.writeFileSync(path, data, "utf-8"); + } + + /** + * @type {CallRecord} + */ + callRecord; + /** + * @type {number} + */ + totalCallsEverPlaced; + /** + * @type {{[year: string]: {[month: string]: number}}} + */ + monthlyTotals; +} + +export class CallRecord { + constructor(date, count) { + this.date = date; + this.count = count; + } + + /** + * @type {string} YYYY-MM-DD + */ + date; + /** + * @type {number} + */ + count; +} + +// test (mjs) +if (import.meta.url === `file://${process.argv[1]}`) { + const records = Records.fromJSONFile("records.json"); + console.log(records); + console.log(JSON.stringify(records, null, 2)); +} \ No newline at end of file diff --git a/timeSpan.js b/timeSpan.js new file mode 100644 index 0000000..e0cc8b0 --- /dev/null +++ b/timeSpan.js @@ -0,0 +1,129 @@ +/** + * Represents a timespan with a start and end time. + */ +export class TimeSpan { + // constructors + constructor(start = Date.now(), end = Date.now()) { + if (start > end) { + throw new Error("Start time must be less than or equal to end time."); + } + this.start = start; + this.end = end; + } + + static fromDates(startDate, endDate) { + return new TimeSpan(startDate, endDate); + } + + static fromMillis(millis) { + return new TimeSpan(0, millis); + } + + static fromSeconds(seconds) { + return TimeSpan.fromMillis(seconds * 1000); + } + + // methods + get totalMillis() { + return this.end - this.start; + } + + get millis() { + return Math.floor((this.totalMillis) % 1000); + } + + get totalSeconds() { + return Math.floor(this.totalMillis / 1000); + } + + get seconds() { + return Math.floor((this.totalMillis) / 1000 % 60); + } + + get totalMinutes() { + return Math.floor(this.totalMillis / 1000 / 60); + } + + get minutes() { + return Math.floor(this.totalMillis / 1000 / 60 % 60); + } + + get totalHours() { + return Math.floor(this.totalMillis / 1000 / 60 / 60); + } + + get hours() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 % 24); + } + + get totalDays() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24); + } + + get days() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 % 30.44); // Average days in a month + } + + get weekDays() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 % 7); + } + + get totalWeeks() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 7); + } + + get weeks() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 7 % 4.345); // Average weeks in a month + } + + get totalMonths() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 30.44); // Average days in a month + } + + get months() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 30.44 % 12); // Average days in a month + } + + get totalYears() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25); // Average days in a year + } + + get years() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25 % 1); // Average days in a year + } + + toString(includeWeeks = true, includeMillis = true) { + const parts = []; + if (this.totalYears >= 1) parts.push(`${this.totalYears} years`); + if (this.totalMonths >= 1) parts.push(`${this.months} months`); + if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks} weeks`); + if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days} days`); + if (this.totalHours >= 1) parts.push(`${this.hours} hours`); + if (this.totalMinutes >= 1) parts.push(`${this.minutes} minutes`); + if (this.totalSeconds >= 1) parts.push(`${this.seconds} seconds`); + if (includeMillis) parts.push(`${this.millis} milliseconds`); + return parts.join(", "); + } + + toShortString(includeWeeks = true, includeMillis = true, withSpaces = false) { + const parts = []; + if (this.totalYears >= 1) parts.push(`${this.totalYears}y`); + if (this.totalMonths >= 1) parts.push(`${this.months}mo`); + if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks}w`); + if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days}d`); + if (this.totalHours >= 1) parts.push(`${this.hours}h`); + if (this.totalMinutes >= 1) parts.push(`${this.minutes}m`); + if (this.totalSeconds >= 1) parts.push(`${this.seconds}s`); + if (includeMillis) parts.push(`${this.millis}ms`); + return parts.join(withSpaces ? " " : ""); + } + + get startDate() { + return new Date(this.start); + } + + get endDate() { + return new Date(this.end); + } + +} \ No newline at end of file diff --git a/timeSpan.test.js b/timeSpan.test.js new file mode 100644 index 0000000..dbf0f37 --- /dev/null +++ b/timeSpan.test.js @@ -0,0 +1,81 @@ +// noinspection ES6ConvertRequireIntoImport - this is CJS, not MJS + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { TimeSpan } = require("./timeSpan"); + +test("TimeSpan should be able to return zero", () => { + const ts = new TimeSpan(); + assert.equal(ts.totalMillis, 0); +}); + +test("TimeSpan should be able to return timespan from milliseconds", () => { + const ts = TimeSpan.fromMillis(1000); + assert.equal(ts.totalMillis, 1000); + assert.equal(ts.totalSeconds, 1); +}); + +test("TimeSpan should be able to return timespan from seconds", () => { + const ts = TimeSpan.fromSeconds(60); + assert.equal(ts.totalMillis, 60000); + assert.equal(ts.totalSeconds, 60); + assert.equal(ts.totalMinutes, 1); + assert.equal(ts.minutes, 1); + assert.equal(ts.hours, 0); + assert.equal(ts.days, 0); +}); + +test("TimeSpan should be pure", () => { + const count = 10; + const timestamps = []; + for (let i = 0; i < count; i++) { + timestamps.push(TimeSpan.fromMillis(8972347984)); + for (const ts2 of timestamps) { + assert.equal(ts2.totalMillis, 8972347984); + assert.equal(ts2.totalSeconds, 8972347); + assert.equal(ts2.totalMinutes, 149539); + assert.equal(ts2.totalHours, 2492); + assert.equal(ts2.totalDays, 103); + assert.equal(ts2.totalWeeks, 14); + assert.equal(ts2.totalMonths, 3); + assert.equal(ts2.totalYears, 0); + + assert.equal(ts2.millis, 984); + assert.equal(ts2.seconds, 7); + assert.equal(ts2.minutes, 19); + assert.equal(ts2.hours, 20); + assert.equal(ts2.days, 12); + assert.equal(ts2.weekDays, 5); + assert.equal(ts2.weeks, 1); + assert.equal(ts2.months, 3); + assert.equal(ts2.years, 0); + } + } +}); + +test("TimeSpan should be able to stringify", () => { + const ts = TimeSpan.fromMillis(8972347984); + assert.equal(ts.toString(), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds"); + assert.equal(ts.toString(true), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds"); + assert.equal(ts.toString(true, false), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds"); + assert.equal(ts.toString(false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds"); + assert.equal(ts.toString(false, false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds"); +}); + +test("TimeSpan should be able to shortStringify", () => { + const ts = TimeSpan.fromMillis(8972347984); + assert.equal(ts.toShortString(), "3mo1w5d20h19m7s984ms"); + assert.equal(ts.toShortString(true), "3mo1w5d20h19m7s984ms"); + assert.equal(ts.toShortString(true, false), "3mo1w5d20h19m7s"); + assert.equal(ts.toShortString(false), "3mo12d20h19m7s984ms"); + assert.equal(ts.toShortString(false, false), "3mo12d20h19m7s"); +}); + +test("TimeSpan should be able to shortStringify with spaces", () => { + const ts = TimeSpan.fromMillis(8972347984); + assert.equal(ts.toShortString(undefined, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms"); + assert.equal(ts.toShortString(true, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms"); + assert.equal(ts.toShortString(true, false, true), "3mo 1w 5d 20h 19m 7s"); + assert.equal(ts.toShortString(false, undefined, true), "3mo 12d 20h 19m 7s 984ms"); + assert.equal(ts.toShortString(false, false, true), "3mo 12d 20h 19m 7s"); +}); \ No newline at end of file