diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e2c55a2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,865 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = false +max_line_length = 150 +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 + +[*.blade.php] +indent_style = space +ij_blade_keep_indents_on_empty_lines = false + +[*.css] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_continuation_indent_size = 2 +ij_css_align_closing_brace_with_properties = false +ij_css_blank_lines_around_nested_selector = 0 +ij_css_blank_lines_between_blocks = 0 +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 = true +ij_css_hex_color_short_format = true +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 = true +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 + +[*.feature] +indent_size = 2 +indent_style = space +ij_gherkin_keep_indents_on_empty_lines = false + +[*.less] +indent_size = 2 +indent_style = space +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 + +[*.markdown] +indent_style = space +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 + +[*.properties] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false + +[*.sass] +indent_size = 2 +indent_style = space +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 +indent_style = space +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 + +[*.twig] +indent_style = space +ij_twig_keep_indents_on_empty_lines = false +ij_twig_spaces_inside_comments_delimiters = true +ij_twig_spaces_inside_delimiters = true +ij_twig_spaces_inside_variable_delimiters = true + +[*.vue] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_continuation_indent_size = 4 +ij_vue_indent_children_of_top_level = template +ij_vue_interpolation_new_line_after_start_delimiter = true +ij_vue_interpolation_new_line_before_end_delimiter = true +ij_vue_interpolation_wrap = off +ij_vue_keep_indents_on_empty_lines = false +ij_vue_spaces_within_interpolation_expressions = true + +[.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,phpunit.xml.dist}] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_continuation_indent_size = 2 +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}] +indent_style = space +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 +indent_style = space +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}] +max_line_length = 80 +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 = false +ij_javascript_align_multiline_parameters = false +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 = off +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 = always +ij_javascript_else_on_new_line = false +ij_javascript_enforce_trailing_comma = whenmultiline +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 = always +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 = always +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 = 1 +ij_javascript_keep_first_column_comment = false +ij_javascript_keep_indents_on_empty_lines = false +ij_javascript_keep_line_breaks = true +ij_javascript_keep_simple_blocks_in_one_line = true +ij_javascript_keep_simple_methods_in_one_line = true +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 = off +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 = false +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 = false +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 = always +ij_javascript_while_on_new_line = false +ij_javascript_wrap_comments = false + +[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}] +ij_continuation_indent_size = 4 +ij_smart_tabs = true +ij_php_align_assignments = true +ij_php_align_class_constants = true +ij_php_align_enum_cases = true +ij_php_align_group_field_declarations = true +ij_php_align_inline_comments = true +ij_php_align_key_value_pairs = true +ij_php_align_match_arm_bodies = true +ij_php_align_multiline_array_initializer_expression = true +ij_php_align_multiline_binary_operation = true +ij_php_align_multiline_chained_methods = true +ij_php_align_multiline_extends_list = true +ij_php_align_multiline_for = false +ij_php_align_multiline_parameters = true +ij_php_align_multiline_parameters_in_calls = false +ij_php_align_multiline_ternary_operation = false +ij_php_align_named_arguments = true +ij_php_align_phpdoc_comments = true +ij_php_align_phpdoc_param_names = true +ij_php_anonymous_brace_style = end_of_line +ij_php_api_weight = 1 +ij_php_array_initializer_new_line_after_left_brace = true +ij_php_array_initializer_right_brace_on_new_line = true +ij_php_array_initializer_wrap = on_every_item +ij_php_assignment_wrap = normal +ij_php_attributes_wrap = split_into_lines +ij_php_author_weight = 4 +ij_php_binary_operation_sign_on_next_line = false +ij_php_binary_operation_wrap = normal +ij_php_blank_lines_after_class_header = 1 +ij_php_blank_lines_after_function = 1 +ij_php_blank_lines_after_imports = 1 +ij_php_blank_lines_after_opening_tag = 1 +ij_php_blank_lines_after_package = 1 +ij_php_blank_lines_around_class = 1 +ij_php_blank_lines_around_constants = 1 +ij_php_blank_lines_around_enum_cases = 0 +ij_php_blank_lines_around_field = 0 +ij_php_blank_lines_around_method = 1 +ij_php_blank_lines_before_class_end = 1 +ij_php_blank_lines_before_imports = 0 +ij_php_blank_lines_before_method_body = 0 +ij_php_blank_lines_before_package = 0 +ij_php_blank_lines_before_return_statement = 1 +ij_php_blank_lines_between_imports = 1 +ij_php_block_brace_style = end_of_line +ij_php_call_parameters_new_line_after_left_paren = false +ij_php_call_parameters_right_paren_on_new_line = false +ij_php_call_parameters_wrap = on_every_item +ij_php_catch_on_new_line = false +ij_php_category_weight = 28 +ij_php_class_brace_style = end_of_line +ij_php_comma_after_last_argument = true +ij_php_comma_after_last_argument_style = when_multiline +ij_php_comma_after_last_array_element = true +ij_php_comma_after_last_closure_use_var = true +ij_php_comma_after_last_closure_use_var_style = when_multiline +ij_php_comma_after_last_match_arm = false +ij_php_comma_after_last_parameter = true +ij_php_comma_after_last_parameter_style = when_multiline +ij_php_concat_spaces = true +ij_php_copyright_weight = 5 +ij_php_deprecated_weight = 3 +ij_php_do_while_brace_force = always +ij_php_else_if_style = combine +ij_php_else_on_new_line = false +ij_php_example_weight = 28 +ij_php_extends_keyword_wrap = normal +ij_php_extends_list_wrap = on_every_item +ij_php_fields_default_visibility = private +ij_php_filesource_weight = 28 +ij_php_finally_on_new_line = false +ij_php_for_brace_force = always +ij_php_for_statement_new_line_after_left_paren = false +ij_php_for_statement_right_paren_on_new_line = false +ij_php_for_statement_wrap = normal +ij_php_force_empty_classes_in_one_line = true +ij_php_force_empty_methods_in_one_line = true +ij_php_force_short_declaration_array_style = true +ij_php_getters_setters_naming_style = camel_case +ij_php_getters_setters_order_style = getters_first +ij_php_global_weight = 9 +ij_php_group_use_wrap = on_every_item +ij_php_heredoc_on_same_line = true +ij_php_if_brace_force = always +ij_php_if_lparen_on_next_line = false +ij_php_if_rparen_on_next_line = true +ij_php_ignore_weight = 28 +ij_php_import_sorting = by_length +ij_php_indent_break_from_case = true +ij_php_indent_case_from_switch = true +ij_php_indent_code_in_php_tags = false +ij_php_internal_weight = 28 +ij_php_keep_blank_lines_after_lbrace = 0 +ij_php_keep_blank_lines_before_right_brace = 0 +ij_php_keep_blank_lines_in_code = 1 +ij_php_keep_blank_lines_in_declarations = 1 +ij_php_keep_control_statement_in_one_line = true +ij_php_keep_first_column_comment = false +ij_php_keep_indents_on_empty_lines = false +ij_php_keep_line_breaks = true +ij_php_keep_rparen_and_lbrace_on_one_line = true +ij_php_keep_simple_classes_in_one_line = true +ij_php_keep_simple_methods_in_one_line = true +ij_php_lambda_brace_style = end_of_line +ij_php_license_weight = 28 +ij_php_line_comment_add_space = false +ij_php_line_comment_at_first_column = true +ij_php_link_weight = 8 +ij_php_lower_case_boolean_const = true +ij_php_lower_case_keywords = true +ij_php_lower_case_null_const = false +ij_php_method_brace_style = end_of_line +ij_php_method_call_chain_wrap = on_every_item +ij_php_method_parameters_new_line_after_left_paren = true +ij_php_method_parameters_right_paren_on_new_line = true +ij_php_method_parameters_wrap = on_every_item +ij_php_method_weight = 15 +ij_php_modifier_list_wrap = false +ij_php_multiline_chained_calls_first_call_on_new_line = true +ij_php_multiline_chained_calls_semicolon_on_new_line = false +ij_php_multiline_closure_lambda_on_new_line = false +ij_php_namespace_brace_style = 1 +ij_php_new_line_after_php_opening_tag = true +ij_php_null_type_position = in_the_end +ij_php_package_weight = 0 +ij_php_param_weight = 10 +ij_php_parameters_attributes_wrap = off +ij_php_parentheses_expression_new_line_after_left_paren = true +ij_php_parentheses_expression_right_paren_on_new_line = true +ij_php_phpdoc_blank_line_before_tags = true +ij_php_phpdoc_blank_lines_around_parameters = true +ij_php_phpdoc_keep_blank_lines = true +ij_php_phpdoc_param_spaces_between_name_and_description = 2 +ij_php_phpdoc_param_spaces_between_tag_and_type = 3 +ij_php_phpdoc_param_spaces_between_type_and_name = 2 +ij_php_phpdoc_use_fqcn = true +ij_php_phpdoc_wrap_long_lines = true +ij_php_place_assignment_sign_on_next_line = false +ij_php_place_parens_for_constructor = 1 +ij_php_property_read_weight = 28 +ij_php_property_weight = 28 +ij_php_property_write_weight = 28 +ij_php_return_type_on_new_line = false +ij_php_return_weight = 11 +ij_php_see_weight = 7 +ij_php_since_weight = 2 +ij_php_sort_phpdoc_elements = true +ij_php_space_after_colon = true +ij_php_space_after_colon_in_enum_backed_type = true +ij_php_space_after_colon_in_named_argument = true +ij_php_space_after_colon_in_return_type = true +ij_php_space_after_comma = true +ij_php_space_after_for_semicolon = true +ij_php_space_after_quest = true +ij_php_space_after_type_cast = true +ij_php_space_after_unary_not = false +ij_php_space_before_array_initializer_left_brace = false +ij_php_space_before_catch_keyword = true +ij_php_space_before_catch_left_brace = true +ij_php_space_before_catch_parentheses = false +ij_php_space_before_class_left_brace = true +ij_php_space_before_closure_left_parenthesis = false +ij_php_space_before_colon = true +ij_php_space_before_colon_in_enum_backed_type = false +ij_php_space_before_colon_in_named_argument = false +ij_php_space_before_colon_in_return_type = false +ij_php_space_before_comma = false +ij_php_space_before_do_left_brace = true +ij_php_space_before_else_keyword = true +ij_php_space_before_else_left_brace = true +ij_php_space_before_finally_keyword = true +ij_php_space_before_finally_left_brace = true +ij_php_space_before_for_left_brace = true +ij_php_space_before_for_parentheses = false +ij_php_space_before_for_semicolon = false +ij_php_space_before_if_left_brace = true +ij_php_space_before_if_parentheses = false +ij_php_space_before_method_call_parentheses = false +ij_php_space_before_method_left_brace = true +ij_php_space_before_method_parentheses = false +ij_php_space_before_quest = false +ij_php_space_before_short_closure_left_parenthesis = false +ij_php_space_before_switch_left_brace = true +ij_php_space_before_switch_parentheses = false +ij_php_space_before_try_left_brace = true +ij_php_space_before_unary_not = false +ij_php_space_before_while_keyword = true +ij_php_space_before_while_left_brace = true +ij_php_space_before_while_parentheses = false +ij_php_space_between_ternary_quest_and_colon = true +ij_php_spaces_around_additive_operators = true +ij_php_spaces_around_arrow = false +ij_php_spaces_around_assignment_in_declare = false +ij_php_spaces_around_assignment_operators = true +ij_php_spaces_around_bitwise_operators = false +ij_php_spaces_around_equality_operators = true +ij_php_spaces_around_logical_operators = true +ij_php_spaces_around_multiplicative_operators = true +ij_php_spaces_around_null_coalesce_operator = true +ij_php_spaces_around_pipe_in_union_type = false +ij_php_spaces_around_relational_operators = true +ij_php_spaces_around_shift_operators = true +ij_php_spaces_around_unary_operator = false +ij_php_spaces_around_var_within_brackets = false +ij_php_spaces_within_array_initializer_braces = false +ij_php_spaces_within_brackets = false +ij_php_spaces_within_catch_parentheses = false +ij_php_spaces_within_for_parentheses = false +ij_php_spaces_within_if_parentheses = false +ij_php_spaces_within_method_call_parentheses = false +ij_php_spaces_within_method_parentheses = false +ij_php_spaces_within_parentheses = false +ij_php_spaces_within_short_echo_tags = true +ij_php_spaces_within_switch_parentheses = false +ij_php_spaces_within_while_parentheses = false +ij_php_special_else_if_treatment = true +ij_php_subpackage_weight = 28 +ij_php_ternary_operation_signs_on_next_line = true +ij_php_ternary_operation_wrap = normal +ij_php_throws_weight = 12 +ij_php_todo_weight = 14 +ij_php_treat_multiline_arrays_and_lambdas_multiline = false +ij_php_unknown_tag_weight = 28 +ij_php_upper_case_boolean_const = false +ij_php_upper_case_null_const = true +ij_php_uses_weight = 28 +ij_php_var_weight = 13 +ij_php_variable_naming_style = camel_case +ij_php_version_weight = 6 +ij_php_while_brace_force = always +ij_php_while_on_new_line = false + +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,composer.lock,jest.config}] +ij_continuation_indent_size = 4 +ij_smart_tabs = true +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 = true + +[{*.htm,*.html,*.sht,*.shtm,*.shtml}] +ij_continuation_indent_size = 4 +ij_smart_tabs = true +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 + +[{*.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 new file mode 100644 index 0000000..d363631 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +API_KEY= +API_URL= +API_VERSION= +CACHE_DIRECTORY= +CACHE_LIFETIME= +CACHE_ENABLED=1 +ITERATE_MAX_PAGES=100 +APP_ENV= +DEBUG=0 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8150594 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,7 @@ +# These are supported funding model platforms + +github: [Gokujo] +ko_fi: devcraft +thanks_dev: gh/gokujo +buy_me_a_coffee: maharder +custom: ['https://paypal.me/MaximH', 'https://yoomoney.ru/to/41001454367103', 'https://dalink.to/maharder'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e275786 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,73 @@ +name: Ошибка +description: Сообщить об ошибке / баге в библиотеке +title: "[Ошибка]: " +labels: + - Ошибка + - bug +assignees: + - Gokujo +body: + - type: markdown + attributes: + value: | + Спасибо, что нашли время, чтобы составить этот отчёт + - type: checkboxes + id: read-rules + attributes: + label: Пользовательское соглашение + description: Перед просьбой добавить или "починить" что-либо в срочном порядке - [нужно прочесть соглашение](https://devcraft.club/pages/licence-agreement/). + options: + - label: Я прочёл / прочла соглашение и согласен / согласна с ними + required: true + - type: textarea + id: what-happened + attributes: + label: В чём проблема? + description: Опиши проблему и шаги воспроизведения. + placeholder: Что произошло и как это повторить + validations: + required: true + - type: textarea + id: what-should-happen + attributes: + label: Что ожидали? + description: Ожидаемое поведение + validations: + required: false + - type: input + id: library-version + attributes: + label: Версия библиотеки / пакета + description: Версия из Composer / Packagist / CHANGELOG + placeholder: например 1.2.3 + validations: + required: true + - type: dropdown + id: php-version + attributes: + label: Версия PHP + description: На какой версии PHP воспроизводится? + options: + - "8.5" + - "8.4" + - "8.3 (Default)" + - "8.2" + - "другое" + validations: + required: true + - type: input + id: composer-version + attributes: + label: Версия Composer (опционально) + description: Вывод `composer --version` + validations: + required: false + - type: textarea + id: screenshots + attributes: + label: Скриншоты / логи + description: Трейсы, логи, скриншоты + value: | + ![DESCRIPTION](LINK.png) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.yml b/.github/ISSUE_TEMPLATE/bug_report_en.yml new file mode 100644 index 0000000..d3e6445 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_en.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Report a bug in the library +title: "[Bug]: " +labels: + - bug +assignees: + - Gokujo +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file this report. + - type: checkboxes + id: read-rules + attributes: + label: Licence agreement + description: Please read the [licence agreement](https://devcraft.club/pages/licence-agreement/) before requesting urgent fixes. + options: + - label: I have read and agree to the licence agreement + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the bug and steps to reproduce. + placeholder: Steps and observed behaviour + validations: + required: true + - type: textarea + id: what-should-happen + attributes: + label: Expected behaviour + validations: + required: false + - type: input + id: library-version + attributes: + label: Package / library version + description: Version from Composer / Packagist / CHANGELOG + placeholder: e.g. 1.2.3 + validations: + required: true + - type: dropdown + id: php-version + attributes: + label: PHP version + options: + - "8.5" + - "8.4" + - "8.3 (Default)" + - "8.2" + - "other" + validations: + required: true + - type: input + id: composer-version + attributes: + label: Composer version (optional) + description: Output of `composer --version` + validations: + required: false + - type: textarea + id: screenshots + attributes: + label: Screenshots / logs + value: | + ![DESCRIPTION](LINK.png) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b53f651 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://readme.devcraft.club/ + about: DevCraft documentation site. + - name: Licence agreement + url: https://devcraft.club/pages/licence-agreement/ + about: Licence agreement before support requests. diff --git a/.github/ISSUE_TEMPLATE/custom.yml b/.github/ISSUE_TEMPLATE/custom.yml new file mode 100644 index 0000000..cb404c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.yml @@ -0,0 +1,53 @@ +name: Другие запросы +description: Если не подходит баг или feature — этот тикет +title: "[Прочее]: " +labels: + - Прочее +assignees: + - Gokujo +body: + - type: markdown + attributes: + value: | + Спасибо, что нашли время, чтобы составить этот отчёт + - type: checkboxes + id: read-rules + attributes: + label: Пользовательское соглашение + description: Перед просьбой добавить или "починить" что-либо в срочном порядке - [нужно прочесть соглашение](https://devcraft.club/pages/licence-agreement/). + options: + - label: Я прочёл / прочла соглашение и согласен / согласна с ними + required: true + - type: textarea + id: what-do-you-need + attributes: + label: В чём соль? + description: Опиши запрос как можно детальнее. + validations: + required: true + - type: input + id: library-version + attributes: + label: Версия библиотеки / пакета (если применимо) + validations: + required: false + - type: dropdown + id: php-version + attributes: + label: Версия PHP + options: + - "8.5" + - "8.4" + - "8.3 (Default)" + - "8.2" + - "другое" + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Скриншоты и файлы + value: | + ![DESCRIPTION](LINK.png) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/custom_en.yml b/.github/ISSUE_TEMPLATE/custom_en.yml new file mode 100644 index 0000000..d875e58 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom_en.yml @@ -0,0 +1,52 @@ +name: Other +description: If it is not a bug or feature request +title: "[Other]: " +labels: + - question +assignees: + - Gokujo +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to open this issue. + - type: checkboxes + id: read-rules + attributes: + label: Licence agreement + description: Please read the [licence agreement](https://devcraft.club/pages/licence-agreement/). + options: + - label: I have read and agree to the licence agreement + required: true + - type: textarea + id: what-do-you-need + attributes: + label: What do you need? + validations: + required: true + - type: input + id: library-version + attributes: + label: Package / library version (if applicable) + validations: + required: false + - type: dropdown + id: php-version + attributes: + label: PHP version + options: + - "8.5" + - "8.4" + - "8.3 (Default)" + - "8.2" + - "other" + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots / files + value: | + ![DESCRIPTION](LINK.png) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..51f83fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,56 @@ +name: Запрос на новый функционал +description: Опиши желаемую возможность библиотеки +title: "[Запрос]: " +labels: + - Запрос на функционал + - enhancement +assignees: + - Gokujo +body: + - type: markdown + attributes: + value: | + Спасибо, что нашли время, чтобы составить этот отчёт + - type: checkboxes + id: read-rules + attributes: + label: Пользовательское соглашение + description: Перед просьбой добавить или "починить" что-либо в срочном порядке - [нужно прочесть соглашение](https://devcraft.club/pages/licence-agreement/). + options: + - label: Я прочёл / прочла соглашение и согласен / согласна с ними + required: true + - type: textarea + id: what-do-you-need + attributes: + label: В чём соль? + description: Опиши желаемый функционал как можно детальнее. + placeholder: Что хочешь добавить или изменить + validations: + required: true + - type: input + id: library-version + attributes: + label: Версия библиотеки / пакета (если применимо) + placeholder: например 1.2.3 + validations: + required: false + - type: dropdown + id: php-version + attributes: + label: Версия PHP + options: + - "8.5" + - "8.4" + - "8.3 (Default)" + - "8.2" + - "другое" + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Скриншоты и файлы + value: | + ![DESCRIPTION](LINK.png) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.yml b/.github/ISSUE_TEMPLATE/feature_request_en.yml new file mode 100644 index 0000000..8176e9a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request_en.yml @@ -0,0 +1,54 @@ +name: Feature request +description: Suggest a new library capability +title: "[Feature]: " +labels: + - enhancement +assignees: + - Gokujo +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file this request. + - type: checkboxes + id: read-rules + attributes: + label: Licence agreement + description: Please read the [licence agreement](https://devcraft.club/pages/licence-agreement/) before requesting urgent changes. + options: + - label: I have read and agree to the licence agreement + required: true + - type: textarea + id: what-do-you-need + attributes: + label: What do you need? + description: Describe the desired feature in detail. + validations: + required: true + - type: input + id: library-version + attributes: + label: Package / library version (if applicable) + placeholder: e.g. 1.2.3 + validations: + required: false + - type: dropdown + id: php-version + attributes: + label: PHP version + options: + - "8.5" + - "8.4" + - "8.3 (Default)" + - "8.2" + - "other" + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots / files + value: | + ![DESCRIPTION](LINK.png) + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1615a44 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,61 @@ +## Создайте заголовок для этого Pull Request + +### Описание + +... Дайте ревьюверу общее представление о проделанной работе и объясните, почему были внесены эти изменения – **ПОЧЕМУ** данное изменение необходимо. Это поле должно предоставлять более глубокий контекст с точки зрения продукта. ... + +... Подробно опишите, как именно была реализована данная задача. Здесь может быть представлена техническая информация. Какие конкретные шаги предприняты для достижения цели? Следует указать детали интеграции сервисов, логику работы, особенности реализации и другие технические аспекты. ... + +### Изменения в коде + +... Здесь следует сосредоточиться на технической стороне реализации. Укажите, какие именно изменения были внесены: добавленный или изменённый функционал, рефакторинг, улучшение существующей структуры кода и т. д. ... + +#### Внесённые изменения за пределами кода + +... Если были изменены внешние сервисы, добавлены новые параметры в настройки задач или внесены изменения в базу данных, опишите это здесь. Сюда также можно включить обновления сторонних сервисов, изменения в инфраструктуре или интеграцию с внешними API. ... + +### Дополнительная информация + +... Добавьте любую дополнительную информацию, которая может быть полезна ревьюеру при оценке данного Pull Request. Это могут быть соображения по производительности, архитектурные решения и т. д. ... + +### Issues + +Fixes #(Issue) + +### Тип изменений + +Удалите неподходящие варианты. + +- [ ] Исправление ошибки (обратная совместимость, исправление проблемы) +- [ ] Новая функциональность (обратная совместимость, добавление новой функции) +- [ ] Изменение, нарушающее обратную совместимость (исправление или новый функционал, который может сломать существующую логику) +- [ ] Эти изменения требуют обновления документации + +### Контрольный список: + +Удалите неподходящие варианты. + +- [ ] Мой код соответствует стилевым правилам проекта +- [ ] Я провёл самопроверку своего кода +- [ ] Я добавил комментарии в код, особенно в сложных для понимания местах +- [ ] Я обновил соответствующую документацию +- [ ] Мои изменения не вызывают новых предупреждений +- [ ] Я добавил тесты, доказывающие работоспособность исправления или новой функциональности +- [ ] Все новые и существующие unit-тесты успешно проходят локальную проверку +- [ ] Все зависимые изменения были объединены и опубликованы в соответствующих модулях +- [ ] Я проверил код на наличие орфографических ошибок + +### Reviewers + +- Maxim +- Gokujo + +### Labels / Release + +Immer automatisch: `Automatic`, `DevBot CI`, `Publish`. +Forgejo-Labels werden gespiegelt (`Publish/Github` nicht). +**Release:** Label `releases/vX.Y.Z` → Tag + Packagist **erst nach Merge dieses PRs**. + +### Changelog + +Vor Release: Eintrag in `CHANGELOG.md` unter der Version (Keep a Changelog). diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml new file mode 100644 index 0000000..4708065 --- /dev/null +++ b/.github/workflows/packagist.yml @@ -0,0 +1,22 @@ +name: Packagist + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + notify-packagist: + runs-on: ubuntu-latest + steps: + - name: Notify Packagist + env: + PACKAGIST_USERNAME: ${{ secrets.COMPOSER_USERNAME }} + PACKAGIST_TOKEN: ${{ secrets.COMPOSER_UPDATE_API_KEY }} + run: | + test -n "$PACKAGIST_USERNAME" && test -n "$PACKAGIST_TOKEN" + curl --fail-with-body -sS -XPOST \ + -H 'content-type:application/json' \ + "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \ + -d "{\"repository\":{\"url\":\"https://github.com/${{ github.repository }}\"}}" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..e11b8fd --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,55 @@ +name: Release tag + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + tag: + if: > + github.event.pull_request.merged == true && + contains(join(github.event.pull_request.labels.*.name, ','), 'Publish') && + contains(join(github.event.pull_request.labels.*.name, ','), 'releases/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + fetch-tags: true + + - name: Create tag from releases/v* label + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TAG="$(jq -r '[.pull_request.labels[].name | select(startswith("releases/v"))][0] // empty | sub("^releases/"; "")' "$GITHUB_EVENT_PATH")" + if [ -z "$TAG" ]; then + echo "No releases/v* label found" + exit 1 + fi + case "$TAG" in + v*) ;; + *) echo "Tag must start with v, got: $TAG"; exit 1 ;; + esac + + SHA="${{ github.event.pull_request.merge_commit_sha }}" + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + EXISTING="$(git rev-list -n 1 "refs/tags/${TAG}")" + if [ "$EXISTING" = "$SHA" ]; then + echo "Tag ${TAG} already points at ${SHA} — skip" + exit 0 + fi + echo "Tag ${TAG} exists at ${EXISTING}, refusing to move to ${SHA}" + exit 1 + fi + + git config user.email "ci@devcraft.club" + git config user.name "DevBot CI" + git tag -a "$TAG" -m "Release ${TAG}" "$SHA" + git push origin "refs/tags/${TAG}" + echo "Created tag ${TAG} at ${SHA}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd4ff1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,540 @@ +# ============================================================================== +# 1. Операционные системы (OS) +# ============================================================================== +### macOS ### +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Windows ### +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk + +### Linux ### +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +# ============================================================================== +# 2. ИИ-инструменты, ассистенты и остатки LLM +# ============================================================================== +### AI / Copilots / IDE-плагины ### +.claude/ +.cursor/ +.cursor-server/ +.continue/ +.codeium/ +.tabnine/ +.cody/ +*.chatgpt +chatgpt_history/ +.aix/ +.copilot/ +github-copilot-* + +# ============================================================================== +# 3. IDE и редакторы +# ============================================================================== +### JetBrains (IntelliJ, PyCharm, WebStorm, Android Studio и др.) ### +.idea/ +*.iml +*.ipr +*.iws +out/ +cmake-build-*/ +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### Visual Studio Code ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +*.code-workspace +.history/ +*.vsix +.vscode-test/ + +### Eclipse ### +.metadata +.settings/ +.loadpath +.recommenders/ +.externalToolBuilders/ +*.launch +*.pydevproject +.cproject +.autotools +.factorypath +.buildpath +.target +.tern-project +.texlipse +.springBeans +.apt_generated/ +.apt_generated_test/ +.cache-main +.scala_dependencies +.worksheet +.sts4-cache/ +.project +.classpath + +### Sublime Text ### +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +sftp-config.json +sftp-config-alt*.json +Package Control.* +GitHub.sublime-settings +*.sublime* + +### NetBeans ### +**/nbproject/private/ +**/nbproject/Makefile-*.mk +**/nbproject/Package-*.bash +nbbuild/ +nbdist/ +.nb-gradle/ + +# ============================================================================== +# 4. Языки и фреймворки +# ============================================================================== +### Python ### +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +*.mo +*.pot +local_settings.py +db.sqlite3 +db.sqlite3-journal +instance/ +.webassets-cache +.scrapy +docs/_build/ +.pybuilder/ +.ipynb_checkpoints +profile_default/ +ipython_config.py +.pdm.toml +.pdm-python +.pdm-build/ +__pypackages__/ +celerybeat-schedule +celerybeat.pid +*.sage.py +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.env +.spyderproject +.spyproject +.ropeproject +/site +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +cython_debug/ +poetry.toml +.ruff_cache/ +pyrightconfig.json + +### Node.js / React / Vue / JS-экосистема ### +node_modules/ +jspm_packages/ +web_modules/ +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +pids/ +*.pid +*.seed +*.pid.lock +lib-cov/ +coverage/ +*.lcov +.nyc_output/ +.grunt/ +bower_components/ +.lock-wscript +build/Release/ +typings/ +*.tsbuildinfo +.npm/ +.eslintcache +.stylelintcache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ +.node_repl_history +*.tgz +.yarn-integrity +.env.development.local +.env.test.local +.env.production.local +.env.local +.cache/ +.parcel-cache/ +.next/ +out/ +.nuxt/ +.vuepress/dist/ +.temp/ +.docusaurus/ +.serverless/ +.fusebox/ +.dynamodb/ +.tern-port +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +psd/ +thumb/ +sketch/ + +### Java / Android ### +*.class +*.apk +*.aar +*.ap_ +*.aab +*.dex +bin/ +gen/ +local.properties +proguard/ +.navigation/ +captures/ +.externalNativeBuild/ +.cxx/ +freeline.py +freeline/ +freeline_project_description.json +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ +fastlane/readme.md +vcs.xml +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +gen-external-apklibs/ +output.json +*.ctxt +.mtj.tmp/ +*.jar +*.war +*.nar +*.ear +hs_err_pid* +replay_pid* + +### PHP / Composer / Symfony / CakePHP ### +composer.phar +/vendor/ +/app/cache/* +/app/logs/* +!app/cache/.gitkeep +!app/logs/.gitkeep +/app/spool/* +/var/cache/* +/var/logs/* +/var/sessions/* +!var/cache/.gitkeep +!var/logs/.gitkeep +!var/sessions/.gitkeep +/var/log/* +!var/log/.gitkeep +/app/config/parameters.yml +/app/config/parameters.ini +/app/bootstrap.php.cache +/var/bootstrap.php.cache +/bin/* +!bin/console +!bin/symfony_requirements +/web/bundles/ +/web/uploads/ +/app/phpunit.xml +/phpunit.xml +**/Entity/*~ +/.web-server-pid +/config/app.php +/tmp/cache/models/* +!/tmp/cache/models/empty +/tmp/cache/persistent/* +!/tmp/cache/persistent/empty +/tmp/cache/views/* +!/tmp/cache/views/empty +/tmp/sessions/* +!/tmp/sessions/empty +/tmp/tests/* +!/tmp/tests/empty +/logs/* +!/logs/empty +/app/tmp/* +/app/Config/core.php +/app/Config/database.php +/vendors/* + +### C / C++ / CMake ### +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles/ +CMakeScripts/ +Testing/ +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps/ +CMakeUserPresets.json + +### NW.js ### +credits.html +locales/ +libEGL.dll +libGLEv2.dll +node.dll +nw.dll +nw.exe +natives_blob.bin +nw_100_percent.pak +nw_200_percent.pak +nw_elf.dll +snapshot_blob.bin +resources.pak +d3dcompiler_47.dll +ffmpeg.dll +icudtl.dat +pnacl/ +chromedriver.exe +nacl_irt_x86_64.nexe +nwjc.exe +payload.exe + +### GitBook / CMS ### +_book/ +*.epub +*.mobi +*.pdf + +# ============================================================================== +# 5. Сборка и DevOps +# ============================================================================== +### Gradle ### +.gradle/ +**/build/ +!src/**/build/ +gradle-app.setting +!gradle-wrapper.jar +!gradle-wrapper.properties +.gradletasknamecache + +### Maven ### +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +### JBoss ### +jboss/server/all/deploy/project.ext +jboss/server/default/deploy/project.ext +jboss/server/minimal/deploy/project.ext +jboss/server/all/log/*.log +jboss/server/all/tmp/**/* +jboss/server/all/data/**/* +jboss/server/all/work/**/* +jboss/server/default/log/*.log +jboss/server/default/tmp/**/* +jboss/server/default/data/**/* +jboss/server/default/work/**/* +jboss/server/minimal/log/*.log +jboss/server/minimal/tmp/**/* +jboss/server/minimal/data/**/* +jboss/server/minimal/work/**/* +*.DEPLOYED + +### Turbo ### +.turbo/ + +# ============================================================================== +# 6. Разное (архивы, логи, резервные копии и т.д.) +# ============================================================================== +### Git: резервные копии и diff ### +*.orig +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt +*.patch +*.diff +*.gho +*.ori +*.bak +*.swp +*.tmp +*~.nib + +### Безопасность / ключи ### +*.ppk + +### Архивы ### +*.7z +*.rar +*.zip +*.gz +*.gzip +*.bzip +*.bzip2 +*.bz2 +*.xz +*.lzma +*.xar +*.zst +*.tzst +*.iso +*.tar +*.dmg +*.xpi +*.gem +*.deb +*.rpm +*.txz + +### Office: временные файлы ### +~$*.doc* +Backup of *.doc* +~$*.xls* +*.xlk +~$*.ppt* +*.~vsd* + +# ============================================================================== +# 7. DevCraft / DLE Plugin +# ============================================================================== +### DevCraft (локальная разработка) ### +!devcraft/ +!devcraft/src/ +!devcraft/src/* +devcraft/vendor/ +devcraft/composer.phar +devcraft/logs/* +devcraft/cache/* +devcraft/.composer/* + +### DevCraft в upload/ (структура установки DLE) ### +!upload/devcraft/ +!upload/devcraft/src/ +!upload/devcraft/src/* +upload/devcraft/vendor/ +upload/devcraft/composer.phar +upload/devcraft/logs/* +upload/devcraft/cache/* +upload/devcraft/.composer/* + +### Артефакты сборки (create_install_archive.sh) ### +install_*.zip + +composer.lock +vendor/ +cache/ + +.phpunit.result.cache/ +.phpunit.cache/ +.phpunit/ +*.code-workspace \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be34873 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 + +### Added + +- Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 7286b17..0353215 100644 --- a/README.md +++ b/README.md @@ -1 +1,140 @@ -# Webshare-Proxy +# WebShare Proxy API Wrapper + +PHP wrapper for the [WebShare Proxy API](https://apidocs.webshare.io) with rate-limit retries, GET response caching, filtering, pagination iterators, and typed request helpers. + +Domain terms: see [CONTEXT.md](CONTEXT.md). + +## Requirements + +- PHP >= 8.3 +- Composer + +## Installation + +```bash +composer require devcraft/webshare-io-api-wrapper +``` + +## Configuration + +```bash +export API_KEY="your_webshare_api_key" +``` + +Or `.env`: + +``` +API_KEY=your_webshare_api_key +API_URL=https://proxy.webshare.io/api/ +API_VERSION=v2 +CACHE_ENABLED=1 +CACHE_LIFETIME=3600 +CACHE_DIRECTORY=./cache +ITERATE_MAX_PAGES=100 +DEBUG=0 +``` + +## Quick start + +```php +query() + ->withFilter('mode', 'direct') + ->withPageSize(25) + ->withFilter('country_code', ['US', 'DE'], FilterOperator::IN); + +$list = $proxies->getList(); + +foreach ($proxies->iterate() as $proxy) { + echo $proxy->getConnectionString(backbone: true), "\n"; +} +``` + +`App::Client()` returns the shared HTTP client. `App::Requests('Profile'|'Notification'|…)` auto-discovers request classes. + +## Request surface + +| App::Requests key | Coverage | +|-------------------|----------| +| `Proxy` | list, iterate, download, refresh | +| `Profile` | profile + preferences | +| `Notification` | list/retrieve/dismiss/restore | +| `ProxyReplacement` | list/retrieve/create (v3) | +| `ReplacedProxy` | list/download | +| `ProxyConfig` | config/status/listStats/update (v3) | +| `IpAuthorization` | list/iterate/CRUD + whatsMyIp | +| `ProxyStats` | list/aggregate/activities/download | +| `DownloadToken` | get/reset token by scope | +| `ApiKey` | list/iterate/CRUD | +| `SubUser` | list/iterate/CRUD/refresh + `masqueradeAs()` | + +**Proxy Connection** is not a REST resource — use `Proxy::getConnectionString()` (backbone/direct). See CONTEXT.md. + +## Caching + +Successful **GET** responses are cached (`CACHE_LIFETIME`). Mutations invalidate the resource tag. Paths containing `/download/` are never cached. Bypass with `forceRefresh: true` on `request()` / `Client::send()`, or `CACHE_ENABLED=0`. + +## Filtering, ordering & search + +```php +$proxies->query() + ->withFilter('country_code', ['US', 'DE'], FilterOperator::IN) + ->withFilter('created_at', '2024-01-01', FilterOperator::GT) + ->withOrderBy('-created_at', 'country_code') + ->withSearch('residential'); +``` + +| Operator | API syntax | +|----------|------------| +| `EQUAL` | `?field=value` | +| `IN` | `?field__in=a,b` | +| `GT` / `LT` | `?field__gt=` / `__lt=` | +| `CONTAINS` | `?field__contains=` | + +## Pagination + +Manual pages via `withPage` / `withStartingAfter`, or `iterate()` / `PageIterator::items()` (default max pages from `ITERATE_MAX_PAGES`, default 100). + +## Sub-user masquerade + +```php +App::Requests('SubUser')->masqueradeAs(7); +// subsequent Client calls send X-Subuser: 7 +App::Requests('SubUser')->masqueradeAs(null); +``` + +## Error handling + +```php +use Devcraft\Webshare\Exceptions\ApiException; +use Devcraft\Webshare\Exceptions\RateLimitException; + +try { + App::Requests('Proxy')->query()->withFilter('mode', 'direct'); + App::Requests('Proxy')->getList(); +} catch (RateLimitException $e) { + echo "Retry after {$e->retryAfterSeconds}s\n"; +} catch (ApiException $e) { + foreach ($e->getValidationErrors() as $field => $errors) { + foreach ($errors as $error) { + echo "$field: {$error['message']}\n"; + } + } +} +``` + +429/5xx: up to 3 retries with exponential backoff; `Retry-After` preferred; `x-ratelimit-reset` timestamps are converted and clamped (1–120s). + +## License + +AGPL-3.0-or-later — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d376da8 --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "devcraftclub/webshare-io-api-wrapper", + "description": "PHP wrapper for the WebShare Proxy API with rate limiting, filtering, and error handling", + "version": "1.0.0", + "type": "library", + "license": "AGPL-3.0-or-later", + "keywords": [ + "webshare", + "proxy", + "api", + "wrapper" + ], + "homepage": "https://github.com/DevCraftClub/Webshare-Proxy", + "support": { + "email": "dev@devcraft.club", + "issues": "https://github.com/DevCraftClub/Webshare-Proxy/issues", + "forum": "https://devcraft.club", + "docs": "https://readme.devcraft.club/dev/api_wrappers/webshare-proxy/1.0.0/guides/english/" + }, + "autoload": { + "psr-4": { + "Devcraft\\Webshare\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Devcraft\\Webshare\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Maxim Harder", + "email": "dev@devcraft.club", + "role": "developer", + "homepage": "https://devcraft.club" + }, + { + "name": "DevCraft", + "email": "admin@devcraft.club", + "role": "company", + "homepage": "https://devcraft.club" + } + ], + "minimum-stability": "stable", + "require": { + "php": ">=8.3", + "guzzlehttp/guzzle": "^7.0 || ^8.0", + "vlucas/phpdotenv": "^5.6", + "marcin-orlowski/lombok-php": "^1.2", + "psr/log": "^3.0", + "analog/analog": "^1.0", + "symfony/cache": "^8.1", + "devcraftclub/dev-tools": "^1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..db9838b --- /dev/null +++ b/manifest.json @@ -0,0 +1,4 @@ +{ + "version": "1.0.0", + "name": "webshare-io-api-wrapper" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6f31bec --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Abstracts/AbstractBasicClass.php b/src/Abstracts/AbstractBasicClass.php new file mode 100644 index 0000000..11cc39d --- /dev/null +++ b/src/Abstracts/AbstractBasicClass.php @@ -0,0 +1,9 @@ +endpoint = $endpoint; + $this->queryBuilder = (new QueryBuilder())->withOptionalFields($parameters)->withRequiredFields($requiredFields); + } + + public function query(): QueryBuilder { + return $this->queryBuilder; + } + + public function updateQuery(QueryBuilder $queryBuilder): void { + $this->queryBuilder = $queryBuilder; + } + + public function setEndpoint(string $endpoint): void { + $this->endpoint = $endpoint; + } + + public function withEndpoint(string $endpoint): self { + $this->endpoint = $endpoint; + + return $this; + } + + public function getEndpoint(): string { + return $this->endpoint; + } + + /** + * @throws \JsonException + */ + public function request( + RequestMethod $method = RequestMethod::GET, + ?string $additionalEndpoint = NULL, + ?RequestBody $body = NULL, + ?string $apiVersion = NULL, + bool $forceRefresh = false, + ): string { + $endpoint = $this->endpoint; + if($additionalEndpoint !== NULL) { + $endpoint = rtrim($endpoint, '/') . '/' . $additionalEndpoint; + } + + $apiVersion ??= App::Client()->getApiVersion(); + + return $this->defineClient($apiVersion)->send($endpoint, $method, $this->queryBuilder, $body, $forceRefresh); + } + + private function defineClient(string $apiVersion): Client { + $client = App::Client(); + if($apiVersion === $client->getApiVersion()) { + return $client; + } + + return App::newClient(version: $apiVersion); + } + +} diff --git a/src/Classes/App.php b/src/Classes/App.php new file mode 100644 index 0000000..6247e73 --- /dev/null +++ b/src/Classes/App.php @@ -0,0 +1,207 @@ + self::loadRequests(), + ttl : 86400, + ); + + $requestClass = $requests[$requestType] ?? NULL; + + if($requestClass === NULL) { + return NULL; + } + + return new $requestClass(); + } + + public static function cacheEnabled(): bool { + self::init(); + + return self::$cacheEnabled; + } + + public static function cacheLifetime(): int { + self::init(); + + return self::$cacheLifetime; + } + + public static function iterateMaxPages(): int { + self::init(); + + return self::$iterateMaxPages; + } + + public static function Debug(): bool { + return self::$debug; + } + + private static function env(string $key, mixed $default = NULL): mixed { + if(array_key_exists($key, $_ENV)) { + return $_ENV[$key]; + } + + $value = getenv($key); + + return $value === false ? $default : $value; + } + + /** + * @throws \ReflectionException + */ + private static function loadRequests(): array { + self::init(); + + $requests = []; + $requestsDir = rtrim(dirname(__FILE__, 2), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'Requests'; + + if(!is_dir($requestsDir)) { + throw new \RuntimeException("Requests directory not found: {$requestsDir}"); + } + + $dirIterator = new RecursiveDirectoryIterator($requestsDir, FilesystemIterator::SKIP_DOTS); + $iterator = new RecursiveIteratorIterator($dirIterator); + $phpFiles = new RegexIterator($iterator, '/^.+\.php$/i', RegexIterator::GET_MATCH); + + foreach($phpFiles as $file) { + $filePath = $file[0]; + $relativePath = str_replace($requestsDir, '', $filePath); + $classRelative = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], $relativePath); + $fqn = rtrim('Devcraft\\Webshare\\Requests\\', '\\') . '\\' . ltrim($classRelative, '\\'); + + if(class_exists($fqn, true) && is_subclass_of($fqn, AbstractRequest::class)) { + $reflection = new ReflectionClass($fqn); + + if(!$reflection->isAbstract()) { + $key = str_replace('Request', '', $reflection->getShortName()); + $requests[$key] = $fqn; + } + } + } + + return $requests; + } + +} diff --git a/src/Classes/CacheManager.php b/src/Classes/CacheManager.php new file mode 100644 index 0000000..ae6d7fc --- /dev/null +++ b/src/Classes/CacheManager.php @@ -0,0 +1,117 @@ + $tags + * + * @throws \Psr\Cache\InvalidArgumentException + */ + public static function set(mixed $value, ?string $key = NULL, ?int $ttl = NULL, array $tags = []): void { + $cacheKey = self::resolveCacheKey($key); + $cachedItem = App::Cache()->getItem($cacheKey); + $cachedItem->set($value); + + if($ttl !== NULL) { + $cachedItem->expiresAfter($ttl); + } + + if($tags !== [] && $cachedItem instanceof \Symfony\Component\Cache\CacheItem) { + $cachedItem->tag($tags); + } + + App::Cache()->save($cachedItem); + } + + /** + * @throws \Psr\Cache\InvalidArgumentException + */ + public static function get(?string $key = NULL): mixed { + $cacheKey = self::resolveCacheKey($key); + $cachedItem = App::Cache()->getItem($cacheKey); + + return $cachedItem->isHit() ? $cachedItem->get() : NULL; + } + + /** + * @param list $tags + */ + public static function remember(callable $callback, ?string $key = NULL, ?int $ttl = NULL, array $tags = []): mixed { + $cacheKey = self::resolveCacheKey($key); + + if(App::Debug()) { + $ttl = 5; + } + + return App::Cache()->get($cacheKey, function(ItemInterface $item) use ($callback, $ttl, $tags) { + if($ttl !== NULL) { + $item->expiresAfter($ttl); + } + if($tags !== [] && method_exists($item, 'tag')) { + $item->tag($tags); + } + + return $callback(); + }); + } + + /** + * @throws \Psr\Cache\InvalidArgumentException + */ + public static function forget(?string $key = NULL): bool { + return App::Cache()->deleteItem(self::resolveCacheKey($key)); + } + + /** + * @param list $tags + */ + public static function invalidateTags(array $tags): void { + $cache = App::Cache(); + if($cache instanceof TagAwareAdapter) { + $cache->invalidateTags($tags); + } + } + + private static function resolveCacheKey(?string $key): string { + if($key !== NULL && str_starts_with($key, 'http.')) { + return self::hashKey($key); + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2] ?? []; + + $parts = [ + $trace['class'] ?? 'global', + $trace['function'] ?? 'unknown', + ]; + + if($key !== NULL) { + $parts[] = $key; + } + + $parts = array_map([self::class, 'sanitizeKey'], $parts); + + return self::hashKey(implode('.', $parts)); + } + + private static function hashKey(string $rawKey): string { + if(App::Debug()) { + return strtolower(self::sanitizeKey($rawKey)); + } + + return hash('xxh128', strtolower($rawKey)); + } + + private static function sanitizeKey(mixed $key): string { + $sanitized = filter_var((string) $key, FILTER_SANITIZE_FULL_SPECIAL_CHARS); + + return strtolower(str_replace(['{', '}', '(', ')', '/', '\\', '@', ':'], '_', $sanitized)); + } + +} diff --git a/src/Classes/Client.php b/src/Classes/Client.php new file mode 100644 index 0000000..f6d0045 --- /dev/null +++ b/src/Classes/Client.php @@ -0,0 +1,238 @@ +apiKey)) { + throw new \InvalidArgumentException('API key must not be empty'); + } + + $this->httpClient = $httpClient ?? new GuzzleClient([ + 'base_uri' => rtrim($this->apiUrl, '/') . '/', + 'headers' => [ + 'Authorization' => 'Token ' . $this->apiKey, + ], + ]); + } + + public function withSubuser(?string $subuserId): self { + $this->subuserId = $subuserId; + + return $this; + } + + public function getSubuser(): ?string { + return $this->subuserId; + } + + /** + * Send a request to the WebShare API. + * + * @throws ApiException on 4xx/5xx responses + * @throws RateLimitException|\JsonException when retries exhausted after 429 + */ + public function send( + string $endpoint, + #[ExpectedValues(valuesFromClass: RequestMethod::class)] + RequestMethod $method = RequestMethod::GET, + ?QueryBuilder $query = NULL, + ?RequestBody $body = NULL, + bool $forceRefresh = false, + ): string { + $url = $this->resolveUrl($endpoint, $query); + $options = [ + RequestOptions::HEADERS => $this->buildHeaders($body), + ]; + + if($body !== NULL && in_array($method, [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH], true)) { + $body->build(); + + match (true) { + $body->multipart !== [] => $options[RequestOptions::MULTIPART] = $body->multipart, + $body->postData !== [] => $options[RequestOptions::JSON] = $body->postData, + default => $options[RequestOptions::BODY] = $body->body, + }; + } + + $cacheable = $this->isCacheable($method, $endpoint); + $cacheKey = $cacheable ? $this->cacheKey($method, $url) : NULL; + $cacheTag = $this->cacheTag($endpoint); + + if($cacheable && !$forceRefresh && App::cacheEnabled()) { + $cached = CacheManager::get($cacheKey); + if(is_string($cached)) { + return $cached; + } + } + + $attempt = 0; + $backoffMs = 1000; + + while(true) { + try { + $response = $this->httpClient->request($method->value, $url, $options); + $this->trackRateLimits($response->getHeaders()); + $result = $response->getBody()->getContents(); + + if($cacheable && App::cacheEnabled() && $cacheKey !== NULL) { + CacheManager::set($result, $cacheKey, App::cacheLifetime(), [$cacheTag]); + } + + if($method !== RequestMethod::GET && App::cacheEnabled()) { + CacheManager::invalidateTags([$cacheTag]); + } + + return $result; + } catch(RequestException $e) { + $response = $e->getResponse(); + if($response === NULL) { + throw new ApiException('Network error: ' . $e->getMessage(), 0, $e); + } + + $status = $response->getStatusCode(); + $responseBody = (string) $response->getBody(); + + if($status === 429 || $status >= 500) { + $attempt++; + if($attempt >= self::MAX_RETRIES) { + $status === 429 + ? throw new RateLimitException($this->parseRetryAfter($response->getHeaders()), $e) + : throw new ApiException("HTTP $status: " . ($responseBody !== '' ? $responseBody : $e->getMessage()), $status, $e); + } + usleep($this->calculateBackoff($backoffMs, $response->getHeaders()) * 1000); + $backoffMs *= 2; + continue; + } + + if($status === 400) { + throw ApiException::fromValidationResponse($responseBody, $status); + } + + throw new ApiException("HTTP $status: " . ($responseBody !== '' ? $responseBody : $e->getMessage()), $status, $e); + } catch(GuzzleException $e) { + throw new ApiException('HTTP error: ' . $e->getMessage(), 0, $e); + } + } + } + + /** + * @return array{remaining?: int, reset?: int} + */ + public function getRateLimits(): array { + return $this->rateLimits; + } + + public function getApiVersion(): string { + return $this->apiVersion; + } + + private function buildHeaders(?RequestBody $body): array { + $headers = $body?->header ?? []; + + if($this->subuserId !== NULL && $this->subuserId !== '') { + $headers['X-Subuser'] = $this->subuserId; + } + + return $headers; + } + + private function resolveUrl(string $endpoint, ?QueryBuilder $query = NULL): string { + $path = $this->apiVersion . '/' . ltrim($endpoint, '/'); + $queryString = $query?->build() ?? ''; + + return $queryString !== '' ? $path . '?' . $queryString : $path; + } + + private function isCacheable(RequestMethod $method, string $endpoint): bool { + if($method !== RequestMethod::GET) { + return false; + } + + return !str_contains('/' . ltrim($endpoint, '/') . '/', '/download/'); + } + + private function cacheKey(RequestMethod $method, string $url): string { + $subuser = $this->subuserId ?? ''; + + return 'http.' . $method->value . '.' . $url . '.sub:' . $subuser; + } + + private function cacheTag(string $endpoint): string { + $parts = explode('/', trim($endpoint, '/')); + + return 'http.' . ($parts[0] !== '' ? $parts[0] : 'root'); + } + + private function trackRateLimits(array $headers): void { + $headers = array_change_key_case($headers); + + if(isset($headers['x-ratelimit-remaining'][0])) { + $this->rateLimits['remaining'] = (int) $headers['x-ratelimit-remaining'][0]; + } + if(isset($headers['x-ratelimit-reset'][0])) { + $this->rateLimits['reset'] = (int) $headers['x-ratelimit-reset'][0]; + } + } + + private function calculateBackoff(int $baseMs, array $headers): int { + return max($baseMs, $this->parseRetryAfter($headers) * 1000); + } + + /** + * Prefer Retry-After (seconds). Treat large x-ratelimit-reset values as unix timestamps. + */ + private function parseRetryAfter(array $headers): int { + $headers = array_change_key_case($headers); + + if(isset($headers['retry-after'][0])) { + return $this->clampBackoffSeconds((int) $headers['retry-after'][0]); + } + + if(isset($headers['x-ratelimit-reset'][0])) { + $reset = (int) $headers['x-ratelimit-reset'][0]; + $seconds = $reset > time() ? $reset - time() : $reset; + + return $this->clampBackoffSeconds($seconds); + } + + return 60; + } + + private function clampBackoffSeconds(int $seconds): int { + return max(self::BACKOFF_MIN_SEC, min(self::BACKOFF_MAX_SEC, $seconds)); + } + +} diff --git a/src/Classes/PageIterator.php b/src/Classes/PageIterator.php new file mode 100644 index 0000000..d0dede8 --- /dev/null +++ b/src/Classes/PageIterator.php @@ -0,0 +1,42 @@ + + */ + public static function items(callable $fetchPage, ?int $maxPages = NULL): \Generator { + $maxPages ??= App::iterateMaxPages(); + $maxPages = max(1, $maxPages); + + for($pageIndex = 0; $pageIndex < $maxPages; $pageIndex++) { + $response = $fetchPage($pageIndex); + if(!$response instanceof PaginatedResponse) { + throw new ApiException('PageIterator expects a PaginatedResponse from fetchPage.'); + } + + foreach($response->results as $item) { + yield $item; + } + + if($response->next === NULL) { + return; + } + } + + throw new ApiException("PageIterator exceeded max pages ({$maxPages})."); + } + +} diff --git a/src/Classes/QueryBuilder.php b/src/Classes/QueryBuilder.php new file mode 100644 index 0000000..c52e52b --- /dev/null +++ b/src/Classes/QueryBuilder.php @@ -0,0 +1,140 @@ +pageSize = $pageSize; + + return $this; + } + + public function withPage(int $page): self { + $this->page = $page; + + return $this; + } + + public function withStartingAfter(string $startingAfter): self { + $this->startingAfter = $startingAfter; + + return $this; + } + + public function withRequiredField(string $field): self { + $this->requiredFields[] = $field; + + return $this; + } + + public function withOptionalField(string $field): self { + $this->optionalFields[] = $field; + + return $this; + } + + public function withRequiredFields(array $fields): self { + $this->requiredFields = array_merge($this->requiredFields, $fields); + + return $this; + } + + public function withOptionalFields(array $fields): self { + $this->optionalFields = array_merge($this->optionalFields, $fields); + + return $this; + } + + /** + * Add a filter parameter using WebShare API filter syntax. + * + * @see https://apidocs.webshare.io/#filtering + */ + public function withFilter(string $field, mixed $value, FilterOperator $operator = FilterOperator::EQUAL): self { + $this->checkForPossibleField($field); + + $key = $field . $operator->value; + $this->filters[$key] = is_array($value)? implode(',', $value) : $value; + + return $this; + } + + /** + * Set ordering for list endpoints. Prefix with `-` for descending. + * + * @see https://apidocs.webshare.io/#ordering + * + * @param string ...$fields e.g. 'status', '-created_at' + */ + public function withOrderBy(string ...$fields): self { + $this->ordering = array_merge($this->ordering, $fields); + + return $this; + } + + /** + * Set text search parameter. + * + * @see https://apidocs.webshare.io/#search + */ + public function withSearch(string $term): self { + $this->search = $term; + + return $this; + } + + public function build(): string { + $params = array_filter([ + 'page_size' => $this->pageSize, + 'page' => $this->page, + 'starting_after' => $this->startingAfter, + 'ordering' => $this->ordering? implode(',', $this->ordering) : NULL, + 'search' => $this->search, + ...$this->filters, + ], static fn($v) => $v !== NULL); + + $this->checkRequiredFields($params); + + return http_build_query($params); + } + + public function getFields(): array { + return array_merge($this->getRequiredFields(), $this->getOptionalFields()); + } + + private function checkForPossibleField(string $field): void { + if (!in_array($field, $this->getFields())) { + throw new ApiException("Field '$field' is not allowed."); + } + } + + private function checkRequiredFields(array $parameters): void { + if($this->requiredFields === []) { + return; + } + + $missing = array_diff($this->requiredFields, array_keys($parameters)); + if($missing !== []) { + throw new ApiException('Required fields are not set: ' . implode(', ', $missing)); + } + } +} diff --git a/src/Entities/ApiKey.php b/src/Entities/ApiKey.php new file mode 100644 index 0000000..6578b6c --- /dev/null +++ b/src/Entities/ApiKey.php @@ -0,0 +1,17 @@ +username || !$this->password) { + return sprintf("http://%s:%s", $this->proxy_address, $this->port); + } + + if(!$backbone) { + return sprintf("http://%s:%s@%s:%s", $this->username, $this->password, $this->proxy_address, $this->port); + } + + $user = $this->username; + if($this->country_code) { + $user .= '-' . strtolower($this->country_code); + } + if($city !== NULL) { + $user .= '-city_' . str_replace(' ', '_', strtolower($city)); + } + if($session !== NULL) { + $user .= '-' . $session; + } + + return sprintf("http://%s:%s@p.webshare.io:80", $user, $this->password); + } + +} \ No newline at end of file diff --git a/src/Entities/ProxyActivity.php b/src/Entities/ProxyActivity.php new file mode 100644 index 0000000..0944ca5 --- /dev/null +++ b/src/Entities/ProxyActivity.php @@ -0,0 +1,30 @@ + + */ + #[ArrayOf(ProxyStatErrorReason::class)] + public array $error_reasons = []; + public array $countries_used = []; + public ?int $number_of_proxies_used = NULL; + public array $protocols_used = []; + public ?float $average_concurrency = NULL; + public ?float $average_rps = NULL; + public ?string $last_request_sent_at = NULL; + +} diff --git a/src/Entities/ProxyStatErrorReason.php b/src/Entities/ProxyStatErrorReason.php new file mode 100644 index 0000000..3d458e0 --- /dev/null +++ b/src/Entities/ProxyStatErrorReason.php @@ -0,0 +1,17 @@ + + */ + public array $header = []; + + public ?string $body = NULL; + + /** + * @var array + */ + public array $postData = []; + + /** + * @var array + */ + public array $formData = []; + + /** + * @var list + */ + public array $multipart = []; + + public function withPostData(string $key, mixed $value): self { + $this->guardBodyTypeConflict('postData'); + $this->postData[$key] = $value; + + return $this; + } + + public function withHeader(string $key, mixed $value): self { + $this->header[$key] = $value; + + return $this; + } + + public function withFormData(string $key, mixed $value): self { + $this->guardBodyTypeConflict('formData'); + $this->formData[$key] = $value; + + return $this; + } + + public function withMultipart(string $key, mixed $value): self { + $this->guardBodyTypeConflict('multipart'); + $this->multipart[] = [ + 'name' => $key, + 'contents' => $value, + ]; + + return $this; + } + + /** + * @throws \JsonException + */ + public function build(): self { + $this->prepare(); + + return $this; + } + + private function guardBodyTypeConflict(string $incoming): void { + $active = array_filter([ + 'postData' => !empty($this->postData), + 'formData' => !empty($this->formData), + 'multipart' => !empty($this->multipart), + ]); + + unset($active[$incoming]); + + if($active !== []) { + throw new \LogicException(sprintf( + 'Cannot use "%s": request body already uses "%s". Only one body type is allowed per request.', + $incoming, + implode('", "', array_keys($active)), + )); + } + } + + /** + * @throws \JsonException + */ + private function prepare(): void { + $this->header = array_filter($this->header, static fn($value) => $value !== NULL); + + if(!array_key_exists('Content-Type', $this->header) && !isset(array_change_key_case($this->header)['content-type'])) { + $this->header['Content-Type'] = $this->resolveContentType(); + } + + if(!array_key_exists('Accept', $this->header) && !isset(array_change_key_case($this->header)['accept'])) { + $this->header['Accept'] = self::CONTENT_TYPE_JSON; + } + + $this->body = $this->buildBody(); + } + + private function resolveContentType(): string { + return match (true) { + $this->formData !== [] => self::CONTENT_TYPE_FORM, + default => self::CONTENT_TYPE_JSON, + }; + } + + /** + * @throws \JsonException + */ + private function buildBody(): ?string { + if($this->formData !== []) { + $formData = array_filter($this->formData, static fn($value) => $value !== NULL); + + return http_build_query($formData); + } + + if($this->multipart !== []) { + return NULL; + } + + if($this->postData === []) { + return NULL; + } + + $postData = array_filter($this->postData, static fn($value) => $value !== NULL); + + return json_encode($postData, JSON_THROW_ON_ERROR); + } + +} \ No newline at end of file diff --git a/src/Entities/SubUser.php b/src/Entities/SubUser.php new file mode 100644 index 0000000..63dbde4 --- /dev/null +++ b/src/Entities/SubUser.php @@ -0,0 +1,22 @@ +validationErrors = $validationErrors; + parent::__construct($message, $code, $previous); + } + + public function getValidationErrors(): array { + return $this->validationErrors; + } + + /** + * Parse the WebShare API 400 response body into structured validation errors. + * + * @return array> + */ + public static function fromValidationResponse(string $body, int $statusCode = 400): self { + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + return new self('Bad Request', $statusCode); + } + + $errors = []; + $messages = []; + foreach ($decoded as $field => $fieldErrors) { + if (!is_array($fieldErrors)) { + continue; + } + foreach ($fieldErrors as $error) { + $errors[$field][] = [ + 'message' => $error['message'] ?? 'Unknown error', + 'code' => $error['code'] ?? 'unknown', + ]; + $messages[] = "$field: " . ($error['message'] ?? 'Unknown error'); + } + } + + return new self( + $messages ? implode('; ', $messages) : 'Bad Request', + $statusCode, + null, + $errors, + ); + } +} diff --git a/src/Exceptions/RateLimitException.php b/src/Exceptions/RateLimitException.php new file mode 100644 index 0000000..068826b --- /dev/null +++ b/src/Exceptions/RateLimitException.php @@ -0,0 +1,19 @@ +request(), true)); + } + + /** + * @return \Generator + */ + public function iterate(?int $maxPages = NULL): \Generator { + return PageIterator::items(function(int $pageIndex) { + $this->query()->withPage($pageIndex + 1); + + return $this->list(); + }, $maxPages); + } + + public function retrieve(int $id): ApiKey { + return ApiKey::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true)); + } + + public function create(string $label): ApiKey { + $body = (new RequestBody())->withPostData('label', $label); + + return ApiKey::fromArray(json_decode($this->request(RequestMethod::POST, body: $body), true)); + } + + public function update(int $id, string $label): ApiKey { + $body = (new RequestBody())->withPostData('label', $label); + + return ApiKey::fromArray(json_decode($this->request(RequestMethod::PATCH, (string) $id, $body), true)); + } + + public function delete(int $id): void { + $this->request(RequestMethod::DELETE, (string) $id); + } + +} diff --git a/src/Requests/DownloadTokenRequest.php b/src/Requests/DownloadTokenRequest.php new file mode 100644 index 0000000..b824041 --- /dev/null +++ b/src/Requests/DownloadTokenRequest.php @@ -0,0 +1,31 @@ +request(RequestMethod::POST, rtrim($scope, '/') . '/'), + true, + )); + } + + public function reset(string $scope): DownloadToken { + return DownloadToken::fromArray(json_decode( + $this->request(RequestMethod::POST, rtrim($scope, '/') . '/reset/'), + true, + )); + } + +} diff --git a/src/Requests/IpAuthorizationRequest.php b/src/Requests/IpAuthorizationRequest.php new file mode 100644 index 0000000..5729f29 --- /dev/null +++ b/src/Requests/IpAuthorizationRequest.php @@ -0,0 +1,62 @@ +request(), true)); + } + + /** + * @return \Generator + */ + public function iterate(?int $maxPages = NULL): \Generator { + return PageIterator::items(function(int $pageIndex) { + $this->query()->withPage($pageIndex + 1); + + return $this->list(); + }, $maxPages); + } + + public function retrieve(int $id): IpAuthorization { + return IpAuthorization::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true)); + } + + public function create(string $ipAddress): IpAuthorization { + $body = (new RequestBody())->withPostData('ip_address', $ipAddress); + + return IpAuthorization::fromArray(json_decode($this->request(RequestMethod::POST, body: $body), true)); + } + + public function delete(int $id): void { + $this->request(RequestMethod::DELETE, (string) $id); + } + + /** + * @return array{ip_address?: string} + */ + public function whatsMyIp(): array { + $saved = $this->getEndpoint(); + $this->withEndpoint('proxy/ipauthorization/whatsmyip'); + try { + return json_decode($this->request(), true) ?? []; + } finally { + $this->withEndpoint($saved); + } + } + +} diff --git a/src/Requests/NotificationRequest.php b/src/Requests/NotificationRequest.php new file mode 100644 index 0000000..135b2f9 --- /dev/null +++ b/src/Requests/NotificationRequest.php @@ -0,0 +1,32 @@ +request(), true)); + } + + public function retrieve(int $id): Notification { + return Notification::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true)); + } + + public function dismiss(int $id): Notification { + return Notification::fromArray(json_decode($this->request(RequestMethod::POST, "$id/dismiss/"), true)); + } + + public function restore(int $id): Notification { + return Notification::fromArray(json_decode($this->request(RequestMethod::POST, "$id/restore/"), true)); + } +} \ No newline at end of file diff --git a/src/Requests/ProfileRequest.php b/src/Requests/ProfileRequest.php new file mode 100644 index 0000000..956e0d7 --- /dev/null +++ b/src/Requests/ProfileRequest.php @@ -0,0 +1,48 @@ +request(), true)); + } + + public function update(Profile $profile): Profile { + $body = new RequestBody(); + foreach(array_filter($profile->toArray(), static fn($v) => $v !== NULL) as $key => $value) { + $body->withPostData($key, $value); + } + + return Profile::fromArray(json_decode($this->request(RequestMethod::PATCH, NULL, $body), true)); + } + + public function retrievePreferences(): ProfilePreference { + return ProfilePreference::fromArray(json_decode($this->request(RequestMethod::GET, 'preferences'), true)); + } + + public function updatePreferences(ProfilePreference $preferences): ProfilePreference { + $body = new RequestBody(); + foreach(array_filter($preferences->toArray(), static fn($v) => $v !== NULL) as $key => $value) { + $body->withPostData($key, $value); + } + + return ProfilePreference::fromArray(json_decode($this->request(RequestMethod::PATCH, 'preferences', $body), true)); + } + +} \ No newline at end of file diff --git a/src/Requests/ProxyConfigRequest.php b/src/Requests/ProxyConfigRequest.php new file mode 100644 index 0000000..2a8e9d1 --- /dev/null +++ b/src/Requests/ProxyConfigRequest.php @@ -0,0 +1,75 @@ +query()->withFilter('plan_id', $planId); + + return $this; + } + + public function retrieve(): ProxyConfig { + return ProxyConfig::fromArray(json_decode($this->request(apiVersion: 'v3'), true)); + } + + public function update(array $fields): ProxyConfig { + $body = new RequestBody(); + foreach($fields as $key => $value) { + $body->withPostData((string) $key, $value); + } + + // Docs: PATCH /api/v2/proxy/config/ (get/status/stats use v3). + return ProxyConfig::fromArray(json_decode($this->request(RequestMethod::PATCH, body: $body, apiVersion: 'v2'), true)); + } + + public function status(): ProxyStatus { + return ProxyStatus::fromArray(json_decode($this->requestAt('proxy/list/status', apiVersion: 'v3'), true)); + } + + public function listStats(): ProxyListStats { + return ProxyListStats::fromArray(json_decode($this->requestAt('proxy/list/stats', apiVersion: 'v3'), true)); + } + + public function assignUnallocatedCountries(array $countries): ProxyConfig { + $body = (new RequestBody())->withPostData('countries', $countries); + + return ProxyConfig::fromArray(json_decode( + $this->requestAt('proxy/config/assign_unallocated_countries', RequestMethod::POST, body: $body, apiVersion: 'v3'), + true, + )); + } + + private function requestAt( + string $endpoint, + RequestMethod $method = RequestMethod::GET, + ?RequestBody $body = NULL, + ?string $apiVersion = NULL, + ): string { + $savedEndpoint = $this->getEndpoint(); + $this->withEndpoint($endpoint); + try { + return $this->request($method, body: $body, apiVersion: $apiVersion); + } finally { + $this->withEndpoint($savedEndpoint); + } + } + +} diff --git a/src/Requests/ProxyReplacementRequest.php b/src/Requests/ProxyReplacementRequest.php new file mode 100644 index 0000000..1a5fbbe --- /dev/null +++ b/src/Requests/ProxyReplacementRequest.php @@ -0,0 +1,44 @@ +request(apiVersion: 'v3'), true)); + } + + /** + * @throws \JsonException + */ + public function retrieve(int $id): ProxyReplacement { + return ProxyReplacement::fromArray(json_decode($this->request(additionalEndpoint: (string) $id, apiVersion: 'v3'), true)); + } + + /** + * @throws \JsonException + */ + public function create(array $to_replace, array $replace_with, bool $dry_run = false): ProxyReplacement { + $body = (new RequestBody())->withPostData('to_replace', $to_replace) + ->withPostData('replace_with', $replace_with) + ->withPostData('dry_run', $dry_run); + return ProxyReplacement::fromArray(json_decode($this->request(RequestMethod::POST, body: $body, apiVersion: 'v3'), true)); + } + +} \ No newline at end of file diff --git a/src/Requests/ProxyRequest.php b/src/Requests/ProxyRequest.php new file mode 100644 index 0000000..39253c1 --- /dev/null +++ b/src/Requests/ProxyRequest.php @@ -0,0 +1,44 @@ +request(), true)); + } + + /** + * @return \Generator + */ + public function iterate(?int $maxPages = NULL): \Generator { + return PageIterator::items(function(int $pageIndex) { + $this->query()->withPage($pageIndex + 1); + + return $this->getList(); + }, $maxPages); + } + + public function downloadList(string $token, string $countryCodes = '-', string $authMethod = 'username', string $mode = 'direct', string $search = '-'): string { + return $this->request(RequestMethod::GET, sprintf('download/%s/%s/any/%s/%s/%s/', $token, $countryCodes, $authMethod, $mode, urlencode($search))); + } + + public function refresh(): void { + $this->request(RequestMethod::POST, 'refresh/'); + } + +} diff --git a/src/Requests/ProxyStatsRequest.php b/src/Requests/ProxyStatsRequest.php new file mode 100644 index 0000000..6a2081e --- /dev/null +++ b/src/Requests/ProxyStatsRequest.php @@ -0,0 +1,100 @@ + + */ + public function list(): array { + $decoded = json_decode($this->request(), true) ?? []; + if(!is_array($decoded)) { + return []; + } + + return array_map( + static fn(array $row) => ProxyStat::fromArray($row), + array_values(array_filter($decoded, 'is_array')), + ); + } + + public function aggregate(): ProxyStat { + $saved = $this->getEndpoint(); + $this->withEndpoint('stats/aggregate'); + try { + return ProxyStat::fromArray(json_decode($this->request(), true)); + } finally { + $this->withEndpoint($saved); + } + } + + public function listActivities(): ProxyActivityList { + $saved = $this->getEndpoint(); + $this->withEndpoint('proxy/activity'); + try { + return ProxyActivityList::fromArray(json_decode($this->request(), true)); + } finally { + $this->withEndpoint($saved); + } + } + + /** + * @return \Generator + */ + public function iterateActivities(?int $maxPages = NULL): \Generator { + $lastTimestamp = NULL; + + return PageIterator::items(function() use (&$lastTimestamp) { + if($lastTimestamp !== NULL) { + $this->query()->withStartingAfter($lastTimestamp); + } + + $list = $this->listActivities(); + if($list->results !== []) { + $last = $list->results[array_key_last($list->results)]; + if($last instanceof ProxyActivity && $last->timestamp !== NULL) { + $lastTimestamp = $last->timestamp; + } + } + + return $list; + }, $maxPages); + } + + public function downloadActivities(): string { + $saved = $this->getEndpoint(); + $this->withEndpoint('proxy/activity/download'); + try { + return $this->request(RequestMethod::GET); + } finally { + $this->withEndpoint($saved); + } + } + +} diff --git a/src/Requests/ReplacedProxyRequest.php b/src/Requests/ReplacedProxyRequest.php new file mode 100644 index 0000000..1cb6319 --- /dev/null +++ b/src/Requests/ReplacedProxyRequest.php @@ -0,0 +1,26 @@ +request(), true)); + } + + public function download(string $token, string $countryCodes = '-', string $authMethod = 'username', string $mode = 'direct', string $search = '-'): string { + // ponytail: docs show download via GET with query params or path params, standardizing to path params to match existing ProxyRequest style. + return $this->request(RequestMethod::GET, sprintf('download/%s/%s/any/%s/%s/%s/', $token, $countryCodes, $authMethod, $mode, urlencode($search))); + } + +} \ No newline at end of file diff --git a/src/Requests/SubUserRequest.php b/src/Requests/SubUserRequest.php new file mode 100644 index 0000000..daf1959 --- /dev/null +++ b/src/Requests/SubUserRequest.php @@ -0,0 +1,76 @@ +request(), true)); + } + + /** + * @return \Generator + */ + public function iterate(?int $maxPages = NULL): \Generator { + return PageIterator::items(function(int $pageIndex) { + $this->query()->withPage($pageIndex + 1); + + return $this->list(); + }, $maxPages); + } + + public function retrieve(int $id): SubUser { + return SubUser::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true)); + } + + public function create(string $label, ?float $proxyLimit = NULL, ?int $maxThreadCount = NULL): SubUser { + $body = (new RequestBody())->withPostData('label', $label); + if($proxyLimit !== NULL) { + $body->withPostData('proxy_limit', $proxyLimit); + } + if($maxThreadCount !== NULL) { + $body->withPostData('max_thread_count', $maxThreadCount); + } + + return SubUser::fromArray(json_decode($this->request(RequestMethod::POST, body: $body), true)); + } + + public function update(int $id, array $fields): SubUser { + $body = new RequestBody(); + foreach($fields as $key => $value) { + $body->withPostData((string) $key, $value); + } + + return SubUser::fromArray(json_decode($this->request(RequestMethod::PATCH, (string) $id, $body), true)); + } + + public function delete(int $id): void { + $this->request(RequestMethod::DELETE, (string) $id); + } + + public function refreshProxyList(int $id): SubUser { + return SubUser::fromArray(json_decode($this->request(RequestMethod::POST, $id . '/refresh/'), true)); + } + + /** + * Masquerade subsequent App::Client() calls as this sub-user (X-Subuser header). + */ + public function masqueradeAs(int|string|null $subuserId): void { + App::Client()->withSubuser($subuserId === NULL ? NULL : (string) $subuserId); + } + +} diff --git a/src/Responses/ApiKeyList.php b/src/Responses/ApiKeyList.php new file mode 100644 index 0000000..d1f0bae --- /dev/null +++ b/src/Responses/ApiKeyList.php @@ -0,0 +1,18 @@ + + */ + #[ArrayOf(ApiKey::class)] + public array $results = []; + +} diff --git a/src/Responses/IpAuthorizationList.php b/src/Responses/IpAuthorizationList.php new file mode 100644 index 0000000..76854ce --- /dev/null +++ b/src/Responses/IpAuthorizationList.php @@ -0,0 +1,18 @@ + + */ + #[ArrayOf(IpAuthorization::class)] + public array $results = []; + +} diff --git a/src/Responses/NotificationList.php b/src/Responses/NotificationList.php new file mode 100644 index 0000000..c4c3dd5 --- /dev/null +++ b/src/Responses/NotificationList.php @@ -0,0 +1,15 @@ + + */ + #[ArrayOf(Notification::class)] + public array $results = []; +} \ No newline at end of file diff --git a/src/Responses/PaginatedResponse.php b/src/Responses/PaginatedResponse.php new file mode 100644 index 0000000..9528b29 --- /dev/null +++ b/src/Responses/PaginatedResponse.php @@ -0,0 +1,39 @@ + + */ + #[ArrayOf(ProxyActivity::class)] + public array $results = []; + +} diff --git a/src/Responses/ProxyList.php b/src/Responses/ProxyList.php new file mode 100644 index 0000000..7adf44c --- /dev/null +++ b/src/Responses/ProxyList.php @@ -0,0 +1,18 @@ + + */ + #[ArrayOf(Proxy::class)] + public array $results = []; + +} \ No newline at end of file diff --git a/src/Responses/ProxyReplacementList.php b/src/Responses/ProxyReplacementList.php new file mode 100644 index 0000000..7f791fd --- /dev/null +++ b/src/Responses/ProxyReplacementList.php @@ -0,0 +1,18 @@ + + */ + #[ArrayOf(ProxyReplacement::class)] + public array $results = []; + +} \ No newline at end of file diff --git a/src/Responses/ReplacedProxyList.php b/src/Responses/ReplacedProxyList.php new file mode 100644 index 0000000..5a7557e --- /dev/null +++ b/src/Responses/ReplacedProxyList.php @@ -0,0 +1,18 @@ + + */ + #[ArrayOf(ReplacedProxy::class)] + public array $results = []; + +} \ No newline at end of file diff --git a/src/Responses/SubUserList.php b/src/Responses/SubUserList.php new file mode 100644 index 0000000..883f9df --- /dev/null +++ b/src/Responses/SubUserList.php @@ -0,0 +1,18 @@ + + */ + #[ArrayOf(SubUser::class)] + public array $results = []; + +} diff --git a/tests/Classes/AppConfigTest.php b/tests/Classes/AppConfigTest.php new file mode 100644 index 0000000..5c14661 --- /dev/null +++ b/tests/Classes/AppConfigTest.php @@ -0,0 +1,29 @@ +assertSame(7, App::iterateMaxPages()); + $this->assertFalse(App::cacheEnabled()); + $this->assertSame(120, App::cacheLifetime()); + } + + public function testRequestsDiscoversProxyRequest(): void { + $req = App::Requests('Proxy'); + $this->assertInstanceOf(\Devcraft\Webshare\Requests\ProxyRequest::class, $req); + $this->assertNull(App::Requests('DoesNotExist')); + } + +} diff --git a/tests/Classes/ClientTest.php b/tests/Classes/ClientTest.php new file mode 100644 index 0000000..46df256 --- /dev/null +++ b/tests/Classes/ClientTest.php @@ -0,0 +1,130 @@ +cacheDir = sys_get_temp_dir() . '/webshare-test-cache-' . uniqid('', true); + mkdir($this->cacheDir); + $_ENV['API_KEY'] = 'test-key'; + $_ENV['CACHE_DIRECTORY'] = $this->cacheDir; + $_ENV['CACHE_LIFETIME'] = '3600'; + $_ENV['CACHE_ENABLED'] = '1'; + $_ENV['DEBUG'] = '0'; + App::resetForTests(); + } + + protected function tearDown(): void { + $this->removeDir($this->cacheDir); + } + + public function testGetWithoutBodyDoesNotTypeError(): void { + $mock = new MockHandler([new Response(200, [], '{"ok":true}')]); + $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + $this->assertSame('{"ok":true}', $client->send('profile')); + } + + public function testGetResponseIsCached(): void { + $mock = new MockHandler([ + new Response(200, [], '{"page":1}'), + new Response(200, [], '{"page":2}'), + ]); + $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + $first = $client->send('profile'); + $second = $client->send('profile'); + + $this->assertSame('{"page":1}', $first); + $this->assertSame('{"page":1}', $second); + $this->assertSame(1, $mock->count()); + } + + public function testForceRefreshBypassesCache(): void { + $mock = new MockHandler([ + new Response(200, [], '{"page":1}'), + new Response(200, [], '{"page":2}'), + ]); + $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + $client->send('profile'); + $second = $client->send('profile', forceRefresh: true); + + $this->assertSame('{"page":2}', $second); + } + + public function testDownloadPathIsNotCached(): void { + $mock = new MockHandler([ + new Response(200, [], 'download-1'), + new Response(200, [], 'download-2'), + ]); + $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + $this->assertSame('download-1', $client->send('proxy/list/download/token/-/any/username/direct/-/')); + $this->assertSame('download-2', $client->send('proxy/list/download/token/-/any/username/direct/-/')); + } + + public function testMutationInvalidatesCacheTag(): void { + $mock = new MockHandler([ + new Response(200, [], '{"v":1}'), + new Response(204, [], ''), + new Response(200, [], '{"v":2}'), + ]); + $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + $this->assertSame('{"v":1}', $client->send('notification')); + $client->send('notification/1/dismiss/', RequestMethod::POST); + $this->assertSame('{"v":2}', $client->send('notification')); + } + + public function testParseRetryAfterTreatsUnixResetAsDeltaAndClamps(): void { + $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create(new MockHandler())])); + $method = new \ReflectionMethod(Client::class, 'parseRetryAfter'); + + $seconds = $method->invoke($client, [ + 'x-ratelimit-reset' => [(string) (time() + 99999)], + ]); + $this->assertSame(120, $seconds); + + $seconds = $method->invoke($client, [ + 'retry-after' => ['5'], + 'x-ratelimit-reset' => [(string) (time() + 99999)], + ]); + $this->assertSame(5, $seconds); + + $seconds = $method->invoke($client, [ + 'x-ratelimit-reset' => ['30'], + ]); + $this->assertSame(30, $seconds); + } + + private function removeDir(string $dir): void { + if(!is_dir($dir)) { + return; + } + foreach(scandir($dir) ?: [] as $item) { + if($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } + +} diff --git a/tests/Classes/PageIteratorTest.php b/tests/Classes/PageIteratorTest.php new file mode 100644 index 0000000..b3b3082 --- /dev/null +++ b/tests/Classes/PageIteratorTest.php @@ -0,0 +1,54 @@ +results = ['a', 'b']; + $page->next = NULL; + + return $page; + }, 10)); + + $this->assertSame(['a', 'b'], $items); + $this->assertSame(1, $calls); + } + + public function testThrowsWhenMaxPagesExceeded(): void { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('PageIterator exceeded max pages (2)'); + + iterator_to_array(PageIterator::items(function(int $i) { + $page = new PaginatedResponse(); + $page->results = [$i]; + $page->next = 'https://example.com/next'; + + return $page; + }, 2)); + } + + public function testYieldsAcrossPagesUntilNextNull(): void { + $items = iterator_to_array(PageIterator::items(function(int $i) { + $page = new PaginatedResponse(); + $page->results = ["p{$i}"]; + $page->next = $i < 1 ? 'https://example.com/next' : NULL; + + return $page; + }, 10)); + + $this->assertSame(['p0', 'p1'], $items); + } + +} diff --git a/tests/Classes/QueryBuilderExtendedTest.php b/tests/Classes/QueryBuilderExtendedTest.php new file mode 100644 index 0000000..6b0b997 --- /dev/null +++ b/tests/Classes/QueryBuilderExtendedTest.php @@ -0,0 +1,43 @@ +withOptionalFields(['status', 'amount', 'city', 'ordering', 'search']) + ->withFilter('status', ['a', 'b'], FilterOperator::IN) + ->withFilter('amount', 10, FilterOperator::GT) + ->withFilter('city', 'New', FilterOperator::CONTAINS) + ->withOrderBy('-created_at', 'status') + ->withSearch('Free') + ->withPage(2) + ->withPageSize(50); + + $built = $query->build(); + $this->assertStringContainsString('status__in=a%2Cb', $built); + $this->assertStringContainsString('amount__gt=10', $built); + $this->assertStringContainsString('city__contains=New', $built); + $this->assertStringContainsString('ordering=-created_at%2Cstatus', $built); + $this->assertStringContainsString('search=Free', $built); + $this->assertStringContainsString('page=2', $built); + $this->assertStringContainsString('page_size=50', $built); + } + + public function testUnknownFieldThrows(): void { + $this->expectException(ApiException::class); + (new QueryBuilder()) + ->withOptionalFields(['status']) + ->withFilter('nope', 1); + } + +} diff --git a/tests/Classes/QueryBuilderTest.php b/tests/Classes/QueryBuilderTest.php new file mode 100644 index 0000000..2edbcda --- /dev/null +++ b/tests/Classes/QueryBuilderTest.php @@ -0,0 +1,37 @@ +withOptionalFields(['ordering']); + $this->assertSame('', $query->build()); + } + + public function testBuildRequiresAllRequiredFields(): void { + $query = (new QueryBuilder()) + ->withOptionalFields(['mode', 'plan_id']) + ->withRequiredFields(['mode']); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Required fields are not set: mode'); + $query->build(); + } + + public function testBuildPassesWhenRequiredFieldPresent(): void { + $query = (new QueryBuilder()) + ->withOptionalFields(['mode']) + ->withRequiredFields(['mode']) + ->withFilter('mode', 'direct'); + + $this->assertStringContainsString('mode=direct', $query->build()); + } + +} diff --git a/tests/Entities/EntityHydrationTest.php b/tests/Entities/EntityHydrationTest.php new file mode 100644 index 0000000..a9f1af7 --- /dev/null +++ b/tests/Entities/EntityHydrationTest.php @@ -0,0 +1,211 @@ + '1', + 'username' => 'user', + 'password' => 'pass', + 'proxy_address' => '1.2.3.4', + 'port' => 80, + 'country_code' => 'US', + 'valid' => true, + ]); + + $this->assertSame('user', $proxy->username); + $this->assertSame(80, $proxy->port); + $this->assertSame('http://user:pass@1.2.3.4:80', $proxy->getConnectionString()); + $this->assertSame( + 'http://user-us-city_los_angeles-rotate:pass@p.webshare.io:80', + $proxy->getConnectionString(backbone: true, session: 'rotate', city: 'los_angeles'), + ); + } + + public function testProxyListFromArray(): void { + $list = ProxyList::fromArray([ + 'count' => 1, + 'next' => NULL, + 'previous' => NULL, + 'results' => [ + [ + 'id' => '9', + 'proxy_address' => '10.0.0.1', + 'port' => 8080, + 'username' => 'u', + 'password' => 'p', + ], + ], + ]); + + $this->assertSame(1, $list->count); + $this->assertCount(1, $list->results); + $this->assertInstanceOf(Proxy::class, $list->results[0]); + $this->assertSame('10.0.0.1', $list->results[0]->proxy_address); + } + + public function testProxyConfigFromArray(): void { + $config = ProxyConfig::fromArray([ + 'id' => 1, + 'state' => 'completed', + 'username' => 'username', + 'request_timeout' => 86400, + 'ip_authorization_country_codes' => ['US', 'FR'], + 'proxy_list_download_token' => 'tok', + 'auto_replace_invalid_proxies'=> true, + ]); + + $this->assertSame(1, $config->id); + $this->assertSame('completed', $config->state); + $this->assertSame(['US', 'FR'], $config->ip_authorization_country_codes); + $this->assertSame('tok', $config->proxy_list_download_token); + } + + public function testProxyStatusAndListStatsFromArray(): void { + $status = ProxyStatus::fromArray([ + 'state' => 'completed', + 'countries' => ['US' => 5], + 'username' => 'u', + 'is_proxy_used' => false, + ]); + $stats = ProxyListStats::fromArray([ + 'available_countries' => ['US' => 95], + 'ip_ranges_24' => ['10.1.1.0/24' => 5], + ]); + + $this->assertSame('completed', $status->state); + $this->assertSame(95, $stats->available_countries['US']); + } + + public function testIpAuthorizationListFromArray(): void { + $list = IpAuthorizationList::fromArray([ + 'count' => 1, + 'next' => NULL, + 'previous'=> NULL, + 'results' => [ + [ + 'id' => 1337, + 'ip_address' => '10.1.2.3', + 'created_at' => '2022-06-14T11:58:10.246406-07:00', + 'last_used_at' => NULL, + ], + ], + ]); + + $this->assertInstanceOf(IpAuthorization::class, $list->results[0]); + $this->assertSame(1337, $list->results[0]->id); + $this->assertSame('10.1.2.3', $list->results[0]->ip_address); + } + + public function testProxyStatAndActivityFromArray(): void { + $stat = ProxyStat::fromArray([ + 'timestamp' => '2022-08-11T17:00:00-07:00', + 'bandwidth_total' => 5000, + 'requests_total' => 5, + 'requests_successful' => 4, + 'error_reasons' => [ + [ + 'reason' => 'client_connect_forbidden_host', + 'type' => 'configuration', + 'how_to_fix' => 'fix it', + 'http_status' => 403, + 'count' => 1, + ], + ], + 'countries_used' => ['GB' => 1], + ]); + + $activity = ProxyActivity::fromArray([ + 'timestamp' => '2022-08-16T15:29:42.517523-07:00', + 'protocol' => 'http', + 'proxy_address' => '192.168.5.1', + 'bytes' => 0, + 'hostname' => 'ipv4.webshare.io', + 'port' => 443, + ]); + + $list = ProxyActivityList::fromArray([ + 'count' => 1, + 'next' => NULL, + 'previous'=> NULL, + 'results' => [ + [ + 'timestamp' => '2022-08-16T15:29:42.517523-07:00', + 'protocol' => 'http', + 'proxy_address' => '192.168.5.1', + ], + ], + ]); + + $this->assertSame(5000, $stat->bandwidth_total); + $this->assertCount(1, $stat->error_reasons); + $this->assertSame('configuration', $stat->error_reasons[0]->type); + $this->assertSame('http', $activity->protocol); + $this->assertInstanceOf(ProxyActivity::class, $list->results[0]); + } + + public function testDownloadTokenApiKeySubUserNotificationFromArray(): void { + $token = DownloadToken::fromArray([ + 'id' => 56, + 'key' => 'abcdefghijklmnopqrstuvwxyz', + 'scope' => 'activity', + 'expire_at' => '2022-06-14T11:58:10.246406-07:00', + ]); + $key = ApiKey::fromArray([ + 'id' => 1337, + 'key' => 'abc1234', + 'label' => 'server1 key', + ]); + $user = SubUser::fromArray([ + 'id' => 7, + 'label' => 'Test User', + 'proxy_limit' => 10.0, + 'proxy_countries' => ['ZZ' => 1000], + ]); + $notification = Notification::fromArray([ + 'id' => 1, + 'type' => 'info', + 'is_dismissable' => true, + 'context' => ['x' => 1], + ]); + + $keys = ApiKeyList::fromArray([ + 'count' => 1, 'next' => NULL, 'previous' => NULL, + 'results' => [['id' => 1, 'key' => 'k', 'label' => 'l']], + ]); + $users = SubUserList::fromArray([ + 'count' => 1, 'next' => NULL, 'previous' => NULL, + 'results' => [['id' => 7, 'label' => 'u', 'proxy_limit' => 1.0]], + ]); + + $this->assertSame('activity', $token->scope); + $this->assertSame('server1 key', $key->label); + $this->assertSame(7, $user->id); + $this->assertTrue($notification->is_dismissable); + $this->assertInstanceOf(ApiKey::class, $keys->results[0]); + $this->assertInstanceOf(SubUser::class, $users->results[0]); + } + +} diff --git a/tests/Exceptions/ExceptionTest.php b/tests/Exceptions/ExceptionTest.php new file mode 100644 index 0000000..3a38310 --- /dev/null +++ b/tests/Exceptions/ExceptionTest.php @@ -0,0 +1,39 @@ + [ + ['message' => 'This field is required.', 'code' => 'required'], + ], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame(400, $ex->getCode()); + $this->assertStringContainsString('mode: This field is required.', $ex->getMessage()); + $errors = $ex->getValidationErrors(); + $this->assertSame('required', $errors['mode'][0]['code']); + } + + public function testFromValidationResponseInvalidJson(): void { + $ex = ApiException::fromValidationResponse('not-json'); + $this->assertSame('Bad Request', $ex->getMessage()); + $this->assertSame([], $ex->getValidationErrors()); + } + + public function testRateLimitExceptionMessage(): void { + $ex = new RateLimitException(45); + $this->assertSame(429, $ex->getCode()); + $this->assertSame(45, $ex->retryAfterSeconds); + $this->assertStringContainsString('45s', $ex->getMessage()); + } + +} diff --git a/tests/Requests/RequestDecodeTest.php b/tests/Requests/RequestDecodeTest.php new file mode 100644 index 0000000..319ca45 --- /dev/null +++ b/tests/Requests/RequestDecodeTest.php @@ -0,0 +1,218 @@ + 1, 'next' => NULL, 'previous' => NULL, + 'results' => [[ + 'id' => '1', 'username' => 'u', 'password' => 'p', + 'proxy_address' => '1.2.3.4', 'port' => 80, 'country_code' => 'US', + ]], + ], JSON_THROW_ON_ERROR); + $this->injectMockClient(new MockHandler([new Response(200, [], $payload)])); + + $req = new ProxyRequest(); + $req->query()->withFilter('mode', 'direct'); + $list = $req->getList(); + + $this->assertCount(1, $list->results); + $this->assertInstanceOf(Proxy::class, $list->results[0]); + $this->assertSame('1.2.3.4', $list->results[0]->proxy_address); + } + + public function testIpAuthorizationListAndCreate(): void { + $listBody = json_encode([ + 'count' => 1, 'next' => NULL, 'previous' => NULL, + 'results' => [['id' => 1, 'ip_address' => '10.0.0.1', 'created_at' => 't', 'last_used_at' => NULL]], + ], JSON_THROW_ON_ERROR); + $createBody = json_encode([ + 'id' => 2, 'ip_address' => '10.0.0.2', 'created_at' => 't', 'last_used_at' => NULL, + ], JSON_THROW_ON_ERROR); + $whatsMyIp = json_encode(['ip_address' => '9.9.9.9'], JSON_THROW_ON_ERROR); + + $this->injectMockClient(new MockHandler([ + new Response(200, [], $listBody), + new Response(200, [], $createBody), + new Response(200, [], $whatsMyIp), + ])); + + $req = new IpAuthorizationRequest(); + $list = $req->list(); + $created = $req->create('10.0.0.2'); + $mine = $req->whatsMyIp(); + + $this->assertInstanceOf(IpAuthorization::class, $list->results[0]); + $this->assertSame(2, $created->id); + $this->assertSame('9.9.9.9', $mine['ip_address']); + } + + public function testNotificationListAndRetrieve(): void { + $listBody = json_encode([ + 'count' => 1, 'next' => NULL, 'previous' => NULL, + 'results' => [['id' => 5, 'type' => 'info', 'is_dismissable' => true]], + ], JSON_THROW_ON_ERROR); + $one = json_encode(['id' => 5, 'type' => 'info', 'is_dismissable' => true], JSON_THROW_ON_ERROR); + + $this->injectMockClient(new MockHandler([ + new Response(200, [], $listBody), + new Response(200, [], $one), + ])); + + $req = new NotificationRequest(); + $list = $req->list(); + $item = $req->retrieve(5); + + $this->assertInstanceOf(Notification::class, $list->results[0]); + $this->assertSame(5, $item->id); + } + + public function testProfileRetrieve(): void { + $body = json_encode([ + 'id' => 1, 'email' => 'a@b.c', 'first_name' => 'A', 'last_name' => 'B', + ], JSON_THROW_ON_ERROR); + $this->injectMockClient(new MockHandler([new Response(200, [], $body)])); + + $profile = (new ProfileRequest())->retrieve(); + $this->assertInstanceOf(Profile::class, $profile); + } + + public function testProxyStatsListAndAggregate(): void { + $listBody = json_encode([[ + 'timestamp' => '2022-08-11T17:00:00-07:00', + 'bandwidth_total' => 5000, + 'requests_total' => 5, + 'error_reasons' => [], + ]], JSON_THROW_ON_ERROR); + $aggBody = json_encode([ + 'bandwidth_total' => 5000, + 'requests_total' => 5, + 'error_reasons' => [], + ], JSON_THROW_ON_ERROR); + + $this->injectMockClient(new MockHandler([ + new Response(200, [], $listBody), + new Response(200, [], $aggBody), + ])); + + $req = new ProxyStatsRequest(); + $list = $req->list(); + $agg = $req->aggregate(); + + $this->assertCount(1, $list); + $this->assertInstanceOf(ProxyStat::class, $list[0]); + $this->assertSame(5000, $agg->bandwidth_total); + } + + public function testDownloadTokenGet(): void { + $body = json_encode([ + 'id' => 56, 'key' => 'abc', 'scope' => 'activity', 'expire_at' => 't', + ], JSON_THROW_ON_ERROR); + $this->injectMockClient(new MockHandler([new Response(200, [], $body)])); + + $token = (new DownloadTokenRequest())->get('activity'); + $this->assertInstanceOf(DownloadToken::class, $token); + $this->assertSame('activity', $token->scope); + } + + public function testApiKeyListAndCreate(): void { + $listBody = json_encode([ + 'count' => 1, 'next' => NULL, 'previous' => NULL, + 'results' => [['id' => 1, 'key' => 'k', 'label' => 'l']], + ], JSON_THROW_ON_ERROR); + $createBody = json_encode([ + 'id' => 2, 'key' => 'new', 'label' => 'server', + ], JSON_THROW_ON_ERROR); + + $this->injectMockClient(new MockHandler([ + new Response(200, [], $listBody), + new Response(200, [], $createBody), + ])); + + $req = new ApiKeyRequest(); + $list = $req->list(); + $created = $req->create('server'); + + $this->assertInstanceOf(ApiKey::class, $list->results[0]); + $this->assertSame('server', $created->label); + } + + public function testSubUserListAndCreate(): void { + $listBody = json_encode([ + 'count' => 1, 'next' => NULL, 'previous' => NULL, + 'results' => [['id' => 7, 'label' => 'Test', 'proxy_limit' => 10.0]], + ], JSON_THROW_ON_ERROR); + $createBody = json_encode([ + 'id' => 8, 'label' => 'new', 'proxy_limit' => 5.0, 'max_thread_count' => 100, + ], JSON_THROW_ON_ERROR); + + $this->injectMockClient(new MockHandler([ + new Response(200, [], $listBody), + new Response(200, [], $createBody), + ])); + + $req = new SubUserRequest(); + $list = $req->list(); + $created = $req->create('new', 5.0, 100); + + $this->assertInstanceOf(SubUser::class, $list->results[0]); + $this->assertSame(8, $created->id); + } + + public function testProxyConfigRetrieveUsesV3Client(): void { + $body = json_encode([ + 'id' => 1, + 'state' => 'completed', + 'username' => 'u', + 'proxy_list_download_token' => 'tok', + ], JSON_THROW_ON_ERROR); + + // retrieve() forces apiVersion v3 → newClient; inject v3 as singleton by matching version. + $_ENV['API_VERSION'] = 'v3'; + \Devcraft\Webshare\Classes\App::resetForTests(); + $this->injectMockClient(new MockHandler([new Response(200, [], $body)]), 'v3'); + + $req = new ProxyConfigRequest(); + $req->withPlanId(1); + $config = $req->retrieve(); + + $this->assertInstanceOf(ProxyConfig::class, $config); + $this->assertSame('tok', $config->proxy_list_download_token); + } + + public function testSubUserMasqueradeSetsClientHeaderState(): void { + $this->injectMockClient(new MockHandler([])); + $req = new SubUserRequest(); + $req->masqueradeAs(42); + $this->assertSame('42', \Devcraft\Webshare\Classes\App::Client()->getSubuser()); + $req->masqueradeAs(NULL); + $this->assertNull(\Devcraft\Webshare\Classes\App::Client()->getSubuser()); + } + +} diff --git a/tests/TestCaseWithApp.php b/tests/TestCaseWithApp.php new file mode 100644 index 0000000..80354d4 --- /dev/null +++ b/tests/TestCaseWithApp.php @@ -0,0 +1,66 @@ +cacheDir = sys_get_temp_dir() . '/webshare-test-cache-' . uniqid('', true); + mkdir($this->cacheDir); + $_ENV['API_KEY'] = 'test-key'; + $_ENV['API_URL'] = 'https://example.com/api/'; + $_ENV['API_VERSION'] = 'v2'; + $_ENV['CACHE_DIRECTORY'] = $this->cacheDir; + $_ENV['CACHE_LIFETIME'] = '3600'; + $_ENV['CACHE_ENABLED'] = '1'; + $_ENV['ITERATE_MAX_PAGES'] = '100'; + $_ENV['DEBUG'] = '0'; + App::resetForTests(); + } + + protected function tearDown(): void { + $this->removeDir($this->cacheDir); + parent::tearDown(); + } + + protected function injectMockClient(MockHandler $mock, string $version = 'v2'): Client { + $client = new Client( + 'test-key', + 'https://example.com/api/', + $version, + new GuzzleClient(['handler' => HandlerStack::create($mock)]), + ); + + $property = new \ReflectionProperty(App::class, 'client'); + $property->setValue(NULL, $client); + + return $client; + } + + protected function removeDir(string $dir): void { + if(!is_dir($dir)) { + return; + } + foreach(scandir($dir) ?: [] as $item) { + if($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } + +}