1- /**
2- * @OnlyCurrentDoc Limits the script to only accessing the spreadsheet it's bound to.
3- */
4-
5- const CONFIG = {
6- REVIEW_STATUS : "Review Status" ,
7- REVIEWER_EMAIL : "Reviewer Email" ,
8- TIMESTAMP : "Review Timestamp" ,
9- NOTES : "Review Notes" ,
10- STATUS_IN_PROGRESS : "In Progress" ,
11- LOCK_TIMEOUT : 10000 , // Reduced to 10 seconds
12- CHUNK_SIZE : 1000 , // Process data in chunks
13- REQUIRED_COLUMNS : [ "Review Status" , "Reviewer Email" , "Review Timestamp" , "Review Notes" ] ,
14- VALID_DECISIONS : new Set ( [ "True" , "False" , "Unsure" ] ) ,
15- CACHE_DURATION : 21600 // 6 hours in seconds
1+ // Sheet column names and values
2+ const REVIEW_STATUS = 'review_status' ;
3+ const REVIEW_NOTES = 'review_notes' ;
4+ const REVIEW_USER = 'review_user' ;
5+ const REVIEW_TIMESTAMP = 'review_timestamp' ;
6+
7+ // Review status values
8+ const STATUS = {
9+ IN_PROGRESS : 'in_progress' ,
10+ CORRECT : 'correct' ,
11+ INCORRECT : 'incorrect' ,
12+ NOT_SURE : 'not_sure'
1613} ;
1714
18- function doGet ( e ) {
19- const sheetId = PropertiesService . getScriptProperties ( ) . getProperty ( 'SHEET_ID' ) ;
20- if ( ! sheetId ) {
21- return HtmlService . createHtmlOutput (
22- '<b>Error:</b> Spreadsheet ID not configured. Please set the SHEET_ID script property.'
23- ) ;
24- }
25-
26- try {
27- SpreadsheetApp . openById ( sheetId ) . getName ( ) ; // Test access
28- return HtmlService . createTemplateFromFile ( 'Index' )
29- . evaluate ( )
30- . setTitle ( 'Augmenta Review' )
31- . addMetaTag ( 'viewport' , 'width=device-width, initial-scale=1' ) ;
32- } catch ( err ) {
33- Logger . log ( "Error accessing Sheet ID '%s': %s" , sheetId , err ) ;
34- return HtmlService . createHtmlOutput (
35- `<b>Error:</b> Cannot access Spreadsheet with ID: ${ sheetId } . Check ID and permissions. Error: ${ err . message } `
36- ) ;
37- }
38- }
39-
4015/**
41- * Executes a function with a lock and proper cleanup
42- * @param {Function } callback Function to execute under lock
43- * @param {number } timeout Lock timeout in milliseconds
44- * @return {* } Result of the callback function
16+ * Get the active sheet and its header row
4517 */
46- function withLock_ ( callback , timeout = CONFIG . LOCK_TIMEOUT ) {
47- const lock = LockService . getScriptLock ( ) ;
48- try {
49- if ( ! lock . tryLock ( timeout ) ) {
50- throw new Error ( "Could not obtain lock. Server might be busy. Please try again." ) ;
51- }
52- return callback ( ) ;
53- } finally {
54- if ( lock . hasLock ( ) ) {
55- lock . releaseLock ( ) ;
56- }
57- }
18+ function getSheet ( ) {
19+ const sheet = SpreadsheetApp . getActiveSheet ( ) ;
20+ const headers = sheet . getRange ( 1 , 1 , 1 , sheet . getLastColumn ( ) ) . getValues ( ) [ 0 ] ;
21+ return { sheet, headers } ;
5822}
5923
6024/**
61- * Gets cached header indices or generates them if not cached
62- * @param {Sheet } sheet The sheet to get headers from
63- * @return {object } Header indices
25+ * Find column index by name
6426 */
65- function getHeaderIndices_ ( sheet ) {
66- const cache = CacheService . getScriptCache ( ) ;
67- const cacheKey = `header_indices_${ sheet . getSheetId ( ) } ` ;
68-
69- let indices = cache . get ( cacheKey ) ;
70- if ( indices ) {
71- return JSON . parse ( indices ) ;
72- }
73-
74- const headerData = getOrAddHeaders_ ( sheet ) ;
75- if ( ! headerData . success ) {
76- throw new Error ( headerData . error ) ;
77- }
78-
79- cache . put ( cacheKey , JSON . stringify ( headerData . indices ) , CONFIG . CACHE_DURATION ) ;
80- return headerData . indices ;
27+ function getColumnIndex ( headers , columnName ) {
28+ const index = headers . indexOf ( columnName ) ;
29+ if ( index === - 1 ) throw new Error ( `Column ${ columnName } not found` ) ;
30+ return index + 1 ;
8131}
8232
8333/**
84- * Finds the next unreviewed row efficiently
85- * @param {Sheet } sheet The sheet to search
86- * @param {number } statusCol Status column index
87- * @param {number } reviewerCol Reviewer column index
88- * @return {number } Row index or -1 if none found
34+ * Returns the next available row and marks it as in_progress
8935 */
90- function findNextUnreviewedRow_ ( sheet , statusCol , reviewerCol ) {
36+ function getNextRow ( ) {
37+ const { sheet, headers } = getSheet ( ) ;
38+ const statusCol = getColumnIndex ( headers , REVIEW_STATUS ) ;
39+
40+ // Get all review status values
9141 const lastRow = sheet . getLastRow ( ) ;
92- if ( lastRow <= 1 ) return - 1 ;
93-
94- for ( let startRow = 2 ; startRow <= lastRow ; startRow += CONFIG . CHUNK_SIZE ) {
95- const endRow = Math . min ( startRow + CONFIG . CHUNK_SIZE - 1 , lastRow ) ;
96- const range = sheet . getRange ( startRow , statusCol , endRow - startRow + 1 , 1 ) ;
97- const values = range . getValues ( ) ;
98-
99- const rowIndex = values . findIndex ( row => ! row [ 0 ] ) ;
100- if ( rowIndex !== - 1 ) {
101- return startRow + rowIndex ;
102- }
103- }
104- return - 1 ;
105- }
106-
107- /**
108- * Gets the next available row for review and assigns it to the current user.
109- * Uses optimized locking and caching strategies.
110- * @return {object } Response containing row data or error/message
111- */
112- function getNextRowToReview ( ) {
113- const userEmail = Session . getActiveUser ( ) . getEmail ( ) ;
114- if ( ! userEmail ) {
115- Logger . log ( 'Could not get user email.' ) ;
116- return { error : "Could not identify the current user. Please ensure you are logged into a Google Account." } ;
117- }
118-
119- const ss = SpreadsheetApp . openById ( PropertiesService . getScriptProperties ( ) . getProperty ( 'SHEET_ID' ) ) ;
120- const sheet = ss . getActiveSheet ( ) ;
121-
122- return withLock_ ( ( ) => {
123- try {
124- const headerIndices = getHeaderIndices_ ( sheet ) ;
125- const statusCol = headerIndices [ CONFIG . REVIEW_STATUS ] + 1 ;
126- const reviewerCol = headerIndices [ CONFIG . REVIEWER_EMAIL ] + 1 ;
127-
128- const nextRowIndex = findNextUnreviewedRow_ ( sheet , statusCol , reviewerCol ) ;
129- if ( nextRowIndex === - 1 ) {
130- return { message : "All rows have been reviewed or assigned." } ;
131- }
132-
133- // Batch update status and reviewer
134- sheet . getRange ( nextRowIndex , statusCol , 1 , 2 ) . setValues ( [ [
135- CONFIG . STATUS_IN_PROGRESS ,
136- userEmail
137- ] ] ) ;
138-
139- // Get complete row data outside of critical section
140- const rowRange = sheet . getRange ( nextRowIndex , 1 , 1 , sheet . getLastColumn ( ) ) ;
141- const rowData = rowRange . getValues ( ) [ 0 ] ;
142- const headers = sheet . getRange ( 1 , 1 , 1 , sheet . getLastColumn ( ) ) . getValues ( ) [ 0 ] ;
143-
144- Logger . log ( `Assigned row ${ nextRowIndex } to ${ userEmail } ` ) ;
42+ if ( lastRow <= 1 ) return null ; // Only header row exists
43+
44+ const statusRange = sheet . getRange ( 2 , statusCol , lastRow - 1 , 1 ) ;
45+ const statusValues = statusRange . getValues ( ) ;
46+
47+ // Find first empty status row
48+ for ( let i = 0 ; i < statusValues . length ; i ++ ) {
49+ if ( ! statusValues [ i ] [ 0 ] ) {
50+ const row = i + 2 ; // Add 2 for header row and 0-based index
51+
52+ // Mark as in_progress and return row data
53+ sheet . getRange ( row , statusCol ) . setValue ( STATUS . IN_PROGRESS ) ;
54+
55+ // Get all row data
56+ const rowData = sheet . getRange ( row , 1 , 1 , headers . length ) . getValues ( ) [ 0 ] ;
14557 return {
146- rowIndex : nextRowIndex ,
58+ rowIndex : row ,
14759 headers : headers ,
148- rowData : rowData
60+ data : rowData
14961 } ;
150-
151- } catch ( error ) {
152- Logger . log ( `Error in getNextRowToReview: ${ error } ` ) ;
153- return { error : `An error occurred: ${ error . message } ` } ;
15462 }
155- } ) ;
63+ }
64+
65+ return null ; // No available rows
15666}
15767
15868/**
159- * Submits review and gets next row in one operation for maximum performance.
160- * @param {number } rowIndex Row being reviewed (1-based)
161- * @param {string } decision Review decision ('True', 'False', 'Unsure')
162- * @param {string } notes Reviewer notes
163- * @return {object } Result containing success status and next row data
69+ * Submit a review for a specific row
16470 */
165- function submitAndGetNext ( rowIndex , decision , notes ) {
166- const userEmail = Session . getActiveUser ( ) . getEmail ( ) ;
167- if ( ! userEmail ) {
168- return { success : false , message : "Could not identify current user" } ;
169- }
170-
171- if ( ! rowIndex || ! CONFIG . VALID_DECISIONS . has ( decision ) ) {
172- return { success : false , message : "Invalid submission data" } ;
173- }
174-
175- const ss = SpreadsheetApp . openById ( PropertiesService . getScriptProperties ( ) . getProperty ( 'SHEET_ID' ) ) ;
176- const sheet = ss . getActiveSheet ( ) ;
177-
178- return withLock_ ( ( ) => {
179- try {
180- const headerIndices = getHeaderIndices_ ( sheet ) ;
181- const statusCol = headerIndices [ CONFIG . REVIEW_STATUS ] + 1 ;
182- const reviewerCol = headerIndices [ CONFIG . REVIEWER_EMAIL ] + 1 ;
183- const timestampCol = headerIndices [ CONFIG . TIMESTAMP ] + 1 ;
184- const notesCol = headerIndices [ CONFIG . NOTES ] + 1 ;
185-
186- // Submit current review
187- sheet . getRange ( rowIndex , statusCol , 1 , 4 ) . setValues ( [ [
188- decision ,
189- userEmail ,
190- new Date ( ) ,
191- notes || ""
192- ] ] ) ;
193-
194- // Find and assign next row
195- const nextRowIndex = findNextUnreviewedRow_ ( sheet , statusCol , reviewerCol ) ;
196- if ( nextRowIndex === - 1 ) {
197- return { success : true , message : "All rows reviewed" } ;
198- }
199-
200- // Assign next row and get its data
201- sheet . getRange ( nextRowIndex , statusCol , 1 , 2 ) . setValues ( [ [ CONFIG . STATUS_IN_PROGRESS , userEmail ] ] ) ;
202- const rowData = sheet . getRange ( nextRowIndex , 1 , 1 , sheet . getLastColumn ( ) ) . getValues ( ) [ 0 ] ;
203- const headers = sheet . getRange ( 1 , 1 , 1 , sheet . getLastColumn ( ) ) . getValues ( ) [ 0 ] ;
204-
205- SpreadsheetApp . flush ( ) ;
206- return {
207- success : true ,
208- rowIndex : nextRowIndex ,
209- headers : headers ,
210- rowData : rowData
211- } ;
212-
213- } catch ( error ) {
214- Logger . log ( `Error in submitAndGetNext: ${ error } ` ) ;
215- return { success : false , message : error . message } ;
216- }
217- } ) ;
71+ function submitReview ( rowIndex , decision , notes ) {
72+ const { sheet, headers } = getSheet ( ) ;
73+
74+ // Get column indices
75+ const statusCol = getColumnIndex ( headers , REVIEW_STATUS ) ;
76+ const notesCol = getColumnIndex ( headers , REVIEW_NOTES ) ;
77+ const userCol = getColumnIndex ( headers , REVIEW_USER ) ;
78+ const timeCol = getColumnIndex ( headers , REVIEW_TIMESTAMP ) ;
79+
80+ // Update each field individually to ensure correct column order
81+ sheet . getRange ( rowIndex , statusCol ) . setValue ( decision ) ;
82+ sheet . getRange ( rowIndex , notesCol ) . setValue ( notes ) ;
83+ sheet . getRange ( rowIndex , userCol ) . setValue ( Session . getActiveUser ( ) . getEmail ( ) ) ;
84+ sheet . getRange ( rowIndex , timeCol ) . setValue ( new Date ( ) ) ;
85+
86+ return true ;
21887}
21988
22089/**
221- * Ensures required columns exist and returns their indices
222- * @param {Sheet } sheet The sheet to check/modify
223- * @return {object } Header information and status
90+ * Web app entry point - serves HTML interface
22491 */
225- function getOrAddHeaders_ ( sheet ) {
226- try {
227- const headerRange = sheet . getRange ( 1 , 1 , 1 , sheet . getMaxColumns ( ) ) ;
228- const headers = headerRange . getValues ( ) [ 0 ] . map ( h => String ( h ) . trim ( ) ) ;
229- const headerIndices = { } ;
230- const missingCols = [ ] ;
231- let nextCol = headers . length ;
232-
233- // Find existing headers and identify missing ones
234- CONFIG . REQUIRED_COLUMNS . forEach ( colName => {
235- const index = headers . findIndex ( h => h === colName ) ;
236- if ( index !== - 1 ) {
237- headerIndices [ colName ] = index ;
238- } else {
239- missingCols . push ( colName ) ;
240- headerIndices [ colName ] = nextCol ++ ;
241- }
242- } ) ;
243-
244- // Add missing columns if needed
245- if ( missingCols . length > 0 ) {
246- Logger . log ( "Adding missing columns: " + missingCols . join ( ', ' ) ) ;
247- const currentCols = sheet . getMaxColumns ( ) ;
248- const neededCols = Math . max ( ...Object . values ( headerIndices ) ) + 1 ;
249-
250- if ( neededCols > currentCols ) {
251- sheet . insertColumnsAfter ( currentCols , neededCols - currentCols ) ;
252- }
253-
254- // Batch update for missing headers
255- const headerUpdates = missingCols . map ( colName => [ colName ] ) ;
256- const updateRange = sheet . getRange ( 1 , headerIndices [ missingCols [ 0 ] ] + 1 , 1 , missingCols . length ) ;
257- updateRange . setValues ( headerUpdates ) ;
258-
259- // Refresh headers after adding new columns
260- const updatedHeaders = sheet . getRange ( 1 , 1 , 1 , neededCols ) . getValues ( ) [ 0 ] ;
261- return {
262- success : true ,
263- headers : updatedHeaders ,
264- indices : headerIndices
265- } ;
266- }
267-
268- return {
269- success : true ,
270- headers : headers ,
271- indices : headerIndices
272- } ;
273-
274- } catch ( error ) {
275- Logger . log ( `Error in getOrAddHeaders_: ${ error } ` ) ;
276- return {
277- success : false ,
278- error : `Failed to set up review columns: ${ error . message } `
279- } ;
280- }
92+ function doGet ( ) {
93+ return HtmlService . createHtmlOutputFromFile ( 'Index' )
94+ . setTitle ( 'Review Interface' )
95+ . setFaviconUrl ( 'https://www.google.com/sheets/about/favicon.ico' ) ;
28196}
0 commit comments