dsu 2.4.4 → 3.0.0.alpha.0

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -0
  3. data/CHANGELOG.md +42 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +2 -2
  6. data/Rakefile +6 -0
  7. data/current_project.bak +4 -0
  8. data/lib/dsu/cli.rb +24 -6
  9. data/lib/dsu/crud/json_file.rb +3 -0
  10. data/lib/dsu/migration/version.rb +1 -1
  11. data/lib/dsu/models/color_theme.rb +7 -58
  12. data/lib/dsu/models/configuration.rb +18 -3
  13. data/lib/dsu/models/entry_group.rb +0 -7
  14. data/lib/dsu/models/migration_version.rb +0 -1
  15. data/lib/dsu/models/project.rb +295 -0
  16. data/lib/dsu/presenters/base_presenter_ex.rb +1 -12
  17. data/lib/dsu/presenters/export/all_presenter.rb +14 -19
  18. data/lib/dsu/presenters/export/dates_presenter.rb +17 -20
  19. data/lib/dsu/presenters/import/all_presenter.rb +20 -25
  20. data/lib/dsu/presenters/import/dates_presenter.rb +25 -27
  21. data/lib/dsu/presenters/import/import_entry.rb +22 -0
  22. data/lib/dsu/presenters/import/import_file.rb +9 -1
  23. data/lib/dsu/presenters/project/create_presenter.rb +44 -0
  24. data/lib/dsu/presenters/project/delete_by_number_presenter.rb +54 -0
  25. data/lib/dsu/presenters/project/delete_presenter.rb +53 -0
  26. data/lib/dsu/presenters/project/list_presenter.rb +24 -0
  27. data/lib/dsu/presenters/project/rename_by_number_presenter.rb +63 -0
  28. data/lib/dsu/presenters/project/rename_presenter.rb +57 -0
  29. data/lib/dsu/presenters/project/use_by_number_presenter.rb +53 -0
  30. data/lib/dsu/presenters/project/use_presenter.rb +52 -0
  31. data/lib/dsu/services/entry_group/exporter_service.rb +22 -5
  32. data/lib/dsu/services/entry_group/importer_service.rb +41 -8
  33. data/lib/dsu/services/project/hydrator_service.rb +40 -0
  34. data/lib/dsu/services/project/rename_service.rb +70 -0
  35. data/lib/dsu/subcommands/export.rb +4 -2
  36. data/lib/dsu/subcommands/import.rb +7 -3
  37. data/lib/dsu/subcommands/project.rb +149 -0
  38. data/lib/dsu/support/ask.rb +10 -3
  39. data/lib/dsu/support/color_themable.rb +1 -1
  40. data/lib/dsu/support/command_hookable.rb +7 -2
  41. data/lib/dsu/support/descriptable.rb +5 -21
  42. data/lib/dsu/support/fileable.rb +39 -1
  43. data/lib/dsu/support/project_file_system.rb +121 -0
  44. data/lib/dsu/support/short_string.rb +24 -0
  45. data/lib/dsu/support/time_comparable.rb +2 -0
  46. data/lib/dsu/support/transform_project_name.rb +24 -0
  47. data/lib/dsu/validators/project_name_validator.rb +58 -0
  48. data/lib/dsu/version.rb +1 -1
  49. data/lib/dsu/views/base_list_view.rb +41 -0
  50. data/lib/dsu/views/export.rb +60 -6
  51. data/lib/dsu/views/import.rb +83 -7
  52. data/lib/dsu/views/import_dates.rb +17 -0
  53. data/lib/dsu/views/project/create.rb +87 -0
  54. data/lib/dsu/views/project/delete.rb +96 -0
  55. data/lib/dsu/views/project/delete_by_number.rb +19 -0
  56. data/lib/dsu/views/project/list.rb +115 -0
  57. data/lib/dsu/views/project/rename.rb +98 -0
  58. data/lib/dsu/views/project/rename_by_number.rb +21 -0
  59. data/lib/dsu/views/project/use.rb +97 -0
  60. data/lib/dsu/views/project/use_by_number.rb +19 -0
  61. data/lib/dsu.rb +2 -10
  62. data/lib/locales/en/active_record.yml +9 -0
  63. data/lib/locales/en/commands.yml +9 -3
  64. data/lib/locales/en/miscellaneous.yml +4 -0
  65. data/lib/locales/en/services.yml +4 -0
  66. data/lib/locales/en/subcommands.yml +247 -15
  67. data/project.bak +0 -0
  68. metadata +34 -9
  69. data/lib/dsu/presenters/export/messages.rb +0 -32
  70. data/lib/dsu/presenters/export/nothing_to_export.rb +0 -13
  71. data/lib/dsu/presenters/export/service_callable.rb +0 -20
  72. data/lib/dsu/presenters/import/messages.rb +0 -42
  73. data/lib/dsu/presenters/import/service_callable.rb +0 -21
