yuno-proofreader 0.1.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +435 -0
  5. data/exe/yuno-proofreader +16 -0
  6. data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
  7. data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
  8. data/lib/yuno/proofreader/analysis/entries.rb +73 -0
  9. data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
  10. data/lib/yuno/proofreader/analysis/graph.rb +115 -0
  11. data/lib/yuno/proofreader/analysis/judge.rb +103 -0
  12. data/lib/yuno/proofreader/analysis/judge.yml +27 -0
  13. data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
  14. data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
  15. data/lib/yuno/proofreader/analysis/links.rb +54 -0
  16. data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
  17. data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
  18. data/lib/yuno/proofreader/analysis/report.rb +110 -0
  19. data/lib/yuno/proofreader/analysis/runner.rb +171 -0
  20. data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
  21. data/lib/yuno/proofreader/analysis/settings.rb +90 -0
  22. data/lib/yuno/proofreader/analysis/signals.rb +102 -0
  23. data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
  24. data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
  25. data/lib/yuno/proofreader/analysis/weights.rb +77 -0
  26. data/lib/yuno/proofreader/analysis/weights.yml +39 -0
  27. data/lib/yuno/proofreader/audit/agenda.rb +86 -0
  28. data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
  29. data/lib/yuno/proofreader/audit/answers.rb +74 -0
  30. data/lib/yuno/proofreader/audit/attempt.rb +69 -0
  31. data/lib/yuno/proofreader/audit/batch.rb +64 -0
  32. data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
  33. data/lib/yuno/proofreader/audit/cadence.rb +67 -0
  34. data/lib/yuno/proofreader/audit/instructions.rb +30 -0
  35. data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
  36. data/lib/yuno/proofreader/audit/prompt.rb +157 -0
  37. data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
  38. data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
  39. data/lib/yuno/proofreader/audit/report.rb +153 -0
  40. data/lib/yuno/proofreader/audit/runner.rb +144 -0
  41. data/lib/yuno/proofreader/audit/steps.rb +56 -0
  42. data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
  43. data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
  44. data/lib/yuno/proofreader/cli/command_log.rb +42 -0
  45. data/lib/yuno/proofreader/cli/flags.rb +48 -0
  46. data/lib/yuno/proofreader/cli/init_command.rb +116 -0
  47. data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
  48. data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
  49. data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
  50. data/lib/yuno/proofreader/cli.rb +218 -0
  51. data/lib/yuno/proofreader/config.rb +25 -0
  52. data/lib/yuno/proofreader/context.rb +55 -0
  53. data/lib/yuno/proofreader/env_file.rb +76 -0
  54. data/lib/yuno/proofreader/errors.rb +17 -0
  55. data/lib/yuno/proofreader/findings/finding.rb +95 -0
  56. data/lib/yuno/proofreader/findings/matching.rb +34 -0
  57. data/lib/yuno/proofreader/findings/merger.rb +43 -0
  58. data/lib/yuno/proofreader/findings/outline.rb +73 -0
  59. data/lib/yuno/proofreader/findings/plan.rb +24 -0
  60. data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
  61. data/lib/yuno/proofreader/findings/source.rb +79 -0
  62. data/lib/yuno/proofreader/findings/text.rb +37 -0
  63. data/lib/yuno/proofreader/http/client.rb +131 -0
  64. data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
  65. data/lib/yuno/proofreader/http/robots.rb +72 -0
  66. data/lib/yuno/proofreader/init/ask.rb +67 -0
  67. data/lib/yuno/proofreader/init/detector.rb +100 -0
  68. data/lib/yuno/proofreader/init/interview.rb +210 -0
  69. data/lib/yuno/proofreader/init/parsing.rb +47 -0
  70. data/lib/yuno/proofreader/init/web.rb +33 -0
  71. data/lib/yuno/proofreader/llm/client.rb +140 -0
  72. data/lib/yuno/proofreader/llm/defaults.rb +22 -0
  73. data/lib/yuno/proofreader/llm/defaults.yml +6 -0
  74. data/lib/yuno/proofreader/llm/errors.rb +26 -0
  75. data/lib/yuno/proofreader/llm/factory.rb +45 -0
  76. data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
  77. data/lib/yuno/proofreader/llm/json_text.rb +23 -0
  78. data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
  79. data/lib/yuno/proofreader/llm/provider.rb +58 -0
  80. data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
  81. data/lib/yuno/proofreader/llm/providers.rb +38 -0
  82. data/lib/yuno/proofreader/llm/types.rb +13 -0
  83. data/lib/yuno/proofreader/llm/usage.rb +17 -0
  84. data/lib/yuno/proofreader/log.rb +129 -0
  85. data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
  86. data/lib/yuno/proofreader/pages/report.rb +45 -0
  87. data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
  88. data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
  89. data/lib/yuno/proofreader/settings_file.rb +51 -0
  90. data/lib/yuno/proofreader/site.rb +74 -0
  91. data/lib/yuno/proofreader/site_reader.rb +131 -0
  92. data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
  93. data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
  94. data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
  95. data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
  96. data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
  97. data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
  98. data/lib/yuno/proofreader/sitemap/report.rb +80 -0
  99. data/lib/yuno/proofreader/store/cells.rb +67 -0
  100. data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
  101. data/lib/yuno/proofreader/store/memory.rb +90 -0
  102. data/lib/yuno/proofreader/store/records.rb +28 -0
  103. data/lib/yuno/proofreader/store/schema.rb +109 -0
  104. data/lib/yuno/proofreader/store/sheets.rb +183 -0
  105. data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
  106. data/lib/yuno/proofreader/version.rb +7 -0
  107. data/lib/yuno/proofreader.rb +101 -0
  108. data/proofreader.example.yml +134 -0
  109. metadata +194 -0
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "timeout"
6
+ require "uri"
7
+ require "zlib"
8
+
9
+ require_relative "net_http_transport"
10
+
11
+ module Yuno
12
+ module Proofreader
13
+ module Http
14
+ PRODUCT_TOKEN = "yuno-proofreader"
15
+ DEFAULT_USER_AGENT = "#{PRODUCT_TOKEN}/#{VERSION} (+https://gitlab.com/alsen-ca/yuno-proofreader)".freeze
16
+
17
+ Settings = Data.define(:user_agent, :delay, :max_redirects, :timeout, :max_bytes) do
18
+ def initialize(user_agent: DEFAULT_USER_AGENT, delay: 0.5, max_redirects: 3,
19
+ timeout: 30, max_bytes: 5_000_000)
20
+ super
21
+ end
22
+ end
23
+
24
+ Response = Data.define(:url, :status, :body, :content_type) do
25
+ def success? = status.between?(200, 299)
26
+ end
27
+
28
+ # Fetches web pages politely: it says who it is, waits between requests, follows a few redirects and
29
+ # never reads more than max_bytes. It returns the answer for any HTTP status, error statuses included,
30
+ # and raises FetchError only when no answer could be obtained.
31
+ class Client
32
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
33
+ ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5"
34
+ NETWORK_ERRORS = [
35
+ SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
36
+ Net::ProtocolError, Net::HTTPBadResponse, Zlib::Error
37
+ ].freeze
38
+ REAL_SLEEP = ->(seconds) { sleep(seconds) }
39
+ REAL_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
40
+
41
+ def initialize(settings: Settings.new, transport: NetHttpTransport.new,
42
+ sleeper: REAL_SLEEP, clock: REAL_CLOCK)
43
+ @settings = settings
44
+ @transport = transport
45
+ @sleeper = sleeper
46
+ @clock = clock
47
+ @last_request_at = nil
48
+ end
49
+
50
+ def get(url)
51
+ uri = parse(url)
52
+ (@settings.max_redirects + 1).times do
53
+ pace
54
+ raw = request(uri)
55
+ target = redirect_target(uri, raw)
56
+ return response_from(uri, raw) unless target
57
+
58
+ uri = target
59
+ end
60
+ raise FetchError, "Too many redirects for #{url}"
61
+ end
62
+
63
+ private
64
+
65
+ def parse(url)
66
+ uri = URI.parse(url.to_s.strip)
67
+ return uri if %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
68
+
69
+ raise FetchError, "Not a web address: #{url.inspect}"
70
+ rescue URI::InvalidURIError
71
+ raise FetchError, "Not a valid web address: #{url.inspect}"
72
+ end
73
+
74
+ # Waits so that at least settings.delay seconds pass between two requests.
75
+ def pace
76
+ now = @clock.call
77
+ if @last_request_at
78
+ wait = @settings.delay - (now - @last_request_at)
79
+ if wait.positive?
80
+ @sleeper.call(wait)
81
+ now += wait
82
+ end
83
+ end
84
+ @last_request_at = now
85
+ end
86
+
87
+ def request(uri)
88
+ raw = @transport.call(uri, headers: request_headers, **limits)
89
+ raw.with(headers: raw.headers.transform_keys { |name| name.to_s.downcase })
90
+ rescue *NETWORK_ERRORS => e
91
+ raise FetchError, "Could not fetch #{uri}: #{e.message}"
92
+ end
93
+
94
+ def request_headers
95
+ { "User-Agent" => @settings.user_agent, "Accept" => ACCEPT }
96
+ end
97
+
98
+ def limits
99
+ { timeout: @settings.timeout, max_bytes: @settings.max_bytes }
100
+ end
101
+
102
+ def redirect_target(uri, raw)
103
+ return unless REDIRECTS.include?(raw.status)
104
+
105
+ location = raw.headers["location"].to_s.strip
106
+ raise FetchError, "#{uri} redirects without saying where to" if location.empty?
107
+
108
+ parse(URI.join(uri.to_s, location).to_s)
109
+ rescue URI::Error
110
+ raise FetchError, "#{uri} redirects to an invalid address"
111
+ end
112
+
113
+ def response_from(uri, raw)
114
+ type = raw.headers["content-type"].to_s
115
+ Response.new(url: uri.to_s, status: raw.status, body: decode(raw.body, type), content_type: type)
116
+ end
117
+
118
+ # The body as clean UTF-8: the charset from the Content-Type header if there is one, otherwise UTF-8.
119
+ def decode(body, content_type)
120
+ charset = content_type[/charset=["']?([\w-]+)/i, 1]
121
+ text = body.dup
122
+ return text.force_encoding(Encoding::UTF_8).scrub if charset.nil? || charset.casecmp?("utf-8")
123
+
124
+ text.force_encoding(charset).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
125
+ rescue ArgumentError, EncodingError
126
+ body.dup.force_encoding(Encoding::UTF_8).scrub
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Yuno
7
+ module Proofreader
8
+ module Http
9
+ # What a transport returns: the raw status, the headers (lowercase names) and the body.
10
+ RawResponse = Data.define(:status, :headers, :body)
11
+
12
+ # Sends one GET request with Net::HTTP and reads at most max_bytes of the answer.
13
+ # It follows no redirects and repairs no encodings. The client does both.
14
+ class NetHttpTransport
15
+ def call(uri, headers:, timeout:, max_bytes:)
16
+ raw = nil
17
+ http(uri, timeout).request(Net::HTTP::Get.new(uri, headers)) do |response|
18
+ raw = RawResponse.new(status: response.code.to_i, headers: headers_of(response),
19
+ body: read_limited(response, max_bytes))
20
+ end
21
+ raw
22
+ end
23
+
24
+ private
25
+
26
+ def http(uri, timeout)
27
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
28
+ http.use_ssl = uri.scheme == "https"
29
+ http.open_timeout = timeout
30
+ http.read_timeout = timeout
31
+ end
32
+ end
33
+
34
+ def headers_of(response)
35
+ response.to_hash.transform_values { |values| values.join(", ") }
36
+ end
37
+
38
+ def read_limited(response, max_bytes)
39
+ raise too_large(max_bytes) if response["content-length"].to_i > max_bytes
40
+
41
+ buffer = String.new(encoding: Encoding::BINARY)
42
+ response.read_body do |chunk|
43
+ buffer << chunk
44
+ raise too_large(max_bytes) if buffer.bytesize > max_bytes
45
+ end
46
+ buffer
47
+ end
48
+
49
+ def too_large(max_bytes)
50
+ FetchError.new("The answer is larger than #{max_bytes} bytes")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Http
8
+ # The rules of a robots.txt for one crawler name. Rules are matched the way search engines do:
9
+ # the longest matching pattern wins and Allow wins a tie. Percent-encoding is not normalized.
10
+ class Robots
11
+ Rule = Data.define(:allow, :pattern, :regexp)
12
+
13
+ def self.allow_all = new([])
14
+
15
+ # Uses the group that names this crawler, or else the group for all crawlers (*).
16
+ def self.parse(text, agent:)
17
+ groups = groups_in(text.to_s)
18
+ name = agent.to_s.downcase
19
+ chosen = groups.select { |group| group[:agents].include?(name) }
20
+ chosen = groups.select { |group| group[:agents].include?("*") } if chosen.empty?
21
+ new(chosen.flat_map { |group| group[:rules] }.filter_map { |allow, value| rule(allow, value) })
22
+ end
23
+
24
+ def self.groups_in(text)
25
+ groups = []
26
+ after_agent = false
27
+ fields_in(text).each do |field, value|
28
+ if field == "user-agent"
29
+ groups << { agents: [], rules: [] } unless after_agent
30
+ groups.last[:agents] << value.downcase
31
+ after_agent = true
32
+ elsif %w[allow disallow].include?(field)
33
+ groups.last[:rules] << [field == "allow", value] unless groups.empty?
34
+ after_agent = false
35
+ end
36
+ end
37
+ groups
38
+ end
39
+
40
+ # The "field: value" pairs of the file, without comments and blank or malformed lines.
41
+ def self.fields_in(text)
42
+ text.each_line.filter_map do |line|
43
+ field, value = line.sub(/#.*/, "").strip.split(/\s*:\s*/, 2)
44
+ [field.downcase, value] if field && value
45
+ end
46
+ end
47
+
48
+ def self.rule(allow, value)
49
+ return if value.empty?
50
+
51
+ source = value.chomp("$").split("*", -1).map { |part| Regexp.escape(part) }.join(".*")
52
+ anchor = value.end_with?("$") ? "\\z" : ""
53
+ Rule.new(allow:, pattern: value, regexp: Regexp.new("\\A#{source}#{anchor}"))
54
+ end
55
+ private_class_method :groups_in, :fields_in, :rule
56
+
57
+ def initialize(rules)
58
+ @rules = rules
59
+ end
60
+
61
+ def allowed?(url)
62
+ uri = URI.parse(url.to_s)
63
+ target = uri.path.to_s.empty? ? "/" : uri.path
64
+ target += "?#{uri.query}" if uri.query
65
+ best = @rules.select { |rule| rule.regexp.match?(target) }
66
+ .max_by { |rule| [rule.pattern.length, rule.allow ? 1 : 0] }
67
+ best.nil? || best.allow
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Init
6
+ # Asks questions on a terminal and reads the answers. Without a terminal it must not be used to ask:
7
+ # interactive? says which it is.
8
+ class Ask
9
+ def initialize(input:, out:)
10
+ @input = input
11
+ @out = out
12
+ end
13
+
14
+ def interactive? = @input.respond_to?(:tty?) && @input.tty?
15
+
16
+ # One line of text. An empty answer gives the default, which may be nil.
17
+ def line(question, default: nil)
18
+ @out.print("#{question}#{" [#{default}]" if default}: ")
19
+ answer = read
20
+ answer.empty? ? default : answer
21
+ end
22
+
23
+ # Asks until something is typed.
24
+ def required(question, default: nil)
25
+ loop do
26
+ answer = line(question, default:)
27
+ return answer unless answer.nil? || answer.empty?
28
+
29
+ @out.puts "An answer is needed here."
30
+ end
31
+ end
32
+
33
+ def yes?(question, default: true)
34
+ loop do
35
+ answer = line("#{question} #{default ? '[Y/n]' : '[y/N]'}").to_s.downcase
36
+ return default if answer.empty?
37
+ return answer.start_with?("y") if %w[y yes n no].include?(answer)
38
+
39
+ @out.puts "Please answer y or n."
40
+ end
41
+ end
42
+
43
+ # Lines until an empty one. An empty list means the question was skipped.
44
+ def list(question)
45
+ @out.puts "#{question} One per line, an empty line to finish."
46
+ lines = []
47
+ loop do
48
+ @out.print(" > ")
49
+ answer = read
50
+ return lines if answer.empty?
51
+
52
+ lines << answer
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def read
59
+ text = @input.gets
60
+ raise Error, "The input ended before all the questions were answered." if text.nil?
61
+
62
+ text.strip
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Init
8
+ # What a homepage says about the languages of its site. languages is a list of { "code", "prefix" } with the
9
+ # default language first. origin is where the page was found (scheme and host), description its meta
10
+ # description when it is short enough to suggest, and problems say what could not be used.
11
+ Detection = Data.define(:languages, :origin, :description, :problems)
12
+
13
+ # Reads the language versions a homepage announces with its hreflang tags. The tool tells languages apart by
14
+ # the start of the address (/es/...), so a version on another host, or deeper than one path segment, is
15
+ # reported and left out.
16
+ class Detector
17
+ CODE = /\A[a-z]{2,3}(-[a-z0-9]{2,8})*\z/
18
+ ALTERNATES = 'link[rel~="alternate"][hreflang]'
19
+ MAX_DESCRIPTION = 300
20
+
21
+ def self.call(html, url:, website:) = new(html, url, website).call
22
+
23
+ def initialize(html, url, website)
24
+ @doc = Nokogiri::HTML(html.to_s)
25
+ @url = url
26
+ @website = website
27
+ @problems = []
28
+ end
29
+
30
+ def call
31
+ languages = ordered(distinct(versions))
32
+ Detection.new(languages:, origin: Parsing.origin(@url), description:, problems: @problems)
33
+ end
34
+
35
+ private
36
+
37
+ def alternates
38
+ @doc.css(ALTERNATES).filter_map do |link|
39
+ code = link["hreflang"].to_s.strip.downcase
40
+ href = Analysis::Links.absolute(@url, link["href"])
41
+ [code, href] if !code.empty? && href
42
+ end
43
+ end
44
+
45
+ def versions
46
+ found = alternates.reject { |pair| pair.first == "x-default" }.uniq(&:first)
47
+ found.filter_map { |code, href| version(code, href) }
48
+ end
49
+
50
+ # { "code", "prefix" } for a version, or nil with the reason noted.
51
+ def version(code, href)
52
+ return note("#{code}: not a language code the tool can use") unless code.match?(CODE)
53
+ return note("#{code}: its address #{href} is on another host") unless Analysis::Links.same_site?(href,
54
+ @website)
55
+
56
+ parts = Analysis::Links.segments(href)
57
+ return note("#{code}: its address #{href} is deeper than a language prefix") if parts.size > 1
58
+
59
+ { "code" => code, "prefix" => parts.empty? ? "" : "/#{parts.first}" }
60
+ end
61
+
62
+ def note(message)
63
+ @problems << message
64
+ nil
65
+ end
66
+
67
+ # Two versions cannot share a prefix: the first one is kept.
68
+ def distinct(list)
69
+ kept = {}
70
+ list.each do |entry|
71
+ first = kept[entry["prefix"]]
72
+ if first
73
+ note("#{entry['code']}: shares its address prefix with #{first['code']}")
74
+ else
75
+ kept[entry["prefix"]] =
76
+ entry
77
+ end
78
+ end
79
+ kept.values
80
+ end
81
+
82
+ # The default language first: the one without a prefix, else the one x-default points to, else the first.
83
+ def ordered(list)
84
+ default = list.find { |entry| entry["prefix"].empty? } || x_default(list) || list.first
85
+ default ? [default, *(list - [default])] : []
86
+ end
87
+
88
+ def x_default(list)
89
+ target = alternates.find { |code, _href| code == "x-default" }&.last
90
+ target && list.find { |entry| Analysis::Links.key(target) == Analysis::Links.key("#{@website}#{entry['prefix']}") }
91
+ end
92
+
93
+ def description
94
+ text = Findings::Text.collapse(@doc.at_css('meta[name="description"]')&.[]("content"))
95
+ text unless text.empty? || text.length > MAX_DESCRIPTION
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Init
6
+ # Works out the settings of a new site: from the command's options, from what the homepage says about itself
7
+ # and, on a terminal, by asking. Only the address and the languages are needed to write a file, the business
8
+ # description is needed before an audit, and everything else is optional.
9
+ class Interview
10
+ LISTS = {
11
+ "skip_paths" => ["skip-path",
12
+ "Pages you do not want audited, written as in the address (for example /thank-you)."],
13
+ "rules" => ["rule", "Writing rules, such as how prices or dates are written."],
14
+ "ignore_terms" => ["ignore-term", "Words that are correct as written and must never be flagged: brand " \
15
+ "names, place names, local words."],
16
+ "intentional_differences" => ["difference", "Differences between language versions that are intended, " \
17
+ "such as a section that exists in one language only."]
18
+ }.freeze
19
+ LANGUAGE_QUESTION = "Languages, the default first: a code, or code:/prefix (for example de, es:/es, en:/en)"
20
+
21
+ # flags: the options (Flags::Parsed). web: looks things up. offline: make no request. yes: accept what was
22
+ # detected, and ask nothing.
23
+ def initialize(flags:, ask:, web:, out:, offline: false, yes: false)
24
+ @flags = flags
25
+ @ask = ask
26
+ @web = web
27
+ @out = out
28
+ @offline = offline
29
+ @yes = yes
30
+ end
31
+
32
+ # The settings as a hash with string keys, in the shape of the settings file.
33
+ def call
34
+ intro
35
+ origin = website
36
+ detection = read_homepage(origin)
37
+ origin = better_origin(origin, detection)
38
+ settings = { "website" => origin, "languages" => languages(detection), **about(detection), **lists }
39
+ settings["sitemap_url"] = @flags.values["sitemap"] if @flags.values["sitemap"]
40
+ check_sitemap(settings)
41
+ settings
42
+ end
43
+
44
+ private
45
+
46
+ def asking? = @ask.interactive? && !@yes
47
+
48
+ def intro
49
+ return unless asking?
50
+
51
+ @out.puts "A few questions to write your settings file. An empty answer skips a question that is optional."
52
+ end
53
+
54
+ def accept?(question) = asking? ? @ask.yes?(question) : true
55
+
56
+ # ---- the address ----
57
+
58
+ def website
59
+ given = @flags.values["website"]
60
+ if given
61
+ return Parsing.origin(given) || raise(ConfigError,
62
+ "--website is not a web address (got #{given.inspect})")
63
+ end
64
+ raise ConfigError, "Say which site to set up: --website ADDRESS" unless @ask.interactive?
65
+
66
+ loop do
67
+ origin = Parsing.origin(@ask.required("Address of the site (for example example.com)"))
68
+ return origin if origin
69
+
70
+ @out.puts "That is not a web address."
71
+ end
72
+ end
73
+
74
+ def read_homepage(origin)
75
+ return if @offline
76
+
77
+ html, url = @web.homepage(origin)
78
+ Detector.call(html, url:, website: origin)
79
+ rescue Error => e
80
+ @out.puts "The homepage could not be read (#{e.message}), so nothing could be detected from it."
81
+ nil
82
+ end
83
+
84
+ def better_origin(origin, detection)
85
+ found = detection&.origin
86
+ return origin if found.nil? || found == origin
87
+
88
+ accept?("#{origin} leads to #{found}. Use #{found} as the address of the site?") ? found : origin
89
+ end
90
+
91
+ # ---- the languages ----
92
+
93
+ def languages(detection)
94
+ given = @flags.lists["language"]
95
+ return with_notes(checked(given.map { |item| Parsing.language(item) })) unless given.empty?
96
+
97
+ detected = Array(detection&.languages)
98
+ return with_notes(detected) if !detected.empty? && announce(detection) && accept?("Use these languages?")
99
+
100
+ with_notes(typed_languages)
101
+ end
102
+
103
+ def checked(list)
104
+ problem = Parsing.language_problem(list)
105
+ raise ConfigError, "--language: #{problem}" if problem
106
+
107
+ list
108
+ end
109
+
110
+ def announce(detection)
111
+ @out.puts "The homepage announces these languages (the first is the default):"
112
+ detection.languages.each do |entry|
113
+ @out.puts " #{entry['code']} #{entry['prefix'].empty? ? '(no prefix)' : entry['prefix']}"
114
+ end
115
+ detection.problems.each { |problem| @out.puts " Left out: #{problem}" }
116
+ end
117
+
118
+ def typed_languages
119
+ unless @ask.interactive?
120
+ raise ConfigError, "Say which languages the site has: --language CODE[:PREFIX], once for each, the " \
121
+ "default first"
122
+ end
123
+
124
+ loop do
125
+ list = Parsing.languages(@ask.required(LANGUAGE_QUESTION))
126
+ problem = Parsing.language_problem(list)
127
+ return list unless problem
128
+
129
+ @out.puts "Not accepted: #{problem}"
130
+ end
131
+ end
132
+
133
+ def with_notes(list)
134
+ given = @flags.lists["note"].to_h do |item|
135
+ item.split("=", 2).then do |code, text|
136
+ [code.to_s.strip.downcase, text.to_s.strip]
137
+ end
138
+ end
139
+ list.map do |language|
140
+ note = given.fetch(language["code"]) { ask_note(language["code"]) }
141
+ note.to_s.empty? ? language : language.merge("notes" => note)
142
+ end
143
+ end
144
+
145
+ def ask_note(code)
146
+ @ask.line("Notes for #{code}: its variant and form of address (Enter to skip)") if asking?
147
+ end
148
+
149
+ # ---- the site ----
150
+
151
+ def about(detection)
152
+ { "business" => text("business", "What the site is and what it offers, in a sentence or two",
153
+ required: true, suggestion: detection&.description),
154
+ "audience" => text("audience", "Who reads the site (Enter to skip)"),
155
+ "tone" => text("tone", "How the text should sound, such as formal or warm (Enter to skip)") }
156
+ end
157
+
158
+ def text(name, question, required: false, suggestion: nil)
159
+ given = @flags.values[name]
160
+ return given.strip if given
161
+ return (suggestion if @yes) unless asking?
162
+
163
+ required ? @ask.required(question, default: suggestion) : @ask.line(question)
164
+ end
165
+
166
+ def lists
167
+ LISTS.to_h { |setting, (flag, question)| [setting, list(flag, question)] }
168
+ end
169
+
170
+ def list(flag, question)
171
+ given = @flags.lists[flag]
172
+ return given.map(&:strip).reject(&:empty?) unless given.empty?
173
+
174
+ asking? ? @ask.list(question) : []
175
+ end
176
+
177
+ # ---- the sitemap ----
178
+
179
+ # Looks for the sitemap. When none is found and there is someone to ask, asks for its address once.
180
+ def check_sitemap(settings)
181
+ return if @offline
182
+
183
+ 2.times do
184
+ return if sitemap_found?(settings) || !asking?
185
+
186
+ address = @ask.line("Address of your sitemap (Enter to skip)")
187
+ return if address.nil? || address.empty?
188
+
189
+ settings["sitemap_url"] = address
190
+ end
191
+ end
192
+
193
+ def sitemap_found?(settings)
194
+ count, paired = @web.sitemap(settings)
195
+ @out.puts sitemap_line(count, paired)
196
+ true
197
+ rescue Error => e
198
+ @out.puts "No usable sitemap was found (#{e.message})."
199
+ false
200
+ end
201
+
202
+ def sitemap_line(count, paired)
203
+ return "Found a sitemap: #{count} pages, paired by language." if paired
204
+
205
+ "Found a sitemap, but its pages could not be paired by language. Run: yuno-proofreader sitemap"
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Init
8
+ # Turns what a person typed, or gave as an option, into settings.
9
+ module Parsing
10
+ SCHEME = %r{\A[a-z][a-z0-9+.-]*://}i
11
+
12
+ module_function
13
+
14
+ # "example.com" or "https://example.com/en" give "https://example.com". Anything that is not a web address
15
+ # gives nil.
16
+ def origin(text)
17
+ value = text.to_s.strip
18
+ return if value.empty?
19
+
20
+ value = "https://#{value}" unless value.match?(SCHEME)
21
+ uri = URI.parse(value)
22
+ return unless %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
23
+
24
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
25
+ rescue URI::Error
26
+ nil
27
+ end
28
+
29
+ # "es:/es" gives code es with the prefix /es, and "de" gives code de with no prefix.
30
+ def language(item)
31
+ code, prefix = item.to_s.split(":", 2)
32
+ { "code" => code.to_s.strip.downcase, "prefix" => prefix.to_s.strip }
33
+ end
34
+
35
+ def languages(text) = text.to_s.split(",").map(&:strip).reject(&:empty?).map { |item| language(item) }
36
+
37
+ # What is wrong with a list of languages, in the words of the settings check, or nil when it is fine.
38
+ def language_problem(list)
39
+ Site.from_hash("website" => "https://example.com", "languages" => list)
40
+ nil
41
+ rescue ConfigError => e
42
+ e.message
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end