na 1.2.80 → 1.2.81

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.
data/lib/na/todo.rb CHANGED
@@ -4,28 +4,28 @@ module NA
4
4
  class Todo
5
5
  attr_accessor :actions, :projects, :files
6
6
 
7
+ # Initialize a Todo object and parse actions/projects/files
8
+ #
9
+ # @param options [Hash] Options for parsing todo files
10
+ # @return [void]
7
11
  def initialize(options = {})
8
12
  @files, @actions, @projects = parse(options)
9
13
  end
10
14
 
11
- ##
12
- ## Read a todo file and create a list of actions
13
- ##
14
- ## @param options The options
15
- ##
16
- ## @option depth [Number] The directory depth to
17
- ## search for files
18
- ## @option done [Boolean] include @done actions
19
- ## @option query [Hash] The todo file query
20
- ## @option tag [Array] Tags to search for
21
- ## @option search [String] A search string
22
- ## @option negate [Boolean] Invert results
23
- ## @option regex [Boolean] Interpret as regular
24
- ## expression
25
- ## @option project [String] The project
26
- ## @option require_na [Boolean] Require @na tag
27
- ## @option file_path [String] file path to parse
28
- ##
15
+ # Read a todo file and create a list of actions
16
+ #
17
+ # @param options [Hash] The options
18
+ # @option options [Number] :depth The directory depth to search for files
19
+ # @option options [Boolean] :done include @done actions
20
+ # @option options [Hash] :query The todo file query
21
+ # @option options [Array] :tag Tags to search for
22
+ # @option options [String] :search A search string
23
+ # @option options [Boolean] :negate Invert results
24
+ # @option options [Boolean] :regex Interpret as regular expression
25
+ # @option options [String] :project The project
26
+ # @option options [Boolean] :require_na Require @na tag
27
+ # @option options [String] :file_path file path to parse
28
+ # @return [Array] files, actions, projects
29
29
  def parse(options)
30
30
  NA::Benchmark.measure('Todo.parse') do
31
31
  defaults = {
@@ -33,6 +33,7 @@ module NA
33
33
  done: false,
34
34
  file_path: nil,
35
35
  negate: false,
36
+ hidden: false,
36
37
  project: nil,
37
38
  query: nil,
38
39
  regex: false,
@@ -43,72 +44,86 @@ module NA
43
44
  }
44
45
 
45
46
  settings = defaults.merge(options)
47
+ # Ensure tag is always an Array
48
+ if settings[:tag].nil?
49
+ settings[:tag] = []
50
+ elsif !settings[:tag].is_a?(Array)
51
+ settings[:tag] = [settings[:tag]]
52
+ end
46
53
 
47
- actions = NA::Actions.new
48
- required = []
49
- optional = []
50
- negated = []
51
- required_tag = []
52
- optional_tag = []
53
- negated_tag = []
54
- projects = []
55
-
56
- NA.notify("Tags: #{settings[:tag]}", debug:true)
57
- NA.notify("Search: #{settings[:search]}", debug:true)
58
-
59
- settings[:tag]&.each do |t|
60
- unless t[:tag].nil?
61
- if settings[:negate]
62
- optional_tag.push(t) if t[:negate]
63
- required_tag.push(t) if t[:required] && t[:negate]
64
- negated_tag.push(t) unless t[:negate]
65
- else
66
- optional_tag.push(t) unless t[:negate] || t[:required]
67
- required_tag.push(t) if t[:required] && !t[:negate]
68
- negated_tag.push(t) if t[:negate]
54
+ actions = NA::Actions.new
55
+ required = []
56
+ optional = []
57
+ negated = []
58
+ required_tag = []
59
+ optional_tag = []
60
+ negated_tag = []
61
+ projects = []
62
+
63
+ NA.notify("Tags: #{settings[:tag]}", debug: true)
64
+ NA.notify("Search: #{settings[:search]}", debug: true)
65
+
66
+ settings[:tag]&.each do |t|
67
+ # If t is a Hash, use its keys; if String, treat as a tag string
68
+ if t.is_a?(Hash)
69
+ unless t[:tag].nil?
70
+ if settings[:negate]
71
+ optional_tag.push(t) if t[:negate]
72
+ required_tag.push(t) if t[:required] && t[:negate]
73
+ negated_tag.push(t) unless t[:negate]
74
+ else
75
+ optional_tag.push(t) unless t[:negate] || t[:required]
76
+ required_tag.push(t) if t[:required] && !t[:negate]
77
+ negated_tag.push(t) if t[:negate]
78
+ end
79
+ end
80
+ elsif t.is_a?(String)
81
+ # Treat string as a simple tag
82
+ optional_tag.push({ tag: t })
69
83
  end