@@ -23,7 +23,8 @@ module Dsu
23
23
  long_desc I18n.t('subcommands.export.all.long_desc')
24
24
  option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
25
25
  def all
26
- Views::Export.new(presenter: all_presenter(options: options)).render
26
+ options = configuration.to_h.merge(self.options).with_indifferent_access
27
+ Views::Export.new(presenter: all_presenter(options: options), options: options).render
27
28
  end
28
29
 
29
30
  desc I18n.t('subcommands.export.dates.desc'), I18n.t('subcommands.export.dates.usage')
@@ -41,7 +42,8 @@ module Dsu
41
42
  return
42
43
  end
43
44
 
44
- Views::Export.new(presenter: dates_presenter_for(from: times.min, to: times.max, options: options)).render
45
+ Views::Export.new(presenter:
46
+ dates_presenter_for(from: times.min, to: times.max, options: options), options: options).render
45
47
  rescue ArgumentError => e
46
48
  Views::Shared::Error.new(messages: e.message).render
47
49
  end
@@ -6,6 +6,7 @@ require_relative '../support/command_options/dsu_times'
6
6
  require_relative '../support/command_options/time_mnemonic'
7
7
  require_relative '../support/time_formatable'
8
8
  require_relative '../views/import'
9
+ require_relative '../views/import_dates'
9
10
  require_relative '../views/shared/error'
10
11
  require_relative 'base_subcommand'
11
12
 
@@ -23,10 +24,12 @@ module Dsu
23
24
  long_desc I18n.t('subcommands.import.all.long_desc')
24
25
  option :import_file, type: :string, required: true, aliases: '-i', banner: 'IMPORT_CVS_FILE'
25
26
  option :merge, type: :boolean, default: true, aliases: '-m'
27
+ option :override, type: :boolean, default: false, aliases: '-o'
26
28
  option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
27
29
  def all
30
+ options = configuration.to_h.merge(self.options).with_indifferent_access
28
31
  Views::Import.new(presenter: all_presenter(import_file_path: options[:import_file],
29
- options: options)).render
32
+ options: options), options: options).render
30
33
  end
31
34
 
32
35
  desc I18n.t('subcommands.import.dates.desc'), I18n.t('subcommands.import.dates.usage')
@@ -37,6 +40,7 @@ module Dsu
37
40
  option :to, type: :string, required: true, aliases: '-t', banner: 'DATE|MNEMONIC'
38
41
  option :import_file, type: :string, required: true, aliases: '-i', banner: 'IMPORT_CVS_FILE'
39
42
  option :merge, type: :boolean, default: true, aliases: '-m'
43
+ option :override, type: :boolean, default: false, aliases: '-o'
40
44
  option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
41
45
  def dates
42
46
  options = configuration.to_h.merge(self.options).with_indifferent_access
@@ -46,10 +50,10 @@ module Dsu
46
50
  return
47
51
  end
48
52
 
49
- Views::Import.new(presenter: dates_presenter_for(from: times.min,
53
+ Views::ImportDates.new(presenter: dates_presenter_for(from: times.min,
50
54
  to: times.max,
51
55
  import_file_path: options[:import_file],
52
- options: options)).render
56
+ options: options), options: options).render
53
57
  rescue ArgumentError => e
54
58
  Views::Shared::Error.new(messages: e.message).render
