/** @OnlyCurrentDoc * Bound-script menu. Install ClientCore from ../client-core.cjs as a second file. * Opening/recalculating/editing the sheet never publishes anything. */ function onOpen() { SpreadsheetApp.getUi() .createMenu('Rivory Model Connect') .addItem('1. Configure my credential', 'rivoryConfigure') .addItem('2. List competitions', 'rivoryCompetitions') .addItem('3. Load blank output template', 'rivoryTemplate') .addItem('4. Preview output values', 'rivoryPreview') .addItem('5. Publish reviewed batch / retry', 'rivoryPublish') .addItem('Read my batch receipt', 'rivoryReceipt') .addItem('Discard unpublished preview', 'rivoryDiscard') .addItem('Forget my credential', 'rivoryForgetCredential') .addToUi() } function rivoryPrompt_(title, message) { var ui = SpreadsheetApp.getUi(), result = ui.prompt(title, message, ui.ButtonSet.OK_CANCEL) if (result.getSelectedButton() !== ui.Button.OK) return null return result.getResponseText().trim() } function rivoryAction_(fn) { try { var message = fn() if (message) SpreadsheetApp.getUi().alert(message) } catch (error) { SpreadsheetApp.getUi().alert(error.message) } } function rivoryLocked_(fn) { var lock = LockService.getUserLock() lock.waitLock(5000) try { return fn() } finally { lock.releaseLock() } } function rivoryConfig_() { var p = PropertiesService.getUserProperties() return { api_base: RivoryModelConnect.apiBase(p.getProperty('rv_api_base')), token: RivoryModelConnect.credential(p.getProperty('rv_token')), competition_id: p.getProperty('rv_competition'), model_version: p.getProperty('rv_model_version'), } } function rivoryConfigure() { rivoryAction_(function () { var base = rivoryPrompt_('API base', 'Copy the HTTPS API base from Model Connect in Rivory.') if (base === null) return RivoryModelConnect.apiBase(base) var token = rivoryPrompt_( 'Scoped Model credential', 'Stored in your UserProperties, never a cell. Only use a bound script with trusted editors.' ) if (token === null) return RivoryModelConnect.credential(token) var catalog = rivoryFetch_( { api_base: RivoryModelConnect.apiBase(base), token: token }, '/competitions' ) RivoryModelConnect.check( Array.isArray(catalog.competitions) && catalog.competitions.length > 0, 'No eligible competitions for this credential.' ) var competition = rivoryPrompt_( 'Competition UUID', 'Choose one of these scoped competition IDs:\n' + catalog.competitions .map(function (c) { return c.id + ' · ' + c.name }) .join('\n') ) if (competition === null) return RivoryModelConnect.check( catalog.competitions.some(function (c) { return c.id === competition }), 'Use a canonical competition UUID from the displayed catalog.' ) var version = rivoryPrompt_( 'Model version', 'A version label for this operator’s model (at most 100 characters).' ) if (version === null) return RivoryModelConnect.check( version.length > 0 && version.length <= 100, 'Model version is required.' ) return rivoryLocked_(function () { var run = rivoryLoadRun_() RivoryModelConnect.check( !run || run.api_base === RivoryModelConnect.apiBase(base), 'Discard or resolve the saved run before changing API base.' ) PropertiesService.getUserProperties().setProperties({ rv_api_base: RivoryModelConnect.apiBase(base), rv_token: token, rv_competition: competition, rv_model_version: version, }) return 'Configuration saved for your user. No predictions were sent.' }) }) } function rivoryFetch_(config, path, body, key) { RivoryModelConnect.check( /^\/(?:competitions(?:\/[0-9a-f-]+\/fixtures(?:\?after=[0-9a-f-]+&limit=100)?)?|predictions\/(?:validate|batches(?:\/[0-9a-f-]+)?)|me\/predictions)$/.test( path ), 'Unsupported API path.' ) var options = { method: body ? 'post' : 'get', headers: { Authorization: 'Bearer ' + config.token }, followRedirects: false, muteHttpExceptions: true, } if (body) { options.contentType = 'application/json' options.payload = body } if (key) options.headers['Idempotency-Key'] = key var response try { response = UrlFetchApp.fetch(config.api_base + path, options) } catch (_) { throw new Error( 'No receipt returned. Keep this saved run. Explicitly retry Publish with the same payload and key.' ) } return RivoryModelConnect.response(response.getResponseCode(), response.getContentText()) } function rivoryFixtures_(config) { var all = [], seen = {}, after = null do { var page = rivoryFetch_( config, '/competitions/' + config.competition_id + '/fixtures' + (after ? '?after=' + encodeURIComponent(after) + '&limit=100' : '') ) RivoryModelConnect.check( Array.isArray(page.fixtures) && (page.next_cursor === null || /^[0-9a-f-]{36}$/i.test(page.next_cursor)), 'Unexpected fixture catalog.' ) all = all.concat(page.fixtures) after = page.next_cursor RivoryModelConnect.check(!after || !seen[after], 'Repeated catalog cursor.') seen[after] = true RivoryModelConnect.check(all.length <= 10000, 'Catalog is too large.') } while (after) return all } function rivoryCompetitions() { rivoryAction_(function () { var value = rivoryFetch_(rivoryConfig_(), '/competitions') RivoryModelConnect.check(Array.isArray(value.competitions), 'Unexpected competition catalog.') return ( value.competitions .map(function (c) { return c.id + ' · ' + c.name + ' · ' + c.eligibility }) .join('\n') || 'No eligible competitions for this credential.' ) }) } function rivoryWriteSheet_(name, rows) { var book = SpreadsheetApp.getActive(), sheet = book.getSheetByName(name) || book.insertSheet(name) sheet.clearContents() var range = sheet.getRange(1, 1, rows.length, rows[0].length) range.setNumberFormat('@') // IDs/hashes remain text. Also neutralize formula-like external strings. range.setValues( rows.map(function (r) { return r.map(RivoryModelConnect.safeCell) }) ) sheet.setFrozenRows(1) sheet.autoResizeColumns(1, rows[0].length) return sheet } function rivoryTemplate() { rivoryAction_(function () { var format = rivoryPrompt_('Pick format', 'Enter straight or spread.') if (format === null) return RivoryModelConnect.check( ['straight', 'spread'].indexOf(format) !== -1, 'Choose straight or spread.' ) var config = rivoryConfig_(), fixtures = rivoryFixtures_(config), rounds = RivoryModelConnect.availableRounds(fixtures) RivoryModelConnect.check(rounds.length > 0, 'No unlocked fixtures are available.') var round = rivoryPrompt_( 'Round', 'Copy an exact canonical round label from this catalog:\n' + rounds .map(function (r) { return r.round_label + ' · ' + r.unlocked_fixtures + ' unlocked fixtures' }) .join('\n') + '\nBlank includes every unlocked fixture (maximum 100).' ) if (round === null) return var ui = SpreadsheetApp.getUi() if ( ui.alert( 'Replace Rivory Output?', 'This replaces only the dedicated Rivory Output tab. Other model tabs are untouched. Selections start blank.', ui.ButtonSet.YES_NO ) !== ui.Button.YES ) return return rivoryLocked_(function () { var run = rivoryLoadRun_() RivoryModelConnect.check( !run || run.state !== 'uncertain', 'Resolve the uncertain batch with Publish / retry before making a new output.' ) var current = rivoryConfig_() RivoryModelConnect.check( current.api_base === config.api_base && current.token === config.token && current.competition_id === config.competition_id, 'Configuration changed while you chose the template. Start again.' ) rivoryWriteSheet_('Rivory Output', RivoryModelConnect.template(fixtures, format, round)) return 'Template loaded. Put explicit canonical home/away participant IDs in picked_participant_id. Optional projections never choose a side for you.' }) }) } // Transactional pointer + 7,000-character chunks avoid the per-property 9 KiB limit. // No token is part of the run. A failed save occurs before any publish request. function rivorySaveRun_(run) { var p = PropertiesService.getUserProperties(), previous = p.getProperty('rv_run_pointer') var prefix = 'rv_run_' + Utilities.getUuid() + '_' var text = Utilities.base64EncodeWebSafe(JSON.stringify(run), Utilities.Charset.UTF_8), count = Math.ceil(text.length / 7000), written = [] RivoryModelConnect.check( text.length <= 400000, 'Saved run exceeds Apps Script storage allowance. Use the Node publisher.' ) try { for (var i = 0; i < count; i++) { var name = prefix + i p.setProperty(name, text.slice(i * 7000, (i + 1) * 7000)) written.push(name) } p.setProperty('rv_run_pointer', JSON.stringify({ prefix: prefix, count: count })) } catch (_) { written.forEach(function (name) { p.deleteProperty(name) }) throw new Error( 'Could not persist the run or receipt. Preserve the prior saved run. A publish may already have been sent; recover using the same payload and key.' ) } if (previous) rivoryDeleteChunks_(JSON.parse(previous)) } function rivoryDeleteChunks_(pointer) { var p = PropertiesService.getUserProperties() for (var i = 0; i < pointer.count; i++) p.deleteProperty(pointer.prefix + i) } function rivoryLoadRun_() { var p = PropertiesService.getUserProperties(), text = p.getProperty('rv_run_pointer') if (!text) return null var pointer = JSON.parse(text), encoded = '' for (var i = 0; i < pointer.count; i++) { var chunk = p.getProperty(pointer.prefix + i) RivoryModelConnect.check( chunk !== null, 'Saved run is incomplete; restore the original run before retrying.' ) encoded += chunk } return JSON.parse( Utilities.newBlob(Utilities.base64DecodeWebSafe(encoded)).getDataAsString('UTF-8') ) } function rivoryReceiptSheet_(run, value) { var payload = RivoryModelConnect.assertRun(run, run.api_base) var rows = [ [ 'run_id', 'generated_at', 'model_version', 'batch_id', 'fixture_id', 'pick_format', 'picked_participant_id', 'status', 'code', 'message', 'revision', 'line_revision', ], ] value.rows.forEach(function (r, i) { var pick = payload.predictions[i] rows.push([ payload.external_run_id, payload.generated_at, payload.model_version, value.batch_id || 'Preview only', r.fixture_id, pick.pick_format, pick.picked_participant_id, r.status, r.code || '', r.message, r.revision == null ? '' : r.revision, r.line_revision || '', ]) }) rivoryWriteSheet_('Rivory Receipt', rows) } function rivoryPreview() { rivoryAction_(function () { return rivoryLocked_(function () { var old = rivoryLoadRun_() RivoryModelConnect.check( !old || old.state !== 'uncertain', 'Resolve the uncertain batch with Publish / retry before generating a new run.' ) var config = rivoryConfig_(), sheet = SpreadsheetApp.getActive().getSheetByName('Rivory Output') RivoryModelConnect.check(sheet, 'Load the output template first.') // getValues returns computed cell values, never executed formula text or source URLs. var run = RivoryModelConnect.newRun( config, sheet.getDataRange().getValues(), rivoryFixtures_(config), new Date().toISOString(), Utilities.getUuid(), Utilities.getUuid() ) rivorySaveRun_(run) var value = rivoryFetch_(config, '/predictions/validate', run.payload_text) RivoryModelConnect.previewResult(run, value) rivorySaveRun_(run) rivoryReceiptSheet_(run, value) return run.state === 'previewed' ? 'Preview passed. Review Rivory Receipt, then use Publish explicitly. Sheet edits after Preview do not change this saved batch.' : 'Preview contains rejected rows. Fix output values and Preview a new run. Nothing was published.' }) }) } function rivoryPublish() { rivoryAction_(function () { var snapshot = rivoryLoadRun_() RivoryModelConnect.check(snapshot, 'Preview first.') var payload = RivoryModelConnect.assertRun(snapshot, rivoryConfig_().api_base), spread = payload.predictions.some(function (r) { return r.pick_format === 'spread' }) var ui = SpreadsheetApp.getUi(), warning = 'Publish this frozen run ' + payload.external_run_id + ' (' + payload.predictions.length + ' rows)? Review the Rivory Receipt tab first. Later sheet edits are not included.' if (spread) warning += '\nSpread grading uses the line captured at kickoff. Preview checks the current line revision; publication does not pin the future grading line. Your chosen side stays fixed. Continue only if you accept this.' if (ui.alert('Publish / recover receipt', warning, ui.ButtonSet.YES_NO) !== ui.Button.YES) return return rivoryLocked_(function () { var config = rivoryConfig_(), run = rivoryLoadRun_() RivoryModelConnect.check( run && run.payload_text === snapshot.payload_text && run.idempotency_key === snapshot.idempotency_key, 'Saved run changed while you reviewed; review the new batch first.' ) RivoryModelConnect.publishReady(run, config.api_base, true, true) run.state = 'uncertain' rivorySaveRun_(run) var value = rivoryFetch_( config, '/predictions/batches', run.payload_text, run.idempotency_key ) RivoryModelConnect.publishResult(run, value) rivorySaveRun_(run) rivoryReceiptSheet_(run, value) return value.rows.some(function (r) { return r.status === 'rejected' }) ? 'Batch returned some rejected rows. Inspect each receipt; accepted rows are already published. Do not resend a new full batch blindly.' : 'Owned receipt saved. Picks published (or unchanged on retry).' }) }) } function rivoryReceipt() { rivoryAction_(function () { return rivoryLocked_(function () { var config = rivoryConfig_(), run = rivoryLoadRun_() RivoryModelConnect.check( run && run.receipt && run.receipt.batch_id, 'No batch ID returned. Explicitly use Publish / retry on the same saved run to recover its receipt.' ) var value = rivoryFetch_(config, '/predictions/batches/' + run.receipt.batch_id) RivoryModelConnect.publishResult(run, value) rivorySaveRun_(run) rivoryReceiptSheet_(run, value) return 'Owned batch receipt refreshed.' }) }) } function rivoryDiscard() { rivoryAction_(function () { return rivoryLocked_(function () { var run = rivoryLoadRun_() RivoryModelConnect.check( !run || ['unpreviewed', 'needs_changes', 'previewed'].indexOf(run.state) !== -1, 'Resolve an uncertain/published batch before replacing it; do not lose its retry identity.' ) var p = PropertiesService.getUserProperties(), pointer = p.getProperty('rv_run_pointer') p.deleteProperty('rv_run_pointer') if (pointer) rivoryDeleteChunks_(JSON.parse(pointer)) return 'Unpublished preview discarded. No picks were sent.' }) }) } function rivoryForgetCredential() { PropertiesService.getUserProperties().deleteProperty('rv_token') SpreadsheetApp.getUi().alert( 'Credential removed locally. Revoke it in Rivory to invalidate other copies. The saved run remains for receipt recovery.' ) }