70
84
  end
71
- end
72
85
 
73
- unless settings[:search].nil? || settings[:search].empty?
74
- if settings[:regex] || settings[:search].is_a?(String)
75
- if settings[:negate]
76
- negated.push(settings[:search])
86
+ unless settings[:search].nil? || settings[:search].empty?
87
+ if settings[:regex] || settings[:search].is_a?(String)
88
+ if settings[:negate]
89
+ negated.push(settings[:search])
90
+ else
91
+ optional.push(settings[:search])
92
+ required.push(settings[:search])
93
+ end
77
94
  else
78
- optional.push(settings[:search])
79
- required.push(settings[:search])
80
- end
81
- else
82
- settings[:search].each do |t|
83
- opt, req, neg = parse_search(t, settings[:negate])
84
- optional.concat(opt)
85
- required.concat(req)
86
- negated.concat(neg)
95
+ settings[:search].each do |t|
96
+ opt, req, neg = parse_search(t, settings[:negate])
97
+ optional.concat(opt)
98
+ required.concat(req)
99
+ negated.concat(neg)
100
+ end
87
101
  end
88
102
  end
89
- end
90
-
91
- # Pre-compile regexes for better performance
92
- optional = optional.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
93
- required = required.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
94
- negated = negated.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
95
-
96
- files = if !settings[:file_path].nil?
97
- [settings[:file_path]]
98
- elsif settings[:query].nil?
99
- NA.find_files(depth: settings[:depth])
100
- else
101
- NA.match_working_dir(settings[:query])
102
- end
103
103
 
104
- NA.notify("Files: #{files.join(', ')}", debug: true)
105
- # Cache project regex compilation outside the line loop for better performance
106
- project_regex = if settings[:project]
107
- rx = settings[:project].split(%r{[/:]}).join('.*?/')
108
- Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
109
- end
104
+ # Pre-compile regexes for better performance
105
+ optional = optional.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
106
+ required = required.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
107
+ negated = negated.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
108
+
109
+ files = if !settings[:file_path].nil?
110
+ [settings[:file_path]]
111
+ elsif settings[:query].nil?
112
+ NA.find_files(depth: settings[:depth], include_hidden: settings[:hidden])
113
+ else
114
+ NA.match_working_dir(settings[:query])
115
+ end
116
+
117
+ NA.notify("Files: #{files.join(', ')}", debug: true)
118
+ # Cache project regex compilation outside the line loop for better performance
119
+ project_regex = if settings[:project]
120
+ rx = settings[:project].split(%r{[/:]}).join('.*?/')
121
+ Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
122
+ end
110
123
 
111
124
  files.each do |file|
125
+ next if File.directory?(file)
126
+
112
127
  NA::Benchmark.measure("Parse file: #{File.basename(file)}") do
113
128
  NA.save_working_dir(File.expand_path(file))
114
129
  content = file.read_file
@@ -116,60 +131,58 @@ module NA
116
131
  parent = []
117
132
  in_yaml = false
118
133
  in_action = false
