doto 0.0.1.pre.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. checksums.yaml +7 -0
  2. data/.env.test +1 -0
  3. data/.reek.yml +20 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +206 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +7 -0
  8. data/CODE_OF_CONDUCT.md +84 -0
  9. data/Gemfile +30 -0
  10. data/Gemfile.lock +179 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +38 -0
  13. data/Rakefile +16 -0
  14. data/bin/console +36 -0
  15. data/bin/doto +3 -0
  16. data/bin/setup +18 -0
  17. data/exe/doto +33 -0
  18. data/lib/core/ruby/color_theme_colors.rb +16 -0
  19. data/lib/core/ruby/color_theme_mode.rb +42 -0
  20. data/lib/core/ruby/wrap_and_join.rb +31 -0
  21. data/lib/doto/base_cli.rb +56 -0
  22. data/lib/doto/cli.rb +131 -0
  23. data/lib/doto/command_services/add_entry_service.rb +50 -0
  24. data/lib/doto/crud/json_file.rb +161 -0
  25. data/lib/doto/env.rb +44 -0
  26. data/lib/doto/migration/base_service.rb +118 -0
  27. data/lib/doto/migration/migrator.rb +24 -0
  28. data/lib/doto/migration/raw_helpers/color_theme_hash.rb +13 -0
  29. data/lib/doto/migration/raw_helpers/configuration_hash.rb +15 -0
  30. data/lib/doto/migration/raw_helpers/entry_group_hash.rb +13 -0
  31. data/lib/doto/migration/raw_json_file.rb +15 -0
  32. data/lib/doto/migration/raw_json_files.rb +56 -0
  33. data/lib/doto/migration/v20230613121411/service.rb +94 -0
  34. data/lib/doto/migration/v20240210161248/service.rb +148 -0
  35. data/lib/doto/migration/version.rb +7 -0
  36. data/lib/doto/models/color_theme.rb +224 -0
  37. data/lib/doto/models/configuration.rb +185 -0
  38. data/lib/doto/models/entry.rb +63 -0
  39. data/lib/doto/models/entry_group.rb +223 -0
  40. data/lib/doto/models/migration_version.rb +49 -0
  41. data/lib/doto/models/project.rb +295 -0
  42. data/lib/doto/presenters/base_presenter.rb +32 -0
  43. data/lib/doto/presenters/base_presenter_ex.rb +15 -0
  44. data/lib/doto/presenters/color_theme_presenter.rb +52 -0
  45. data/lib/doto/presenters/color_theme_show_presenter.rb +55 -0
  46. data/lib/doto/presenters/configuration_presenter.rb +50 -0
  47. data/lib/doto/presenters/entry_group/list/date_presenter.rb +77 -0
  48. data/lib/doto/presenters/entry_group/list/dates_presenter.rb +60 -0
  49. data/lib/doto/presenters/entry_group/list/messages.rb +15 -0
  50. data/lib/doto/presenters/entry_group/list/nothing_to_list.rb +15 -0
  51. data/lib/doto/presenters/entry_group_presenter.rb +35 -0
  52. data/lib/doto/presenters/entry_presenter.rb +25 -0
  53. data/lib/doto/presenters/export/all_presenter.rb +44 -0
  54. data/lib/doto/presenters/export/dates_presenter.rb +55 -0
  55. data/lib/doto/presenters/import/all_presenter.rb +57 -0
  56. data/lib/doto/presenters/import/dates_presenter.rb +70 -0
  57. data/lib/doto/presenters/import/import_entry.rb +22 -0
  58. data/lib/doto/presenters/import/import_file.rb +33 -0
  59. data/lib/doto/presenters/project/create_presenter.rb +44 -0
  60. data/lib/doto/presenters/project/defaultable.rb +15 -0
  61. data/lib/doto/presenters/project/delete_by_number_presenter.rb +54 -0
  62. data/lib/doto/presenters/project/delete_presenter.rb +53 -0
  63. data/lib/doto/presenters/project/list_presenter.rb +24 -0
  64. data/lib/doto/presenters/project/rename_by_number_presenter.rb +63 -0
  65. data/lib/doto/presenters/project/rename_presenter.rb +57 -0
  66. data/lib/doto/presenters/project/use_by_number_presenter.rb +57 -0
  67. data/lib/doto/presenters/project/use_presenter.rb +56 -0
  68. data/lib/doto/services/color_theme/hydrator_service.rb +42 -0
  69. data/lib/doto/services/configuration/hydrator_service.rb +42 -0
  70. data/lib/doto/services/entry/hydrator_service.rb +33 -0
  71. data/lib/doto/services/entry_group/browse_service.rb +100 -0
  72. data/lib/doto/services/entry_group/counter_service.rb +32 -0
  73. data/lib/doto/services/entry_group/deleter_service.rb +35 -0
  74. data/lib/doto/services/entry_group/editor_service.rb +103 -0
  75. data/lib/doto/services/entry_group/exporter_service.rb +98 -0
  76. data/lib/doto/services/entry_group/hydrator_service.rb +37 -0
  77. data/lib/doto/services/entry_group/importer_service.rb +117 -0
  78. data/lib/doto/services/migration_version/hydrator_service.rb +36 -0
  79. data/lib/doto/services/project/hydrator_service.rb +40 -0
  80. data/lib/doto/services/project/rename_service.rb +70 -0
  81. data/lib/doto/services/stderr_redirector_service.rb +27 -0
  82. data/lib/doto/services/stdout_redirector_service.rb +27 -0
  83. data/lib/doto/services/temp_file/reader_service.rb +33 -0
  84. data/lib/doto/services/temp_file/writer_service.rb +35 -0
  85. data/lib/doto/subcommands/base_subcommand.rb +12 -0
  86. data/lib/doto/subcommands/browse.rb +49 -0
  87. data/lib/doto/subcommands/config.rb +81 -0
  88. data/lib/doto/subcommands/delete.rb +108 -0
  89. data/lib/doto/subcommands/edit.rb +48 -0
  90. data/lib/doto/subcommands/export.rb +62 -0
  91. data/lib/doto/subcommands/import.rb +72 -0
  92. data/lib/doto/subcommands/list.rb +95 -0
  93. data/lib/doto/subcommands/project.rb +146 -0
  94. data/lib/doto/subcommands/theme.rb +131 -0
  95. data/lib/doto/support/ask.rb +44 -0
  96. data/lib/doto/support/color_themable.rb +36 -0
  97. data/lib/doto/support/command_help_colorizeable.rb +34 -0
  98. data/lib/doto/support/command_hookable.rb +71 -0
  99. data/lib/doto/support/command_options/doto_times.rb +48 -0
  100. data/lib/doto/support/command_options/time.rb +84 -0
  101. data/lib/doto/support/command_options/time_mnemonic.rb +108 -0
  102. data/lib/doto/support/command_options/time_mnemonics.rb +16 -0
  103. data/lib/doto/support/descriptable.rb +29 -0
  104. data/lib/doto/support/entry_group_browsable.rb +104 -0
  105. data/lib/doto/support/field_errors.rb +11 -0
  106. data/lib/doto/support/fileable.rb +136 -0
  107. data/lib/doto/support/presentable.rb +11 -0
  108. data/lib/doto/support/project_file_system.rb +118 -0
  109. data/lib/doto/support/short_string.rb +24 -0
  110. data/lib/doto/support/time_comparable.rb +21 -0
  111. data/lib/doto/support/time_formatable.rb +65 -0
  112. data/lib/doto/support/times_sortable.rb +71 -0
  113. data/lib/doto/support/transform_project_name.rb +24 -0
  114. data/lib/doto/support/utils.rb +11 -0
  115. data/lib/doto/validators/color_theme_validator.rb +74 -0
  116. data/lib/doto/validators/description_validator.rb +51 -0
  117. data/lib/doto/validators/entries_validator.rb +77 -0
  118. data/lib/doto/validators/project_name_validator.rb +58 -0
  119. data/lib/doto/validators/time_validator.rb +25 -0
  120. data/lib/doto/validators/version_validator.rb +29 -0
  121. data/lib/doto/version.rb +6 -0
  122. data/lib/doto/views/base_list_view.rb +41 -0
  123. data/lib/doto/views/color_theme/index.rb +62 -0
  124. data/lib/doto/views/color_theme/show.rb +107 -0
  125. data/lib/doto/views/configuration/show.rb +41 -0
  126. data/lib/doto/views/entry_group/edit.rb +121 -0
  127. data/lib/doto/views/entry_group/list.rb +23 -0
  128. data/lib/doto/views/entry_group/shared/no_entries_to_display.rb +53 -0
  129. data/lib/doto/views/entry_group/shared/no_entries_to_display_for_month_of.rb +32 -0
  130. data/lib/doto/views/entry_group/shared/no_entries_to_display_for_week_of.rb +33 -0
  131. data/lib/doto/views/entry_group/shared/no_entries_to_display_for_year_of.rb +33 -0
  132. data/lib/doto/views/entry_group/show.rb +63 -0
  133. data/lib/doto/views/export.rb +82 -0
  134. data/lib/doto/views/import.rb +105 -0
  135. data/lib/doto/views/import_dates.rb +17 -0
  136. data/lib/doto/views/project/create.rb +87 -0
  137. data/lib/doto/views/project/delete.rb +96 -0
  138. data/lib/doto/views/project/delete_by_number.rb +19 -0
  139. data/lib/doto/views/project/list.rb +115 -0
  140. data/lib/doto/views/project/rename.rb +98 -0
  141. data/lib/doto/views/project/rename_by_number.rb +21 -0
  142. data/lib/doto/views/project/use.rb +97 -0
  143. data/lib/doto/views/project/use_by_number.rb +19 -0
  144. data/lib/doto/views/shared/error.rb +17 -0
  145. data/lib/doto/views/shared/info.rb +17 -0
  146. data/lib/doto/views/shared/message.rb +85 -0
  147. data/lib/doto/views/shared/model_errors.rb +32 -0
  148. data/lib/doto/views/shared/success.rb +17 -0
  149. data/lib/doto/views/shared/warning.rb +17 -0
  150. data/lib/doto.rb +33 -0
  151. data/lib/locales/en/active_record.yml +17 -0
  152. data/lib/locales/en/commands.yml +165 -0
  153. data/lib/locales/en/miscellaneous.yml +29 -0
  154. data/lib/locales/en/presenters.yml +19 -0
  155. data/lib/locales/en/services.yml +14 -0
  156. data/lib/locales/en/subcommands.yml +786 -0
  157. data/lib/seed_data/0/.todo +5 -0
  158. data/lib/seed_data/20230613121411/.doto +8 -0
  159. data/lib/seed_data/20230613121411/doto/migration_version.json +3 -0
  160. data/lib/seed_data/20230613121411/doto/themes/cherry.json +79 -0
  161. data/lib/seed_data/20230613121411/doto/themes/christmas.json +79 -0
  162. data/lib/seed_data/20230613121411/doto/themes/default.json +79 -0
  163. data/lib/seed_data/20230613121411/doto/themes/lemon.json +79 -0
  164. data/lib/seed_data/20230613121411/doto/themes/light.json +79 -0
  165. data/lib/seed_data/20230613121411/doto/themes/matrix.json +79 -0
  166. data/lib/seed_data/20230613121411/doto/themes/whiteout.json +79 -0
  167. data/lib/seed_data/20240210161248/.doto +9 -0
  168. data/lib/seed_data/20240210161248/doto/current_project.json +4 -0
  169. data/lib/seed_data/20240210161248/doto/migration_version.json +3 -0
  170. data/lib/seed_data/20240210161248/doto/projects/default/project.json +5 -0
  171. data/lib/seed_data/20240210161248/doto/themes/cherry.json +79 -0
  172. data/lib/seed_data/20240210161248/doto/themes/christmas.json +79 -0
  173. data/lib/seed_data/20240210161248/doto/themes/default.json +79 -0
  174. data/lib/seed_data/20240210161248/doto/themes/lemon.json +79 -0
  175. data/lib/seed_data/20240210161248/doto/themes/light.json +79 -0
  176. data/lib/seed_data/20240210161248/doto/themes/matrix.json +79 -0
  177. data/lib/seed_data/20240210161248/doto/themes/whiteout.json +79 -0
  178. data/sig/dsu.rbs +4 -0
  179. metadata +406 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require_relative '../crud/json_file'