55
59
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../presenters/project/create_presenter'
4
+ require_relative '../presenters/project/delete_presenter'
5
+ require_relative '../presenters/project/list_presenter'
6
+ require_relative '../presenters/project/use_by_number_presenter'
7
+ require_relative '../presenters/project/use_presenter'
8
+ require_relative '../views/project/create'
9
+ require_relative '../views/project/use'
10
+ require_relative '../views/project/use_by_number'
11
+ require_relative '../views/shared/error'
12
+ require_relative 'base_subcommand'
13
+
14
+ module Dsu
15
+ module Subcommands
16
+ class Project < BaseSubcommand
17
+ # TODO: I18n.
18
+ map %w[c] => :create
19
+ map %w[d] => :delete
20
+ map %w[l] => :list
21
+ map %w[r] => :rename
22
+ map %w[u] => :use
23
+
24
+ desc I18n.t('subcommands.project.create.desc'), I18n.t('subcommands.project.create.usage')
25
+ long_desc I18n.t('subcommands.project.create.long_desc')
26
+ option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
27
+ def create(project_name = nil, description = nil)
28
+ project_name = project_name.to_s.strip
29
+ description = description.to_s.strip
30
+ if project_name.blank?
31
+ return Views::Shared::Error.new(
32
+ messages: I18n.t('subcommands.project.messages.project_name_blank')
33
+ ).render
34
+ end
35
+
36
+ options = configuration.to_h.merge(self.options).with_indifferent_access
37
+ presenter = Presenters::Project::CreatePresenter.new(project_name: project_name,
38
+ description: description, options: options)
39
+ Views::Project::Create.new(presenter: presenter, options: options).render
40
+ end
41
+
42
+ desc I18n.t('subcommands.project.delete.desc'), I18n.t('subcommands.project.delete.usage')
43
+ long_desc I18n.t('subcommands.project.delete.long_desc')
44
+ option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
45
+ def delete(project_name_or_number = nil)
46
+ options = configuration.to_h.merge(self.options).with_indifferent_access
47
+ presenter = delete_presenter_for(project_name_or_number, options: options)
48
+ delete_view_for(project_name_or_number, presenter: presenter, options: options).render
49
+ end
50
+
51
+ desc I18n.t('subcommands.project.list.desc'), I18n.t('subcommands.project.list.usage')
52
+ long_desc I18n.t('subcommands.project.list.long_desc')
53
+ option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
54
+ def list
55
+ options = configuration.to_h.merge(self.options).with_indifferent_access
56
+ presenter = Presenters::Project::ListPresenter.new(options: options)
57
+ Views::Project::List.new(presenter: presenter, options: options).render
58
+ end
59
+
60
+ desc I18n.t('subcommands.project.rename.desc'), I18n.t('subcommands.project.rename.usage')
61
+ long_desc I18n.t('subcommands.project.rename.long_desc')
62
+ option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
63
+ def rename(project_name_or_number = nil, new_project_name = nil, new_project_description = nil)
64
+ project_name_or_number = project_name_or_number.to_s.strip
65
+ new_project_name = new_project_name&.to_s&.strip
66
+ new_project_description = new_project_description&.to_s&.strip
67
+
68
+ if new_project_name.blank?
69
+ return Views::Shared::Error.new(
70
+ messages: I18n.t('subcommands.project.messages.new_project_name_blank')
71
+ ).render
72
+ end
73
+
74
+ options = configuration.to_h.merge(self.options).with_indifferent_access
75
+ presenter = rename_presenter_for(project_name_or_number, new_project_name: new_project_name,
76
+ new_project_description: new_project_description, options: options)
77
+ rename_view_for(project_name_or_number, presenter: presenter, options: options).render
78
+ end
79
+
80
+ desc I18n.t('subcommands.project.use.desc'), I18n.t('subcommands.project.use.usage')
81
+ long_desc I18n.t('subcommands.project.use.long_desc')
82
+ option :prompts, type: :hash, default: {}, hide: true, aliases: '-p'
83
+ def use(project_name_or_number = nil)
84
+ options = configuration.to_h.merge(self.options).with_indifferent_access
85
+ presenter = use_presenter_for(project_name_or_number, options: options)
86
+ use_view_for(project_name_or_number, presenter: presenter, options: options).render
87
+ end
88
+
89
+ private
90
+
91
+ def delete_view_for(project_name, presenter:, options:)
92
+ if project_number?(project_name)
93
+ Views::Project::DeleteByNumber.new(presenter: presenter, options: options)
94
+ else
95
+ Views::Project::Delete.new(presenter: presenter, options: options)
96
+ end
97
+ end
98
+
99
+ def delete_presenter_for(project_name, options:)
100
+ if project_number?(project_name)
101
+ Presenters::Project::DeleteByNumberPresenter.new(project_number: project_name.to_i, options: options)
102
+ else
103
+ project_name = Models::Project.default_project_name if project_name.blank?
104
+ Presenters::Project::DeletePresenter.new(project_name: project_name, options: options)
105
+ end
106
+ end
107
+
108
+ def rename_view_for(project_name, presenter:, options:)
109
+ if project_number?(project_name)
110
+ Views::Project::RenameByNumber.new(presenter: presenter, options: options)
111
+ else
112
+ Views::Project::Rename.new(presenter: presenter, options: options)
113
+ end
114
+ end
115
+
116
+ def rename_presenter_for(project_name, new_project_name:, new_project_description:, options:)
117
+ if project_number?(project_name)
118
+ Presenters::Project::RenameByNumberPresenter.new(project_number: project_name.to_i,
119
+ new_project_name: new_project_name, new_project_description: new_project_description, options: options)
120
+ else
121
+ project_name = Models::Project.default_project_name if project_name.blank?
122
+ Presenters::Project::RenamePresenter.new(project_name: project_name,
123
+ new_project_name: new_project_name, new_project_description: new_project_description, options: options)
124
+ end
125
+ end
126
+
127
+ def use_view_for(project_name, presenter:, options:)
128
+ if project_number?(project_name)
129
+ Views::Project::UseByNumber.new(presenter: presenter, options: options)
130
+ else
131
+ Views::Project::Use.new(presenter: presenter, options: options)
132
+ end
133
+ end
134
+
135
+ def use_presenter_for(project_name, options:)
136
+ if project_number?(project_name)
137
+ Presenters::Project::UseByNumberPresenter.new(project_number: project_name.to_i, options: options)
138
+ else
139
+ project_name = Models::Project.default_project_name if project_name.blank?
140
+ Presenters::Project::UsePresenter.new(project_name: project_name, options: options)
141
+ end
142
+ end
143
+
144
+ def project_number?(project_name)
145
+ /^[+-]?\d+(\.\d+)?$/.match?(project_name.to_s)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
3
4
  require 'thor'