119
- content.split(/\n/).each.with_index do |line, idx|
120
- if in_yaml && line !~ /^(---|~~~)\s*$/
121
- NA.notify("YAML: #{line}", debug: true)
122
- elsif line =~ /^(---|~~~)\s*$/
123
- in_yaml = !in_yaml
124
- elsif line.project? && !in_yaml
125
- in_action = false
126
- proj = line.project
127
- indent = line.indent_level
128
-
129
- if indent.zero? # top level project
130
- parent = [proj]
131
- elsif indent <= indent_level # if indent level is same or less, split parent before indent level and append
132
- parent.slice!(indent, parent.count - indent)
133
- parent.push(proj)
134
- else # if indent level is greater, append project to parent
135
- parent.push(proj)
136
- end
137
-
138
- projects.push(NA::Project.new(parent.join(':'), indent, idx, idx))
139
-
140
- indent_level = indent
141
- elsif line.blank?
142
- in_action = false # Comment out to allow line breaks in of notes, which isn't TaskPaper-compatible
143
- elsif line.action?
144
- in_action = false
145
-
146
- # Early exits before creating Action object
147
- next if line.done? && !settings[:done]
148
-
149
- next if settings[:require_na] && !line.na?
150
-
151
- if project_regex
152
- next unless parent.join('/') =~ project_regex
153
- end
154
-
155
- # Only create Action if we passed basic filters
156
- action = line.action
157
- new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
158
-
159
- projects[-1].last_line = idx if projects.count.positive?
160
-
161
- # Tag matching
162
- has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
163
- next if has_tag && !new_action.tags_match?(any: optional_tag,
164
- all: required_tag,
165
- none: negated_tag)
166
-
167
- actions.push(new_action)
168
- in_action = true
169
- elsif in_action
170
- actions[-1].note.push(line.strip) if actions.count.positive?
171
- projects[-1].last_line = idx if projects.count.positive?
172
- end
134
+ content.split("\n").each.with_index do |line, idx|
135
+ if in_yaml && line !~ /^(---|~~~)\s*$/
136
+ NA.notify("YAML: #{line}", debug: true)
137
+ elsif line =~ /^(---|~~~)\s*$/
138
+ in_yaml = !in_yaml
139
+ elsif line.project? && !in_yaml
140
+ in_action = false
141
+ proj = line.project
142
+ indent = line.indent_level
143
+
144
+ if indent.zero? # top level project
145
+ parent = [proj]
146
+ elsif indent <= indent_level # if indent level is same or less, split parent before indent level and append
147
+ parent.slice!(indent, parent.count - indent)
148
+ parent.push(proj)
149
+ else # if indent level is greater, append project to parent
150
+ parent.push(proj)
151
+ end
152
+
153
+ projects.push(NA::Project.new(parent.join(':'), indent, idx, idx))
154
+
155
+ indent_level = indent
156
+ elsif line.blank?
157
+ in_action = false # Comment out to allow line breaks in of notes, which isn't TaskPaper-compatible
158
+ elsif line.action?
159
+ in_action = false
160
+
161
+ # Early exits before creating Action object
162
+ next if line.done? && !settings[:done]
163
+
164
+ next if settings[:require_na] && !line.na?
165
+
166
+ next if project_regex && parent.join('/') !~ project_regex
167
+
168
+ # Only create Action if we passed basic filters
169
+ action = line.action
170
+ new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
171
+
172
+ projects[-1].last_line = idx if projects.count.positive?
173
+
174
+ # Tag matching
175
+ has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
176
+ next if has_tag && !new_action.tags_match?(any: optional_tag,
177
+ all: required_tag,
178
+ none: negated_tag)
179
+
180
+ actions.push(new_action)
181
+ in_action = true
182
+ elsif in_action
183
+ actions[-1].note.push(line.strip) if actions.count.positive?
184
+ projects[-1].last_line = idx if projects.count.positive?
185
+ end
173
186
  end
174
187
  projects = projects.dup
175
188
  end
