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,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "google/apis/sheets_v4"
4
+ require "googleauth"
5
+
6
+ module Yuno
7
+ module Proofreader
8
+ module Store
9
+ # Talks to the Google Sheets API. It knows nothing about pages or suggestions, only about tabs, rows and cells.
10
+ # Rate limits and server hiccups are retried. Everything else becomes a StoreError that says what to do.
11
+ class GoogleGateway
12
+ Api = ::Google::Apis::SheetsV4
13
+ SCOPE = "https://www.googleapis.com/auth/spreadsheets"
14
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
15
+ DATETIME_PATTERN = "yyyy-mm-dd hh:mm"
16
+ REAL_SLEEP = ->(seconds) { sleep(seconds) }
17
+
18
+ def initialize(spreadsheet_id:, service: nil, sleeper: REAL_SLEEP, attempts: 4)
19
+ @id = spreadsheet_id
20
+ @service = service || default_service
21
+ @sleeper = sleeper
22
+ @attempts = attempts
23
+ end
24
+
25
+ # { "Pages" => 123, ... }: the tab titles with their ids.
26
+ def tabs
27
+ sheet = with_retries { @service.get_spreadsheet(@id, fields: "sheets.properties(sheetId,title)") }
28
+ sheet.sheets.to_a.to_h { |tab| [tab.properties.title, tab.properties.sheet_id] }
29
+ end
30
+
31
+ def add_tab(title, rows:, columns:)
32
+ grid = Api::GridProperties.new(row_count: rows, column_count: columns)
33
+ properties = Api::SheetProperties.new(title:, grid_properties: grid)
34
+ reply = batch([Api::Request.new(add_sheet: Api::AddSheetRequest.new(properties:))]).replies.first
35
+ reply.add_sheet.properties.sheet_id
36
+ end
37
+
38
+ def delete_tab(sheet_id)
39
+ batch([Api::Request.new(delete_sheet: Api::DeleteSheetRequest.new(sheet_id:))])
40
+ end
41
+
42
+ # All values of a tab as rows of strings. Empty trailing cells and rows are missing, as in the API.
43
+ def read(title)
44
+ values = with_retries { @service.get_spreadsheet_values(@id, quote(title)) }.values
45
+ values.to_a.map { |row| row.map(&:to_s) }
46
+ end
47
+
48
+ # Adds rows below the last row that has data, writing into the cells that are already formatted.
49
+ def append(title, rows)
50
+ body = Api::ValueRange.new(values: rows)
51
+ with_retries do
52
+ @service.append_spreadsheet_value(@id, "#{quote(title)}!A1", body,
53
+ value_input_option: "RAW", insert_data_option: "OVERWRITE")
54
+ end
55
+ end
56
+
57
+ # cells: [[row, column, value], ...] with row and column starting at 1. One request for all of them.
58
+ def update_cells(title, cells)
59
+ return if cells.empty?
60
+
61
+ data = cells.map do |row, column, value|
62
+ Api::ValueRange.new(range: "#{quote(title)}!#{Cells.a1(row, column)}", values: [[value]])
63
+ end
64
+ body = Api::BatchUpdateValuesRequest.new(data:, value_input_option: "RAW")
65
+ with_retries { @service.batch_update_values(@id, body) }
66
+ end
67
+
68
+ # Frozen bold header, column widths, wrapping, date and number formats and the dropdowns, in one request.
69
+ def format_tab(sheet_id, schema)
70
+ requests = [freeze_request(sheet_id), header_request(sheet_id, schema.columns.size)]
71
+ schema.columns.each_with_index do |column, index|
72
+ requests << width_request(sheet_id, index, column.width)
73
+ requests << body_format_request(sheet_id, index, column)
74
+ end
75
+ schema.dropdowns.each do |name, values|
76
+ requests << dropdown_request(sheet_id, schema.names.index(name), values)
77
+ end
78
+ batch(requests)
79
+ end
80
+
81
+ private
82
+
83
+ def default_service
84
+ service = Api::SheetsService.new
85
+ service.authorization = ::Google::Auth.get_application_default([SCOPE])
86
+ service
87
+ rescue StandardError => e
88
+ raise StoreError, "Could not load Google credentials (#{e.message}). " \
89
+ "Check GOOGLE_APPLICATION_CREDENTIALS, or run scripts/check_google.rb."
90
+ end
91
+
92
+ def batch(requests)
93
+ body = Api::BatchUpdateSpreadsheetRequest.new(requests:)
94
+ with_retries { @service.batch_update_spreadsheet(@id, body) }
95
+ end
96
+
97
+ def quote(title) = "'#{title.gsub("'", "''")}'"
98
+
99
+ def with_retries
100
+ attempt = 0
101
+ begin
102
+ attempt += 1
103
+ yield
104
+ rescue ::Google::Apis::Error => e
105
+ raise explain(e) unless retryable?(e) && attempt < @attempts
106
+
107
+ @sleeper.call(2**attempt)
108
+ retry
109
+ rescue ::Signet::AuthorizationError => e
110
+ raise StoreError,
111
+ "Google rejected the credentials (#{e.message}). Log in again, see scripts/check_google.rb."
112
+ end
113
+ end
114
+
115
+ def status_of(error) = error.respond_to?(:status_code) ? error.status_code : nil
116
+
117
+ # Network trouble has no status. Rate limits and server errors are worth another try.
118
+ def retryable?(error)
119
+ status = status_of(error)
120
+ status.nil? || RETRY_STATUSES.include?(status)
121
+ end
122
+
123
+ def explain(error)
124
+ StoreError.new("Google Sheets: #{reason(status_of(error), error)}")
125
+ end
126
+
127
+ def reason(status, error)
128
+ case status
129
+ when 403
130
+ "no access to the spreadsheet. Share it as Editor with the account you use, " \
131
+ "and check that the Sheets API is enabled."
132
+ when 404 then "spreadsheet not found. Check SPREADSHEET_ID."
133
+ when 400 then bad_request(error)
134
+ else error.message
135
+ end
136
+ end
137
+
138
+ def bad_request(error)
139
+ return error.message unless error.message.include?("Unable to parse range")
140
+
141
+ "tab not found. Run: yuno-proofreader prepare"
142
+ end
143
+
144
+ def grid(sheet_id, **bounds) = Api::GridRange.new(sheet_id:, **bounds)
145
+
146
+ def freeze_request(sheet_id)
147
+ properties = Api::SheetProperties.new(sheet_id:,
148
+ grid_properties: Api::GridProperties.new(frozen_row_count: 1))
149
+ Api::Request.new(update_sheet_properties: Api::UpdateSheetPropertiesRequest.new(
150
+ properties:, fields: "gridProperties.frozenRowCount"
151
+ ))
152
+ end
153
+
154
+ def header_request(sheet_id, columns)
155
+ bold = Api::CellFormat.new(text_format: Api::TextFormat.new(bold: true))
156
+ range = grid(sheet_id, start_row_index: 0, end_row_index: 1, start_column_index: 0, end_column_index: columns)
157
+ Api::Request.new(repeat_cell: Api::RepeatCellRequest.new(
158
+ range:, cell: Api::CellData.new(user_entered_format: bold), fields: "userEnteredFormat.textFormat.bold"
159
+ ))
160
+ end
161
+
162
+ def width_request(sheet_id, index, pixels)
163
+ Api::Request.new(update_dimension_properties: Api::UpdateDimensionPropertiesRequest.new(
164
+ range: Api::DimensionRange.new(sheet_id:, dimension: "COLUMNS", start_index: index, end_index: index + 1),
165
+ properties: Api::DimensionProperties.new(pixel_size: pixels), fields: "pixelSize"
166
+ ))
167
+ end
168
+
169
+ def body_format_request(sheet_id, index, column)
170
+ format = Api::CellFormat.new(vertical_alignment: "TOP")
171
+ format.wrap_strategy = "WRAP" if column.wrap
172
+ format.number_format = number_format(column.type)
173
+ Api::Request.new(repeat_cell: Api::RepeatCellRequest.new(
174
+ range: grid(sheet_id, start_row_index: 1, start_column_index: index, end_column_index: index + 1),
175
+ cell: Api::CellData.new(user_entered_format: format),
176
+ fields: "userEnteredFormat(verticalAlignment,wrapStrategy,numberFormat)"
177
+ ))
178
+ end
179
+
180
+ def number_format(type)
181
+ case type
182
+ when :datetime then Api::NumberFormat.new(type: "DATE_TIME", pattern: DATETIME_PATTERN)
183
+ when :integer then Api::NumberFormat.new(type: "NUMBER", pattern: "0")
184
+ end
185
+ end
186
+
187
+ def dropdown_request(sheet_id, index, values)
188
+ choices = values.map { |value| Api::ConditionValue.new(user_entered_value: value) }
189
+ rule = Api::DataValidationRule.new(condition: Api::BooleanCondition.new(type: "ONE_OF_LIST", values: choices),
190
+ strict: true, show_custom_ui: true)
191
+ Api::Request.new(set_data_validation: Api::SetDataValidationRequest.new(
192
+ range: grid(sheet_id, start_row_index: 1, start_column_index: index, end_column_index: index + 1), rule:
193
+ ))
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Store
6
+ # Keeps the pages, suggestions and analysis in memory, with the same behavior as the Google Sheet store.
7
+ # Used in tests, and handy for dry runs.
8
+ class Memory
9
+ IMPORTANCE_FIELDS = %i[importance tier importance_notes analyzed_at].freeze
10
+
11
+ def initialize(site)
12
+ @languages = site.languages
13
+ @pages = []
14
+ @suggestions = []
15
+ @analysis = []
16
+ end
17
+
18
+ def prepare = ["The in-memory store needs no preparation."]
19
+
20
+ def pages = @pages.dup
21
+
22
+ def add_pages(pages)
23
+ pages.each do |page|
24
+ urls = page.urls.slice(*@languages).reject { |_code, url| url.to_s.empty? }
25
+ @pages << PageRecord.new(row: next_row(@pages), name: page.name, urls:, last_audited: nil,
26
+ fingerprint: "", note: page.notes.join("; "), sitemap_lastmod: page.lastmod)
27
+ end
28
+ pages.size
29
+ end
30
+
31
+ def record_audit(record, fingerprint:, at:)
32
+ change(@pages, record.row) { |page| page.with(last_audited: at, fingerprint:) }
33
+ end
34
+
35
+ # Keeps importance and tier, the fields a page record has. Notes and the time are accepted and left out.
36
+ def record_importance(updates)
37
+ updates.each do |row, changes|
38
+ fields = changes.transform_keys(&:to_sym)
39
+ unknown = fields.keys - IMPORTANCE_FIELDS
40
+ raise StoreError, "Unknown field(s): #{unknown.join(', ')}" unless unknown.empty?
41
+
42
+ change(@pages, row) { |page| page.with(**fields.slice(:importance, :tier)) }
43
+ end
44
+ end
45
+
46
+ def suggestions(page_name = nil)
47
+ page_name ? @suggestions.select { |record| record.page == page_name } : @suggestions.dup
48
+ end
49
+
50
+ def add_suggestions(records)
51
+ records.each { |record| @suggestions << record.with(row: next_row(@suggestions)) }
52
+ records.size
53
+ end
54
+
55
+ # updates: { row => { field => value } }
56
+ def update_suggestions(updates)
57
+ updates.each do |row, changes|
58
+ fields = validated(changes)
59
+ change(@suggestions, row) { |record| record.with(**fields) }
60
+ end
61
+ end
62
+
63
+ def analysis_rows = @analysis.each_with_index.map { |values, index| { row: index + 2, **values } }
64
+
65
+ def replace_analysis(rows)
66
+ @analysis = rows.map { |values| values.transform_keys(&:to_sym) }
67
+ end
68
+
69
+ private
70
+
71
+ def next_row(list) = list.size + 2
72
+
73
+ def change(list, row)
74
+ index = row.to_i - 2
75
+ raise StoreError, "There is no row #{row}" unless index >= 0 && list[index]
76
+
77
+ list[index] = yield(list[index])
78
+ end
79
+
80
+ def validated(changes)
81
+ fields = changes.transform_keys(&:to_sym)
82
+ unknown = fields.keys - (SuggestionRecord.members - [:row])
83
+ raise StoreError, "Unknown field(s): #{unknown.join(', ')}" unless unknown.empty?
84
+
85
+ fields
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Store
6
+ # A row of the Pages tab. urls holds the address per language the page exists in. importance and tier are
7
+ # written by the analysis, and boost is what a person typed in.
8
+ PageRecord = Data.define(:row, :name, :urls, :last_audited, :fingerprint, :note, :sitemap_lastmod, :importance,
9
+ :tier, :boost) do
10
+ def initialize(importance: nil, tier: nil, boost: nil, **fields) = super
11
+
12
+ # The address that identifies the page: its default-language address, or the first one it has.
13
+ def key(default_language) = urls[default_language] || urls.values.first
14
+ end
15
+
16
+ SUGGESTION_DEFAULTS = {
17
+ row: nil, timestamp: nil, page: "", language: "", category: "", effort: "", approximate_place: "",
18
+ original: "", suggested: "", reason: "", verified: "", status: "open", found_by: "",
19
+ last_seen: nil, amount_seen: 1
20
+ }.freeze
21
+
22
+ # A row of the Suggestions tab. Use build to leave out the fields you do not need.
23
+ SuggestionRecord = Data.define(*SUGGESTION_DEFAULTS.keys) do
24
+ def self.build(**fields) = new(**SUGGESTION_DEFAULTS, **fields)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Store
6
+ STATUSES = ["open", "on staging", "done", "rejected"].freeze
7
+
8
+ # One column of a tab. type is :text, :datetime (a sheet date, stored as a serial number), :integer or
9
+ # :number (a decimal number).
10
+ Column = Data.define(:name, :type, :width, :wrap) do
11
+ def initialize(name:, type: :text, width: 120, wrap: false) = super
12
+ end
13
+
14
+ # What a tab looks like: its columns, the ones that must exist, the dropdown columns and the grid size.
15
+ TabSchema = Data.define(:title, :columns, :required, :dropdowns, :rows) do
16
+ def names = columns.map(&:name)
17
+
18
+ def column(name) = columns.find { |column| column.name == name }
19
+
20
+ # The columns of the schema that a header row lacks, required or not.
21
+ def absent(header) = names - normalized(header)
22
+
23
+ def check!(header)
24
+ missing = required - normalized(header)
25
+ return if missing.empty?
26
+
27
+ raise StoreError, "Tab #{title} is missing the column(s) #{missing.join(', ')}. Add them to the header row " \
28
+ "with exactly these names, or delete the tab and run prepare again."
29
+ end
30
+
31
+ def normalized(header) = header.map { |name| name.to_s.strip.downcase }
32
+ end
33
+
34
+ # The tabs the tool works with.
35
+ module Schema
36
+ SUGGESTION_COLUMNS = [
37
+ Column.new(name: "timestamp", type: :datetime, width: 150),
38
+ Column.new(name: "page", width: 180),
39
+ Column.new(name: "language", width: 80),
40
+ Column.new(name: "category", width: 100),
41
+ Column.new(name: "effort", width: 80),
42
+ Column.new(name: "approximate_place", width: 220, wrap: true),
43
+ Column.new(name: "original", width: 300, wrap: true),
44
+ Column.new(name: "suggested", width: 300, wrap: true),
45
+ Column.new(name: "reason", width: 300, wrap: true),
46
+ Column.new(name: "verified", width: 80),
47
+ Column.new(name: "status", width: 100),
48
+ Column.new(name: "found_by", width: 110),
49
+ Column.new(name: "last_seen", type: :datetime, width: 150),
50
+ Column.new(name: "amount_seen", type: :integer, width: 90)
51
+ ].freeze
52
+ SUGGESTION_REQUIRED = %w[page language category effort original suggested reason verified status].freeze
53
+
54
+ # Written by the analysis, except boost, which a person fills in.
55
+ IMPORTANCE_COLUMNS = [
56
+ Column.new(name: "importance", type: :integer, width: 100),
57
+ Column.new(name: "tier", type: :integer, width: 60),
58
+ Column.new(name: "importance_notes", width: 320, wrap: true),
59
+ Column.new(name: "analyzed_at", type: :datetime, width: 150),
60
+ Column.new(name: "boost", type: :number, width: 70)
61
+ ].freeze
62
+
63
+ ANALYSIS_COLUMNS = [
64
+ Column.new(name: "page_name", width: 220),
65
+ Column.new(name: "address", width: 300),
66
+ Column.new(name: "importance", type: :integer, width: 100),
67
+ Column.new(name: "tier", type: :integer, width: 60),
68
+ Column.new(name: "reach", type: :number, width: 80),
69
+ Column.new(name: "presence", type: :number, width: 80),
70
+ Column.new(name: "mentions", type: :number, width: 80),
71
+ Column.new(name: "freshness", type: :number, width: 80),
72
+ Column.new(name: "changed", width: 100),
73
+ Column.new(name: "route", width: 260, wrap: true),
74
+ Column.new(name: "ai_value", type: :integer, width: 80),
75
+ Column.new(name: "ai_kind", width: 100),
76
+ Column.new(name: "ai_place", width: 180),
77
+ Column.new(name: "ai_certainty", width: 90),
78
+ Column.new(name: "ai_reason", width: 320, wrap: true),
79
+ Column.new(name: "ai_spread", type: :integer, width: 80),
80
+ Column.new(name: "analyzed_at", type: :datetime, width: 150)
81
+ ].freeze
82
+
83
+ module_function
84
+
85
+ def pages(languages)
86
+ urls = languages.map { |code| Column.new(name: "#{code}_url", width: 320) }
87
+ columns = [Column.new(name: "page_name", width: 220), *urls,
88
+ Column.new(name: "last_audited", type: :datetime, width: 150),
89
+ Column.new(name: "fingerprint", width: 130),
90
+ Column.new(name: "note", width: 200),
91
+ Column.new(name: "sitemap_lastmod", width: 130),
92
+ *IMPORTANCE_COLUMNS]
93
+ required = ["page_name", *urls.map(&:name), "last_audited"]
94
+ TabSchema.new(title: "Pages", columns:, required:, dropdowns: {}, rows: 1_000)
95
+ end
96
+
97
+ def suggestions
98
+ TabSchema.new(title: "Suggestions", columns: SUGGESTION_COLUMNS, required: SUGGESTION_REQUIRED,
99
+ dropdowns: { "status" => STATUSES }, rows: 5_000)
100
+ end
101
+
102
+ def analysis
103
+ TabSchema.new(title: "Analysis", columns: ANALYSIS_COLUMNS, required: %w[page_name address], dropdowns: {},
104
+ rows: 1_000)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Store
6
+ # Keeps the pages, the suggestions and the analysis in a Google Sheet. Columns are found by their header
7
+ # names, so the order of the columns does not matter and extra columns are left alone. All access goes through
8
+ # a gateway.
9
+ class Sheets
10
+ Table = Data.define(:index, :rows, :width)
11
+ IMPORTANCE = %w[importance tier importance_notes analyzed_at].freeze
12
+
13
+ def self.connect(config, site)
14
+ require_relative "google_gateway"
15
+ new(GoogleGateway.new(spreadsheet_id: config.spreadsheet_id), site)
16
+ end
17
+
18
+ def initialize(gateway, site)
19
+ @gateway = gateway
20
+ @languages = site.languages
21
+ @pages_schema = Schema.pages(@languages)
22
+ @suggestions_schema = Schema.suggestions
23
+ @analysis_schema = Schema.analysis
24
+ end
25
+
26
+ # Creates the tabs that are missing and returns a line for each thing it did or found.
27
+ def prepare = TabSetup.new(@gateway, [@pages_schema, @suggestions_schema, @analysis_schema]).call
28
+
29
+ def pages
30
+ table = read_table(@pages_schema)
31
+ data_rows(table).map { |cells, row| page_record(table, cells, row) }
32
+ end
33
+
34
+ # pages: Sitemap::Page objects. Returns how many were added.
35
+ def add_pages(pages)
36
+ return 0 if pages.empty?
37
+
38
+ table = read_table(@pages_schema)
39
+ @gateway.append(@pages_schema.title, pages.map { |page| build_row(table, @pages_schema, page_values(page)) })
40
+ pages.size
41
+ end
42
+
43
+ def record_audit(record, fingerprint:, at:)
44
+ table = read_table(@pages_schema)
45
+ change_rows(table, @pages_schema, { record.row => { last_audited: at, fingerprint: } })
46
+ end
47
+
48
+ # updates: { row => { importance:, tier:, importance_notes:, analyzed_at: } }, sent in one request. The Pages
49
+ # tab must have those columns.
50
+ def record_importance(updates)
51
+ return if updates.empty?
52
+
53
+ table = read_table(@pages_schema)
54
+ missing = IMPORTANCE - table.index.keys
55
+ unless missing.empty?
56
+ raise StoreError, "Tab Pages lacks the column(s) #{missing.join(', ')}. Type them at the end of its " \
57
+ "header row, with exactly these names. Run prepare to see every column it lacks."
58
+ end
59
+
60
+ change_rows(table, @pages_schema, updates)
61
+ end
62
+
63
+ def suggestions(page_name = nil)
64
+ table = read_table(@suggestions_schema)
65
+ records = data_rows(table).map { |cells, row| suggestion_record(table, cells, row) }
66
+ page_name ? records.select { |record| record.page == page_name } : records
67
+ end
68
+
69
+ def add_suggestions(records)
70
+ return 0 if records.empty?
71
+
72
+ table = read_table(@suggestions_schema)
73
+ rows = records.map { |record| build_row(table, @suggestions_schema, record.to_h.except(:row)) }
74
+ @gateway.append(@suggestions_schema.title, rows)
75
+ records.size
76
+ end
77
+
78
+ # updates: { row => { field => value } }. All changes are sent in one request.
79
+ def update_suggestions(updates)
80
+ return if updates.empty?
81
+
82
+ change_rows(read_table(@suggestions_schema), @suggestions_schema, updates)
83
+ end
84
+
85
+ # The rows of the Analysis tab as hashes by column name, each with its row number.
86
+ def analysis_rows
87
+ table = read_table(@analysis_schema)
88
+ data_rows(table).map { |cells, row| { row:, **attributes(table, @analysis_schema, cells) } }
89
+ end
90
+
91
+ # rows: hashes by column name. The rows the tab already has are overwritten in place, the rest are appended,
92
+ # and rows left over from a longer earlier run are emptied.
93
+ def replace_analysis(rows)
94
+ table = read_table(@analysis_schema)
95
+ built = rows.map { |values| build_row(table, @analysis_schema, values) }
96
+ height = table.rows.size
97
+ blank = Array.new(table.width, "")
98
+ kept = (0...height).map { |index| built[index] || blank }
99
+ @gateway.update_cells(@analysis_schema.title,
100
+ kept.each_with_index.flat_map { |values, index| row_cells(index + 2, values) })
101
+ @gateway.append(@analysis_schema.title, built.drop(height)) if built.size > height
102
+ end
103
+
104
+ private
105
+
106
+ # ---- reading ----
107
+
108
+ def read_table(schema)
109
+ values = @gateway.read(schema.title)
110
+ header = values.first.to_a.map { |name| name.to_s.strip.downcase }
111
+ schema.check!(header)
112
+ Table.new(index: header.each_with_index.to_h, rows: values.drop(1), width: header.size)
113
+ end
114
+
115
+ # The data rows with their row numbers in the sheet. Rows with nothing in them are skipped.
116
+ def data_rows(table)
117
+ table.rows.each_with_index.filter_map do |cells, index|
118
+ [cells, index + 2] unless cells.all? { |cell| cell.to_s.strip.empty? }
119
+ end
120
+ end
121
+
122
+ def attributes(table, schema, cells)
123
+ schema.columns.to_h do |column|
124
+ position = table.index[column.name]
125
+ [column.name.to_sym, Cells.from_cell(position ? cells[position] : "", column.type)]
126
+ end
127
+ end
128
+
129
+ def page_record(table, cells, row)
130
+ attrs = attributes(table, @pages_schema, cells)
131
+ urls = @languages.to_h { |code| [code, attrs[:"#{code}_url"].strip] }.reject { |_code, url| url.empty? }
132
+ PageRecord.new(row:, name: attrs[:page_name], urls:, last_audited: attrs[:last_audited],
133
+ fingerprint: attrs[:fingerprint], note: attrs[:note], sitemap_lastmod: attrs[:sitemap_lastmod],
134
+ importance: attrs[:importance], tier: attrs[:tier], boost: attrs[:boost])
135
+ end
136
+
137
+ def suggestion_record(table, cells, row)
138
+ SuggestionRecord.build(row:, **attributes(table, @suggestions_schema, cells))
139
+ end
140
+
141
+ # ---- writing ----
142
+
143
+ def page_values(page)
144
+ values = { "page_name" => page.name, "note" => page.notes.join("; "), "sitemap_lastmod" => page.lastmod }
145
+ @languages.each { |code| values["#{code}_url"] = page.urls[code] }
146
+ values
147
+ end
148
+
149
+ # A row as wide as the header, with each value in the column of its name. Columns the sheet lacks are skipped.
150
+ def build_row(table, schema, values)
151
+ cells = Array.new(table.width, "")
152
+ values.each do |name, value|
153
+ position = table.index[name.to_s]
154
+ cells[position] = Cells.to_cell(value, schema.column(name.to_s).type) if position
155
+ end
156
+ cells
157
+ end
158
+
159
+ def change_rows(table, schema, changes)
160
+ last_row = table.rows.size + 1
161
+ cells = changes.flat_map do |row, values|
162
+ raise StoreError, "There is no row #{row} in #{schema.title}" unless row.to_i.between?(2, last_row)
163
+
164
+ changed_cells(table, schema, row, values)
165
+ end
166
+ @gateway.update_cells(schema.title, cells)
167
+ end
168
+
169
+ def changed_cells(table, schema, row, values)
170
+ values.filter_map do |name, value|
171
+ column = schema.column(name.to_s)
172
+ raise StoreError, "Unknown field #{name} for #{schema.title}" unless column
173
+
174
+ position = table.index[column.name]
175
+ [row, position + 1, Cells.to_cell(value, column.type)] if position
176
+ end
177
+ end
178
+
179
+ def row_cells(row, values) = values.each_with_index.map { |value, index| [row, index + 1, value] }
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Store
6
+ # Sets up the tabs of the sheet: creates the ones that are missing, sets up empty ones, checks the others and
7
+ # names the columns they lack, and removes an empty default tab. Returns a line for each thing it did or found.
8
+ class TabSetup
9
+ DEFAULT_TABS = %w[Sheet1 Tabellenblatt1].freeze
10
+
11
+ def initialize(gateway, schemas)
12
+ @gateway = gateway
13
+ @schemas = schemas
14
+ end
15
+
16
+ def call
17
+ tabs = @gateway.tabs
18
+ @schemas.flat_map { |schema| prepare_tab(schema, tabs) } + remove_default_tab
19
+ end
20
+
21
+ private
22
+
23
+ def prepare_tab(schema, tabs)
24
+ title = schema.title
25
+ return create_tab(schema) unless tabs.key?(title)
26
+
27
+ header = @gateway.read(title).first.to_a
28
+ return fill_tab(schema, tabs[title]) if header.empty?
29
+
30
+ schema.check!(header)
31
+ existing(schema, header)
32
+ end
33
+
34
+ # A tab that exists keeps its columns as they are. Columns a newer version of the tool added are named, so
35
+ # they can be typed at the end of the header row.
36
+ def existing(schema, header)
37
+ absent = schema.absent(header)
38
+ return ["Tab #{schema.title} already exists."] if absent.empty?
39
+
40
+ ["Tab #{schema.title} already exists, but lacks the column(s) #{absent.join(', ')}. Type them at the end " \
41
+ "of its header row, with exactly these names."]
42
+ end
43
+
44
+ def create_tab(schema)
45
+ sheet_id = @gateway.add_tab(schema.title, rows: schema.rows, columns: schema.columns.size)
46
+ write_header(schema)
47
+ @gateway.format_tab(sheet_id, schema)
48
+ ["Created tab #{schema.title}."]
49
+ end
50
+
51
+ def fill_tab(schema, sheet_id)
52
+ write_header(schema)
53
+ @gateway.format_tab(sheet_id, schema)
54
+ ["Set up the empty tab #{schema.title}."]
55
+ end
56
+
57
+ def write_header(schema)
58
+ cells = schema.names.each_with_index.map { |name, index| [1, index + 1, name] }
59
+ @gateway.update_cells(schema.title, cells)
60
+ end
61
+
62
+ def remove_default_tab
63
+ ours = @schemas.map(&:title)
64
+ @gateway.tabs.filter_map do |title, sheet_id|
65
+ next unless DEFAULT_TABS.include?(title) && !ours.include?(title) && @gateway.read(title).empty?
66
+
67
+ @gateway.delete_tab(sheet_id)
68
+ "Removed the empty tab #{title}."
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end