6
+ require_relative '../migration/version'
7
+ require_relative '../models/configuration'
8
+ require_relative 'fileable'
9
+
10
+ module Doto
11
+ module Support
12
+ module ProjectFileSystem
13
+ class << self
14
+ def included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+ end
18
+
19
+ def exist?
20
+ self.class.project_file_exist?(project_name: project_name)
21
+ end
22
+ alias persisted? exist?
23
+
24
+ def project_initialized?
25
+ self.class.project_initialized?(project_name: project_name)
26
+ end
27
+
28
+ def project_number
29
+ self.class.project_number_for(project_name: project_name)
30
+ end
31
+
32
+ module ClassMethods
33
+ include Fileable
34
+
35
+ # Returns the currently selected (used) project name
36
+ # from doto/current_project.json
37
+ def current_project_name
38
+ Crud::JsonFile.read!(file_path: current_project_file).fetch(:project_name)
39
+ end
40
+
41
+ def default_project_name
42
+ return Models::Configuration::DEFAULT_CONFIGURATION[:default_project] unless Models::Configuration.exist?
43
+
44
+ Models::Configuration.new.default_project
45
+ end
46
+
47
+ def initialize_project(project_name:)
48
+ return if project_initialized?(project_name: project_name)
49
+
50
+ # TODO: Don't know if I like this here.
51
+ unless current_project_file_exist?
52
+ file_data = {
53
+ version: Doto::Migration::VERSION,
54
+ project_name: default_project_name
55
+ }
56
+ Crud::JsonFile.write!(file_data: file_data, file_path: current_project_file)
57
+ end
58
+
59
+ # Creates doto/projects/<project_name>
60
+ FileUtils.mkdir_p(project_folder_for(project_name: project_name))
61
+ end
62
+
63
+ def project_initialized?(project_name:)
64
+ # Checking these files, checks all the containing folders also
65
+ current_project_file_exist? &&
66
+ project_folder_exist?(project_name: project_name)
67
+ end
68
+
69
+ # Does doto/projects/<project_name>/project.json file exist?
70
+ def project_file_exist?(project_name:)
71
+ project_file_path = project_file_for(project_name: project_name)
72
+ File.exist?(project_file_path)
73
+ end
74
+ alias exist? project_file_exist?
75
+ alias persisted? project_file_exist?
76
+
77
+ # Does doto/current_project.json file exist?
78
+ def current_project_file_exist?
79
+ File.exist?(current_project_file)
80
+ end
81
+ alias current_project_file_persisted? current_project_file_exist?
82
+
83
+ # Does doto/projects/<project_name> folder exist?
84
+ def project_folder_exist?(project_name:)
85
+ Dir.exist?(project_folder_for(project_name: project_name))
86
+ end
87
+
88
+ def project_metadata
89
+ project_folder_names.each_with_index.with_object([]) do |(project_name, index), array|
90
+ array << {
91
+ project_number: index + 1,
92
+ project_name: project_name,
93
+ current_project: project_name == current_project_name,
94
+ default_projet: project_name == default_project_name
95
+ }
96
+ end
97
+ end
98
+
99
+ def project_number_for(project_name:)
100
+ project_metadata.find do |metadata|
101
+ metadata[:project_name] == project_name
102
+ end&.[](:project_number) || -1
103
+ end
104
+
105
+ private
106
+
107
+ def project_folder_names
108
+ Pathname.new(projects_folder)
109
+ .children
110
+ .select(&:directory?)
111
+ .map(&:basename)
112
+ .map(&:to_s)
113
+ .sort { |a, b| a.casecmp(b) }
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ module Support
5
+ module ShortString
6
+ SHORT_STRING_MAX_COUNT = 25
7
+
8
+ module_function
9
+
10
+ def short_string(string:, count: SHORT_STRING_MAX_COUNT, elipsis: '...')
11
+ return '' if string.blank?
12
+ return string if string.length <= count
13
+
14
+ # Trim to max count and cut at the last space within the limit
15
+ trimmed_string = string[0...count].rpartition(' ')[0]
16
+
17
+ # If no space found, trim by characters
18
+ trimmed_string = string[0...(count - elipsis.length)] if trimmed_string.empty? && !string.empty?
19
+
20
+ "#{trimmed_string}#{elipsis}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ module Support
5
+ module TimeComparable
6
+ TIME_COMPARABLE_FORMAT_SPECIFIER = '%Y%m%d'
7
+
8
+ module_function
9
+
10
+ def time_equal?(other_time:)
11
+ time_equal_compare_string_for(time: time) == time_equal_compare_string_for(time: other_time)
12
+ end
13
+
14
+ def time_equal_compare_string_for(time:)
15
+ time = time.in_time_zone
16
+
17
+ time.strftime(TIME_COMPARABLE_FORMAT_SPECIFIER)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'active_support/core_ext/numeric/time'
5
+
6
+ module Doto
7
+ module Support
8
+ # This module provides functions for formatting Time objects
9
+ # to display in the console.
10
+ module TimeFormatable
11
+ module_function
12
+
13
+ # TODO: I18n.
14
+ def formatted_time(time:)
15
+ time = time.in_time_zone
16
+
17
+ today_yesterday_or_tomorrow = if time.today?
18
+ 'Today'
19
+ elsif time.yesterday?
20
+ 'Yesterday'
21
+ elsif time.tomorrow?
22
+ 'Tomorrow'
23
+ end
24
+
25
+ time_zone = timezone_for(time: time)
26
+
27
+ return time.strftime("%A, %Y-%m-%d #{time_zone}") unless today_yesterday_or_tomorrow
28
+
29
+ time.strftime("%A, (#{today_yesterday_or_tomorrow}) %Y-%m-%d #{time_zone}")
30
+ end
31
+
32
+ # TODO: I18n.
33
+ def mm_dd(time:, separator: '/')
34
+ time.strftime("%m#{separator}%d")
35
+ end
36
+
37
+ # TODO: I18n.
38
+ def mm_dd_yyyy(time:, separator: '/')
39
+ time.strftime("%m#{separator}%d#{separator}%Y")
40
+ end
41
+
42
+ def dd_mm_yyyy(time:, separator: '/')
43
+ time.strftime("%d#{separator}%m#{separator}%Y")
44
+ end
45
+
46
+ def timezone_for(time:)
47
+ time.zone
48
+ end
49
+
50
+ # TODO: I18n.
51
+ def yyyy_mm_dd_or_through_for(times:)
52
+ return yyyy_mm_dd(time: times[0]) if times.one?
53
+
54
+ times = [yyyy_mm_dd(time: times.min), yyyy_mm_dd(time: times.max)]
55
+
56
+ I18n.t('information.dates.through', from: times[0], to: times[1])
57
+ end
58
+
59
+ # TODO: I18n.
60
+ def yyyy_mm_dd(time:, separator: '-')
61
+ time.strftime("%Y#{separator}%m#{separator}%d")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ module Support
5
+ module TimesSortable
6
+ def sorted_doto_times_for(times:)
7
+ configuration = Models::Configuration.new unless defined?(configuration) && configuration
8
+ entries_display_order = configuration.entries_display_order
9
+ times_sort(times: times_for(times: times), entries_display_order: entries_display_order)
10
+ end
11
+
12
+ def times_sort(times:, entries_display_order: nil)
13
+ times = times.dup
14
+ entries_display_order ||= :asc
15
+
16
+ validate_times_argument!(times: times)
17
+ validate_entries_display_order_argument!(entries_display_order: entries_display_order)
18
+
19
+ return times if times.one?
20
+
21
+ # NOTE: The times array needs to be sorted unconditionally because if
22
+ # the sort is ascending, then the times array needs to be returned
23
+ # in ascending order. If the sort is descending, then in order to
24
+ # properly reverse the times array, it needs to first be sorted in
25
+ # ascending order before being reversed.
26
+ return times.sort if entries_display_order == :asc
27
+
28
+ times.sort_by { |time| -time.to_i }
29
+ end
30
+
31
+ def times_for(times:)
32
+ times = times.dup
33
+ validate_times_argument!(times: times)
34
+
35
+ start_date = times.max
36
+ return times unless start_date.monday? || start_date.on_weekend?
37
+
38
+ # If the start date is a weekend or a Monday then we need to look back
39
+ # to include the preceeding Friday upto and including the start date.
40
+ (0..3).filter_map do |num|
41
+ time = start_date - num.days
42
+ next unless time == start_date || time.on_weekend? || time.friday?
43
+
44
+ time
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def validate_times_argument!(times:)
51
+ raise ArgumentError, "times is the wrong object type: \"#{times.class}\"" unless times.is_a?(Array)
52
+ raise ArgumentError, 'times is empty' if times.empty?
53
+ end
54
+
55
+ def validate_entries_display_order_argument!(entries_display_order:)
56
+ unless entries_display_order.nil? || entries_display_order.is_a?(Symbol)
57
+ raise ArgumentError, "entries_display_order is the wrong object type: \"#{entries_display_order.class}\""
58
+ end
59
+
60
+ unless %i[asc desc].include?(entries_display_order)
61
+ raise ArgumentError, "entries_display_order is invalid: \":#{entries_display_order}\""
62
+ end
63
+ end
64
+
65
+ # NOTE: This, as opposed to using module_function, so that we can
66
+ # invoke .validate_times_sort_arguments! from the .times_sort
67
+ # method with module as the receiver AND when included as a mixin.
68
+ extend self
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ module Support
5
+ module TransformProjectName
6
+ TRANSFORM_PROJECT_NAME_REGEX = %r{[^/\w\s]|_}
7
+ TRANSFORM_PROJECT_NAME_SEPARATOR = '-'
8
+
9
+ module_function
10
+
11
+ def transform_project_name(project_name, options: {})
12
+ normalized_name = project_name
13
+ .gsub(TRANSFORM_PROJECT_NAME_REGEX, ' ') # Replace non-word characters and underscores with space
14
+ .strip # Remove leading and trailing spaces
15
+ .squeeze(' ') # Convert consecutive spaces to a single space
16
+ .tr(' ', TRANSFORM_PROJECT_NAME_SEPARATOR) # Replace spaces with hyphens
17
+ .squeeze(TRANSFORM_PROJECT_NAME_SEPARATOR) # Ensure no consecutive hyphens
18
+
19
+ normalized_name.downcase! if options[:downcase]
20
+ normalized_name
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ module Utils
5
+ class << self
6
+ def strip_escapes(escaped_string)
7
+ escaped_string.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'colorize'
4
+
5
+ # rubocop:disable Layout/LineLength
6
+ module Doto
7
+ module Validators
8
+ class ColorThemeValidator < ActiveModel::Validator
9
+ def validate(record)
10
+ default_theme_colors = record.class::DEFAULT_THEME_COLORS
11
+
12
+ # return unless validate_color_theme_keys!(record, default_theme_colors)
13
+
14
+ default_theme_colors.each_key do |theme_color_key|
15
+ theme_colors_hash = record.public_send(theme_color_key)
16
+
17
+ next unless validate_theme_color_type!(record, theme_color_key, theme_colors_hash)
18
+
19
+ if theme_colors_hash.empty?
20
+ record.errors.add(:base, ":#{theme_color_key} colors Hash is empty")
21
+ next
22
+ end
23
+
24
+ validate_theme_colors!(record, theme_colors_hash)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def colors
31
+ @colors ||= String.colors
32
+ end
33
+
34
+ def modes
35
+ @modes ||= String.modes
36
+ end
37
+
38
+ def validate_theme_color_type!(record, theme_color_key, theme_colors_hash)
39
+ return true if theme_colors_hash.is_a?(Hash)
40
+
41
+ record.errors.add(:base, ":#{theme_color_key} value is the wrong object type. " \
42
+ "\"Hash\" was expected, but \"#{theme_colors_hash.class}\" was received.")
43
+ false
44
+ end
45
+
46
+ def validate_theme_colors!(record, theme_colors_hash)
47
+ unless colors.include?(theme_colors_hash[:color])
48
+ value = theme_color_value_to_s(theme_colors_hash[:color])
49
+ record.errors.add(:base, ":color key value #{value} in theme color Hash #{theme_colors_hash} is not a valid color. " \
50
+ "One of #{colors.wrap_and_join(wrapper: [':', ''])} was expected, but #{value} was received.")
51
+ end
52
+
53
+ unless theme_colors_hash[:mode].nil? || modes.include?(theme_colors_hash[:mode])
54
+ value = theme_color_value_to_s(theme_colors_hash[:mode])
55
+ record.errors.add(:base, ":mode key value #{value} in theme color Hash #{theme_colors_hash} is not a valid mode value. " \
56
+ "One of #{modes.wrap_and_join(wrapper: [':', ''])} was expected, but #{value} was received.")
57
+ end
58
+
59
+ unless theme_colors_hash[:background].nil? || colors.include?(theme_colors_hash[:background])
60
+ value = theme_color_value_to_s(theme_colors_hash[:background])
61
+ record.errors.add(:base, ":background key value #{value} in theme color Hash #{theme_colors_hash} is not a valid color. " \
62
+ "One of #{colors.wrap_and_join(wrapper: [':', ''])} was expected, but #{value} was received.")
63
+ end
64
+ end
65
+
66
+ def theme_color_value_to_s(theme_color_value)
67
+ return ":#{theme_color_value}" if theme_color_value.is_a?(Symbol)
68
+
69
+ "'#{theme_color_value}'"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ # rubocop:enable Layout/LineLength
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ module Validators
5
+ class DescriptionValidator < ActiveModel::Validator
6
+ def validate(record)
7
+ description = record.description
8
+
9
+ if description.blank?
10
+ record.errors.add(:description, :blank)
11
+ return
12
+ end
13
+
14
+ unless description.is_a?(String)
15
+ # TODO: I18n.
16
+ record.errors.add(:description, 'is the wrong object type. ' \
17
+ "\"String\" was expected, but \"#{description.class}\" was received.")
18
+ return
19
+ end
20
+
21
+ validate_description record
22
+ end
23
+
24
+ private
25
+
26
+ def validate_description(record)
27
+ description = record.description
28
+
29
+ return if description.length.between?(min_description_length(record), max_description_length(record))
30
+
31
+ if description.length < min_description_length(record)
32
+ # TODO: I18n.
33
+ record.errors.add(:description, "is too short: \"#{record.short_description}\" " \
34
+ "(minimum is #{min_description_length(record)} characters).")
35
+ elsif description.length > max_description_length(record)
36
+ # TODO: I18n.
37
+ record.errors.add(:description, "is too long: \"#{record.short_description}\" " \
38
+ "(maximum is #{max_description_length(record)} characters).")
39
+ end
40
+ end
41
+
42
+ def min_description_length(record)
43
+ record.class::MIN_DESCRIPTION_LENGTH
44
+ end
45
+
46
+ def max_description_length(record)
47
+ record.class::MAX_DESCRIPTION_LENGTH
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../models/entry'
4
+ require_relative '../support/field_errors'
5
+
6
+ # https://guides.rubyonrails.org/active_record_validations.html#validates-with
7
+ module Doto
8
+ module Validators
9
+ # TODO: I18n.
10
+ class EntriesValidator < ActiveModel::Validator
11
+ include Support::FieldErrors
12
+
13
+ def validate(record)
14
+ unless record.entries.is_a?(Array)
15
+ record.errors.add(:entries, 'is the wrong object type. ' \
16
+ "\"Array\" was expected, but \"#{record.entries.class}\" was received.")
17
+ return
18
+ end
19
+
20
+ return if validate_entry_types record
21
+
22
+ validate_unique_entry record
23
+ validate_entries record
24
+ end
25
+
26
+ private
27
+
28
+ def validate_entry_types(record)
29
+ record.entries.each do |entry|
30
+ next if entry.is_a? Doto::Models::Entry
31
+
32
+ record.errors.add(:entries, 'Array element is the wrong object type. ' \
33
+ "\"Entry\" was expected, but \"#{entry.class}\" was received.",
34
+ type: Support::FieldErrors::FIELD_TYPE_ERROR)
35
+ end
36
+
37
+ record.errors.any?
38
+ end
39
+
40
+ def validate_unique_entry(record)
41
+ return unless record.entries.is_a? Array
42
+
43
+ entry_objects = record.entries.select { |entry| entry.is_a?(Doto::Models::Entry) }
44
+
45
+ descriptions = entry_objects.map(&:description)
46
+ return if descriptions.uniq.length == descriptions.length
47
+
48
+ non_unique_descriptions = descriptions.select { |description| descriptions.count(description) > 1 }.uniq
49
+ non_unique_descriptions.each do |non_unique_description|
50
+ # TODO: I18n.
51
+ record.errors.add(:entries,
52
+ "array contains duplicate entry: \"#{short_description(non_unique_description)}\".",
53
+ type: Support::FieldErrors::FIELD_DUPLICATE_ERROR)
54
+ end
55
+
56
+ record.errors.any?
57
+ end
58
+
59
+ def validate_entries(record)
60
+ entries = record.entries
61
+ return if entries.none?
62
+
63
+ entries.each do |entry|
64
+ next if entry.valid?
65
+
66
+ entry.errors.each do |error|
67
+ record.errors.add(:entries_entry, error.full_message)
68
+ end
69
+ end
70
+ end
71
+
72
+ def short_description(description)
73
+ Models::Entry.short_description(string: description)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/field_errors'
4
+ require_relative '../support/short_string'
5
+
6
+ # https://guides.rubyonrails.org/active_record_validations.html#validates-with
7
+ module Doto
8
+ module Validators
9
+ # TODO: I18n.
10
+ class ProjectNameValidator < ActiveModel::Validator
11
+ include Support::FieldErrors
12
+ include Support::ShortString
13
+
14
+ def validate(record)
15
+ unless record.project_name.is_a?(String)
16
+ record.errors.add(:project_name, 'is the wrong object type. ' \
17
+ "\"String\" was expected, but \"#{record.project.class}\" was received.")
18
+ return
19
+ end
20
+
21
+ unless record.project_name.present?
22
+ record.errors.add(:project_name, :blank, '')
23
+
24
+ return
25
+ end
26
+
27
+ validate_project_name record
28
+ end
29
+
30
+ private
31
+
32
+ def validate_project_name(record)
33
+ project_name = record.project_name
34
+
35
+ return if project_name.length.between?(min_project_name_length(record), max_project_name_length(record))
36
+
37
+ if project_name.length < min_project_name_length(record)
38
+ # TODO: I18n.
39
+ record.errors.add(:project_name, "is too short: \"#{record.project_name}\" " \
40
+ "(minimum is #{min_project_name_length(record)} characters).")
41
+ elsif project_name.length > max_project_name_length(record)
42
+ # TODO: I18n.
43
+ short_project_name = short_string(string: project_name, count: max_project_name_length(record))
44
+ record.errors.add(:project_name, "is too long: \"#{short_project_name}\" " \
45
+ "(maximum is #{max_project_name_length(record)} characters).")
46
+ end
47
+ end
48
+
49
+ def min_project_name_length(record)
50
+ record.class::MIN_PROJECT_NAME_LENGTH
51
+ end
52
+
53
+ def max_project_name_length(record)
54
+ record.class::MAX_PROJECT_NAME_LENGTH
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # https://guides.rubyonrails.org/active_record_validations.html#validates-with
4
+ module Doto
5
+ module Validators
6
+ class TimeValidator < ActiveModel::Validator
7
+ def validate(record)
8
+ time = record.time
9
+
10
+ if time.nil?
11
+ record.errors.add(:time, :blank)
12
+ return
13
+ end
14
+
15
+ unless time.is_a?(Time)
16
+ record.errors.add(:time, 'is the wrong object type. ' \
17
+ "\"Time\" was expected, but \"#{time.class}\" was received.")
18
+ return
19
+ end
20
+
21
+ record.errors.add(:time, 'is not in localtime format.') unless time == time.in_time_zone
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # https://guides.rubyonrails.org/active_record_validations.html#validates-with
4
+ module Doto
5
+ module Validators
6
+ class VersionValidator < ActiveModel::Validator
7
+ def validate(record)
8
+ version = record.version
9
+
10
+ if version.nil?
11
+ record.errors.add(:version, 'is nil')
12
+ return
13
+ end
14
+
15
+ unless version.is_a?(Integer)
16
+ record.errors.add(:version, 'is the wrong object type. ' \
17
+ "\"Integer\" was expected, but \"#{version.class}\" was received.")
18
+ nil
19
+ end
20
+
21
+ # TODO: This validation should check the configuration version
22
+ # against the current migration version and they should match.
23
+ # unless version == record.class::VERSION
24
+ # record.errors.add(:version, "\"#{version}\" is not the correct version: \"#{record.class::VERSION}\"")
25
+ # end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doto
4
+ VERSION_REGEX = /\A\d+\.\d+\.\d+(\.(alpha|beta|rc)\.\d+)?\z/
5
+ VERSION = '0.0.1-alpha.1'
6
+ end