@@ -179,9 +192,9 @@ module NA
179
192
  actions.delete_if do |new_action|
180
193
  has_search = !optional.empty? || !required.empty? || !negated.empty?
181
194
  has_search && !new_action.search_match?(any: optional,
182
- all: required,
183
- none: negated,
184
- include_note: settings[:search_note])
195
+ all: required,
196
+ none: negated,
197
+ include_note: settings[:search_note])
185
198
  end
186
199
  end
187
200
 
@@ -189,6 +202,11 @@ module NA
189
202
  end
190
203
  end
191
204
 
205
+ # Parse a search tag and categorize as optional, required, or negated
206
+ #
207
+ # @param tag [Hash] Search tag with :token, :negate, :required
208
+ # @param negate [Boolean] Invert results
209
+ # @return [Array<Array>] Arrays of optional, required, and negated regexes
192
210
  def parse_search(tag, negate)
193
211
  required = []
194
212
  optional = []
data/lib/na/version.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Na
2
- VERSION = '1.2.80'
4
+ VERSION = '1.2.81'
3
5
  end
data/lib/na.rb CHANGED
@@ -15,6 +15,7 @@ require 'na/hash'
15
15
  require 'na/colors'
16
16
  require 'na/string'
17
17
  require 'na/array'
18
+ require 'yaml'
18
19
  require 'na/theme'
19
20
  require 'na/todo'
20
21
  require 'na/actions'
data/na.gemspec CHANGED
@@ -24,12 +24,14 @@ spec = Gem::Specification.new do |s|
24
24
  s.add_development_dependency('minitest', '~> 5.14')
25
25
  s.add_development_dependency('rdoc', '~> 4.3')
26
26
  s.add_runtime_dependency('chronic', '~> 0.10', '>= 0.10.2')
27
+ s.add_runtime_dependency('git', '~> 3.0.0')
27
28
  s.add_runtime_dependency('gli','~> 2.21.0')
28
29
  s.add_runtime_dependency('mdless', '~> 1.0', '>= 1.0.32')
30
+ s.add_runtime_dependency('ostruct', '~> 0.6', '>= 0.6.1')
29
31
  s.add_runtime_dependency('tty-reader', '~> 0.9', '>= 0.9.0')
30
32
  s.add_runtime_dependency('tty-screen', '~> 0.8', '>= 0.8.1')
31
33
  s.add_runtime_dependency('tty-which', '~> 0.5', '>= 0.5.0')
32
- s.add_runtime_dependency('git', '~> 3.0.0')
33
- s.add_runtime_dependency('ostruct', '~> 0.6', '>= 0.6.1')
34
34
  s.add_development_dependency('tty-spinner', '~> 0.9', '>= 0.9.0')
35
+ s.add_development_dependency 'rspec', '~> 3.0'
36
+ s.add_development_dependency 'bump', '~> 0.6.0'
35
37
  end
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
2
4
  require 'tty-progressbar'
3
5
  require 'shellwords'
4
6
 
@@ -21,7 +23,6 @@ class ::String
21
23
  end
22
24
 
23
25
  class FishCompletions
24
-
25
26
  attr_accessor :commands, :global_options
26
27
 
27
28
  def generate_helpers
@@ -68,7 +69,7 @@ class FishCompletions
68
69
  sections = {}
69
70
  scanned.each do |sect|
70
71
  title = sect[0].downcase.strip.gsub(/ +/, '_').to_sym
71
- content = sect[1].split(/\n/).map(&:strip).delete_if(&:empty?)
72
+ content = sect[1].split("\n").map(&:strip).delete_if(&:empty?)
72
73
  sections[title] = content
73
74
  end
74
75
  sections
@@ -77,6 +78,7 @@ class FishCompletions
77
78
  def parse_option(option)
78
79
  res = option.match(/(?:-(?<short>\w), )?(?:--(?:\[no-\])?(?<long>w+)(?:=(?<arg>\w+))?)\s+- (?<desc>.*?)$/)