4
5
 
5
6
  module Dsu
6
7
  module Support
7
8
  module Ask
8
- def ask(prompt)
9
- options = {}
10
- Thor::LineEditor.readline(prompt, options)
9
+ def ask_while(prompt, options: {}) # rubocop:disable Lint/UnusedMethodArgument
10
+ loop do
11
+ print prompt
12
+ char = $stdin.getch
13
+ puts char
14
+ return char if yield(char)
15
+
16
+ char
17
+ end
11
18
  end
12
19
 
13
20
  def yes?(prompt, options: {})
@@ -8,7 +8,7 @@ module Dsu
8
8
  def prompt_with_options(prompt:, options:)
9
9
  # HACK: This module needs to be refactored to be more generic.
10
10
  target_color_theme = defined?(color_theme) ? color_theme : self
11
- options = "[#{options.join('/')}]"
11
+ options = "[#{options.join(',')}]"
12
12
  "#{apply_theme(prompt, theme_color: target_color_theme.prompt)} " \
13
13
  "#{apply_theme(options, theme_color: target_color_theme.prompt_options)}" \
14
14
  "#{apply_theme('>', theme_color: target_color_theme.prompt)}"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../env'
4
4
  require_relative '../models/color_theme'
5
+ require_relative '../models/project'
5
6
  require_relative '../services/stderr_redirector_service'
6
7
  require_relative '../views/shared/error'
7
8
  require_relative 'color_themable'
@@ -34,14 +35,18 @@ module Dsu
34
35
  end
35
36
 
36
37
  def display_dsu_footer
37
- puts apply_theme('_' * 35, theme_color: color_theme.dsu_footer)
38
+ puts apply_theme('_' * 50, theme_color: color_theme.dsu_footer)
38
39
  # TODO: I18n.
