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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +435 -0
- data/exe/yuno-proofreader +16 -0
- data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
- data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
- data/lib/yuno/proofreader/analysis/entries.rb +73 -0
- data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
- data/lib/yuno/proofreader/analysis/graph.rb +115 -0
- data/lib/yuno/proofreader/analysis/judge.rb +103 -0
- data/lib/yuno/proofreader/analysis/judge.yml +27 -0
- data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
- data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
- data/lib/yuno/proofreader/analysis/links.rb +54 -0
- data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
- data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
- data/lib/yuno/proofreader/analysis/report.rb +110 -0
- data/lib/yuno/proofreader/analysis/runner.rb +171 -0
- data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
- data/lib/yuno/proofreader/analysis/settings.rb +90 -0
- data/lib/yuno/proofreader/analysis/signals.rb +102 -0
- data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
- data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
- data/lib/yuno/proofreader/analysis/weights.rb +77 -0
- data/lib/yuno/proofreader/analysis/weights.yml +39 -0
- data/lib/yuno/proofreader/audit/agenda.rb +86 -0
- data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
- data/lib/yuno/proofreader/audit/answers.rb +74 -0
- data/lib/yuno/proofreader/audit/attempt.rb +69 -0
- data/lib/yuno/proofreader/audit/batch.rb +64 -0
- data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
- data/lib/yuno/proofreader/audit/cadence.rb +67 -0
- data/lib/yuno/proofreader/audit/instructions.rb +30 -0
- data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
- data/lib/yuno/proofreader/audit/prompt.rb +157 -0
- data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
- data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
- data/lib/yuno/proofreader/audit/report.rb +153 -0
- data/lib/yuno/proofreader/audit/runner.rb +144 -0
- data/lib/yuno/proofreader/audit/steps.rb +56 -0
- data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
- data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
- data/lib/yuno/proofreader/cli/command_log.rb +42 -0
- data/lib/yuno/proofreader/cli/flags.rb +48 -0
- data/lib/yuno/proofreader/cli/init_command.rb +116 -0
- data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
- data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
- data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
- data/lib/yuno/proofreader/cli.rb +218 -0
- data/lib/yuno/proofreader/config.rb +25 -0
- data/lib/yuno/proofreader/context.rb +55 -0
- data/lib/yuno/proofreader/env_file.rb +76 -0
- data/lib/yuno/proofreader/errors.rb +17 -0
- data/lib/yuno/proofreader/findings/finding.rb +95 -0
- data/lib/yuno/proofreader/findings/matching.rb +34 -0
- data/lib/yuno/proofreader/findings/merger.rb +43 -0
- data/lib/yuno/proofreader/findings/outline.rb +73 -0
- data/lib/yuno/proofreader/findings/plan.rb +24 -0
- data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
- data/lib/yuno/proofreader/findings/source.rb +79 -0
- data/lib/yuno/proofreader/findings/text.rb +37 -0
- data/lib/yuno/proofreader/http/client.rb +131 -0
- data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
- data/lib/yuno/proofreader/http/robots.rb +72 -0
- data/lib/yuno/proofreader/init/ask.rb +67 -0
- data/lib/yuno/proofreader/init/detector.rb +100 -0
- data/lib/yuno/proofreader/init/interview.rb +210 -0
- data/lib/yuno/proofreader/init/parsing.rb +47 -0
- data/lib/yuno/proofreader/init/web.rb +33 -0
- data/lib/yuno/proofreader/llm/client.rb +140 -0
- data/lib/yuno/proofreader/llm/defaults.rb +22 -0
- data/lib/yuno/proofreader/llm/defaults.yml +6 -0
- data/lib/yuno/proofreader/llm/errors.rb +26 -0
- data/lib/yuno/proofreader/llm/factory.rb +45 -0
- data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
- data/lib/yuno/proofreader/llm/json_text.rb +23 -0
- data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
- data/lib/yuno/proofreader/llm/provider.rb +58 -0
- data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
- data/lib/yuno/proofreader/llm/providers.rb +38 -0
- data/lib/yuno/proofreader/llm/types.rb +13 -0
- data/lib/yuno/proofreader/llm/usage.rb +17 -0
- data/lib/yuno/proofreader/log.rb +129 -0
- data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
- data/lib/yuno/proofreader/pages/report.rb +45 -0
- data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
- data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
- data/lib/yuno/proofreader/settings_file.rb +51 -0
- data/lib/yuno/proofreader/site.rb +74 -0
- data/lib/yuno/proofreader/site_reader.rb +131 -0
- data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
- data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
- data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
- data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
- data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
- data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
- data/lib/yuno/proofreader/sitemap/report.rb +80 -0
- data/lib/yuno/proofreader/store/cells.rb +67 -0
- data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
- data/lib/yuno/proofreader/store/memory.rb +90 -0
- data/lib/yuno/proofreader/store/records.rb +28 -0
- data/lib/yuno/proofreader/store/schema.rb +109 -0
- data/lib/yuno/proofreader/store/sheets.rb +183 -0
- data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
- data/lib/yuno/proofreader/version.rb +7 -0
- data/lib/yuno/proofreader.rb +101 -0
- data/proofreader.example.yml +134 -0
- 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
|