79
80
  return nil unless res
81
+
80
82
  {
81
83
  short: res['short'],
82
84
  long: res['long'],
@@ -92,7 +94,7 @@ class FishCompletions
92
94
  def parse_command(command)
93
95
  res = command.match(/^(?<cmd>[^, \t]+)(?<alias>(?:, [^, \t]+)*)?\s+- (?<desc>.*?)$/)
94
96
  commands = [res['cmd']]
95
- commands.concat(res['alias'].split(/, /).delete_if(&:empty?)) if res['alias']
97
+ commands.concat(res['alias'].split(', ').delete_if(&:empty?)) if res['alias']
96
98
 
97
99
  {
98
100
  commands: commands,
@@ -106,7 +108,7 @@ class FishCompletions
106
108
 
107
109
  def generate_subcommand_completions
108
110
  out = []
109
- @commands.each_with_index do |cmd, i|
111
+ @commands.each_with_index do |cmd, _i|
110
112
  out << "complete -xc na -n '__fish_na_needs_command' -a '#{cmd[:commands].join(' ')}' -d #{Shellwords.escape(cmd[:description])}"
111
113
  end
112
114
 
@@ -114,35 +116,30 @@ class FishCompletions
114
116
  end
115
117
 
116
118
  def generate_subcommand_option_completions
117
-
118
119
  out = []
119
120
  need_export = []
120
121
 
121
- @commands.each_with_index do |cmd, i|
122
+ @commands.each_with_index do |cmd, _i|
122
123
  @bar.advance
123
124
  data = get_help_sections(cmd[:commands].first)
124
125
 
125
- if data[:synopsis].join(' ').strip.split(/ /).last =~ /(path|file)/i
126
- out << "complete -c na -F -n '__fish_na_using_command #{cmd[:commands].join(" ")}'"
127
- end
126
+ out << "complete -c na -F -n '__fish_na_using_command #{cmd[:commands].join(' ')}'" if data[:synopsis].join(' ').strip.split(/ /).last =~ /(path|file)/i
128
127
 
129
- if data[:command_options]
130
- parse_options(data[:command_options]).each do |option|
131
- next if option.nil?
128
+ next unless data[:command_options]
132
129
 
133
- arg = option[:arg] ? '-r' : ''
134
- short = option[:short] ? "-s #{option[:short]}" : ''
135
- long = option[:long] ? "-l #{option[:long]}" : ''
136
- out << "complete -c na #{long} #{short} -f #{arg} -n '__fish_na_using_command #{cmd[:commands].join(' ')}' -d #{Shellwords.escape(option[:description])}"
130
+ parse_options(data[:command_options]).each do |option|
131
+ next if option.nil?
137
132
 
138
- need_export.concat(cmd[:commands]) if option[:long] == 'output'
139
- end
133
+ arg = option[:arg] ? '-r' : ''
134
+ short = option[:short] ? "-s #{option[:short]}" : ''
135
+ long = option[:long] ? "-l #{option[:long]}" : ''
136
+ out << "complete -c na #{long} #{short} -f #{arg} -n '__fish_na_using_command #{cmd[:commands].join(' ')}' -d #{Shellwords.escape(option[:description])}"
137
+
138
+ need_export.concat(cmd[:commands]) if option[:long] == 'output'
140
139
  end
141
140
  end
142
141
 
143
- unless need_export.empty?
144
- out << "complete -f -c na -s o -l output -x -n '__fish_na_using_command #{need_export.join(' ')}' -a '(__fish_na_export_plugins)'"
145
- end
142
+ out << "complete -f -c na -s o -l output -x -n '__fish_na_using_command #{need_export.join(' ')}' -a '(__fish_na_export_plugins)'" unless need_export.empty?
146
143
 
147
144
  # clear
148
145
  out.join("\n")
data/src/_README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is <!--VER-->1.2.79<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.80<!--END VER-->.
13
13
 
14
- `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
14
+ `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
16
16
  Used with Taskpaper files, it can add new action items quickly from the command line, automatically tagging them as next actions. It can also mark actions as completed, delete them, archive them, and move them between projects.
17
17
 
@@ -48,7 +48,7 @@ You can list next actions in files in the current directory by typing `na`. By d
48
48
 
49
49
  #### Adding todos
50
50
 
51
- You can also quickly add todo items from the command line with the `add` subcommand. The script will look for a file in the current directory with a `.taskpaper` extension (configurable).
51
+ You can also quickly add todo items from the command line with the `add` subcommand. The script will look for a file in the current directory with a `.taskpaper` extension (configurable).
52
52
 
53
53
  If found, it will try to locate an `Inbox:` project, or create one if it doesn't exist. Any arguments after `add` will be combined to create a new task in TaskPaper format. They will automatically be assigned as next actions (tagged `@na`) and will show up when `na` lists the tasks for the project.
54
54
 
@@ -82,7 +82,7 @@ If you run the `add` command with no arguments, you'll be asked for input on the
82
82
 
83
83
  ###### Adding notes
84
84
 
85
- Use the `--note` switch to add a note. If STDIN (piped) input is present when this switch is used, it will be included in the note. A prompt will be displayed for adding additional notes, which will be appended to any STDIN note passed. Press CTRL-d to end editing and save the note.
85
+ Use the `--note` switch to add a note. If STDIN (piped) input is present when this switch is used, it will be included in the note. A prompt will be displayed for adding additional notes, which will be appended to any STDIN note passed. Press CTRL-d to end editing and save the note.
86
86
 
87
87
  Notes are not displayed by the `next/tagged/find` commands unless `--notes` is specified.
88
88
 
@@ -163,6 +163,16 @@ Run `na saved` without an argument to list your saved searches.
163
163
  @cli(bundle exec bin/na help saved)
164
164
  ```
165
165
 
166
+ ##### scan
167
+
168
+ Scan a directory tree for todo files and cache them in tdlist.txt. Avoids duplicates and can optionally prune non-existent entries.
169
+
170
+ Scan reports how many files were added and, if --prune is used, how many were pruned. With --dry-run, it lists the full file paths that would be added and/or pruned.
171
+
172
+ ```
173
+ @cli(bundle exec bin/na help scan)
174
+ ```
175
+
166
176
  ##### tagged
167
177
 
168
178
  Example: `na tagged feature +maybe`.
data/test_performance.rb CHANGED
@@ -8,7 +8,7 @@ require_relative 'lib/na/benchmark'
8
8
  module NA
9
9
  module Color
10
10
  def self.template(input)
11
- input.to_s # Simple mock
11
+ input.to_s # Simple mock
12
12
  end
13
13
  end
14
14
 
@@ -41,15 +41,15 @@ end
41
41
  NA::Benchmark.init
42
42
 
43
43
  # Test the optimizations
44
- puts "Testing performance optimizations..."
44
+ puts 'Testing performance optimizations...'
45
45
 
46
46
  # Test 1: Theme caching
47
47
  NA::Benchmark.measure('Theme loading (first time)') do
48
- theme1 = NA::Theme.load_theme
48
+ NA::Theme.load_theme
49
49
  end
50
50
 
51
51
  NA::Benchmark.measure('Theme loading (cached)') do
52
- theme2 = NA.theme
52
+ NA.theme
53
53
  end
54
54
 
55
55
  # Test 2: Color template caching
@@ -70,7 +70,7 @@ end
70
70
 
71
71
  NA::Benchmark.measure('Multiple color templates') do
72
72
  100.times do
73
- NA::Color.template('{bg}Action {c}#{rand(1000)}{x}')
73
+ NA::Color.template("{bg}Action {c}#{rand(1000)}{x}")
74
74
  end
75
75
  end
76
76