39
- puts apply_theme("dsu | Version: #{Dsu::VERSION} | Theme: #{color_theme.theme_name}",
40
+ puts apply_theme("dsu v#{Dsu::VERSION} | Project: #{project} | Theme: #{color_theme.theme_name}",
40
41
  theme_color: color_theme.dsu_footer)
41
42
  end
42
43
 
43
44
  private
44
45
 
46
+ def project
47
+ Models::Project.current_project_name
48
+ end
49
+
45
50
  def suspend_header?(args, _options)
46
51
  return false unless args.count > 1
47
52
 
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'short_string'
4
+
3
5
  module Dsu
4
6
  module Support
5
7
  module Descriptable
6
- DESCRIPTION_MAX_COUNT = 25
7
-
8
8
  class << self
9
9
  def included(base)
10
10
  base.extend(ClassMethods)
@@ -18,26 +18,10 @@ module Dsu
18
18
  end
19
19
 
20
20
  module ClassMethods
21
- def short_description(string:, count: DESCRIPTION_MAX_COUNT, elipsis: '...')
22
- return elipsis unless string.is_a?(String)
23
-
24
- elipsis_length = elipsis.length
25
- count = elipsis_length if count.nil? || count < elipsis_length
26
-
27
- return string if string.length <= count
28
-
29
- tokens = string.split
30
- string = ''
31
-
32
- return "#{tokens.first[0...(count - elipsis_length)]}#{elipsis}" if tokens.count == 1
33
-
34
- tokens.each do |token|
35
- break if string.length + token.length + elipsis_length > count
36
-
37
- string = "#{string} #{token}"
38
- end
21
+ include ShortString
39
22
 
40
- "#{string.strip}#{elipsis}"
23
+ def short_description(string:, count: ShortString::SHORT_STRING_MAX_COUNT, elipsis: '...')
24
+ short_string(string: string, count: count, elipsis: elipsis)
41
25
  end
42
26
  end
43
27
  end
@@ -26,7 +26,8 @@ module Dsu
26
26
  # Entries
27
27
 
28
28
  def entries_folder
29
- File.join(dsu_folder, 'entries')
29
+ project_folder = project_folder_for(project_name: Models::Project.current_project_name)
30
+ File.join(project_folder, 'entries')
30
31
  end
31
32
 
32
33
  def entries_file_name(time:, file_name_format: nil)
@@ -88,6 +89,43 @@ module Dsu
88
89
  File.join(gem_dir, 'lib/seed_data')
89
90
  end
90
91
 
92
+ # Projects
93
+
94
+ # Returns the folder where all the projects are stored.
95
+ def projects_folder
96
+ File.join(dsu_folder, 'projects')
97
+ end
98
+
99
+ # Current project
100
+
101
+ # Contains the name of the file that contains the current
102
+ # dsu project currently being used.
103
+ def current_project_file_name
104
+ 'current_project.json'
105
+ end
106
+
107
+ # The complete path to the current project file.
108
+ def current_project_file
109
+ File.join(dsu_folder, current_project_file_name)
110
+ end
111
+
112
+ # Project helpers
113
+
114
+ # Returns the path of the project with the given name.
115
+ def project_folder_for(project_name:)
116
+ raise I18n.t('errors.project_name_invalid', project_name: '{{blank}}') if project_name.blank?
117
+
118
+ File.join(projects_folder, project_name)
119
+ end
120
+ alias project_folder project_folder_for
121
+
122
+ def project_file_for(project_name:)
123
+ project_folder = project_folder_for(project_name: project_name)
124
+
125
+ File.join(project_folder, 'project.json')
126
+ end
127
+ alias project_file project_file_for
128
+
91
129
  extend self # rubocop:disable Style/ModuleFunction
92
130
  end
93
131
  end
@@ -0,0 +1,121 @@
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 Dsu
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 dsu/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
+ Models::Configuration.new.default_project
43
+ end
44
+
45
+ def initialize_project(project_name:)
46
+ return if project_initialized?(project_name: project_name)
47
+
48
+ # TODO: Don't know if I like this here.
49
+ unless current_project_file_exist?
50
+ file_data = {
51
+ version: Dsu::Migration::VERSION,
52
+ project_name: default_project_name
53
+ }
54
+ Crud::JsonFile.write!(file_data: file_data, file_path: current_project_file)
55
+ end
56
+
57
+ # Creates dsu/projects/<project_name>
58
+ FileUtils.mkdir_p(project_folder_for(project_name: project_name))
59
+ end
60
+
61
+ def project_initialized?(project_name:)
62
+ # Checking these files, checks all the containing folders also
63
+ current_project_file_exist? &&
64
+ project_folder_exist?(project_name: project_name)
65
+ end
66
+
67
+ # Does dsu/projects/<project_name>/project.json file exist?
68
+ def project_file_exist?(project_name:)
69
+ project_file_path = project_file_for(project_name: project_name)
70
+ File.exist?(project_file_path)
71
+ end
72
+ alias exist? project_file_exist?
73
+ alias persisted? project_file_exist?
74
+
75
+ # Does dsu/current_project.json file exist?
76
+ def current_project_file_exist?
77
+ File.exist?(current_project_file)
78
+ end
79
+ alias current_project_file_persisted? current_project_file_exist?
80
+
81
+ # Does dsu/projects folder exist?
82
+ def projects_folder_exist?
83
+ Dir.exist?(projects_folder)
84
+ end
85
+
86
+ # Does dsu/projects/<project_name> folder exist?
87
+ def project_folder_exist?(project_name:)
88
+ Dir.exist?(project_folder_for(project_name: project_name))
89
+ end
90
+
91
+ def project_metadata
92
+ project_folder_names.each_with_index.with_object([]) do |(project_name, index), array|
93
+ array << {
94
+ project_number: index + 1,
95
+ project_name: project_name,
96
+ current_project: project_name == current_project_name,
97
+ default_projet: project_name == default_project_name
98
+ }
99
+ end
100
+ end
101
+
102
+ def project_number_for(project_name:)
103
+ project_metadata.find do |metadata|
104
+ metadata[:project_name] == project_name
105
+ end&.[](:project_number) || -1
106
+ end
107
+
108
+ private
109
+
110
+ def project_folder_names
111
+ Pathname.new(projects_folder)
112
+ .children
113
+ .select(&:directory?)
114
+ .map(&:basename)
115
+ .map(&:to_s)
116
+ .sort { |a, b| a.casecmp(b) }
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dsu
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
@@ -5,6 +5,8 @@ module Dsu
5
5
  module TimeComparable
6
6
  TIME_COMPARABLE_FORMAT_SPECIFIER = '%Y%m%d'
7
7
 
8
+ module_function
9
+
8
10
  def time_equal?(other_time:)
9
11
  time_equal_compare_string_for(time: time) == time_equal_compare_string_for(time: other_time)
10
12
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dsu
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,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 Dsu
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
data/lib/dsu/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Dsu
4
4
  VERSION_REGEX = /\A\d+\.\d+\.\d+(\.(alpha|rc)\.\d+)?\z/
5
- VERSION = '2.4.4'
5
+ VERSION = '3.0.0.alpha.0'
6
6
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../env'
4
+ require_relative '../models/color_theme'
5
+ require_relative '../support/color_themable'
6
+
7
+ module Dsu
8
+ module Views
9
+ class BaseListView
10
+ include Support::ColorThemable
11
+
12
+ attr_reader :presenter
13
+
14
+ def initialize(presenter:, options: {})
15
+ @presenter = presenter
16
+ @options = options&.dup || {}
17
+ @color_theme = Models::ColorTheme.find(theme_name: theme_name)
18
+ end
19
+
20
+ def render
21
+ yield
22
+ rescue StandardError => e
23
+ puts apply_theme(e.message, theme_color: color_theme.error)
24
+ puts apply_theme(e.backtrace_locations.join("\n"), theme_color: color_theme.error) if Dsu.env.local?
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :color_theme, :options
30
+
31
+ def theme_name
32
+ @theme_name ||= options.fetch(:theme_name, Models::Configuration.new.theme_name)
33
+ end
34
+
35
+ def formatted_index(index:)
36
+ apply_theme("#{format('%02s', index + 1)}. ",
37
+ theme_color: color_theme.index)
38
+ end
39
+ end
40
+ end
41
+ end