66import binascii
77import csv
88import io
9+ import hashlib
910import json
1011import logging
1112import os
2829 WorkbookListResponse , WorkbookWithLeadsResponse ,
2930 WorkbookLeadRow , EnrichmentOverlay ,
3031 RunWorkbookRequest , RunWorkbookResponse , RunCellRequest ,
31- AddColumnRequest , ExportRequest ,
32+ AddColumnRequest ,
3233 AddRowsRequest , ImportRowsRequest , DeleteRowsRequest , DeleteMatchingRowsRequest , BulkUpdateRowsRequest ,
3334 GenerateColumnRequest , GenerateColumnResponse ,
3435 WorkbookViewCreate , WorkbookViewUpdate ,
@@ -133,7 +134,8 @@ def _workbook_rows_query(db: Session, wb: Workbook, view_id: Optional[str], sear
133134 if expression is None :
134135 raise HTTPException (status_code = 409 , detail = "Saved view references a missing filter column. Repair the view before continuing." )
135136 normalized = sa_func .lower (sa_func .coalesce (expression , "" ))
136- expected = str (rule .get ("value" ) or "" ).lower ()
137+ comparison = rule .get ("value" )
138+ expected = str ("" if comparison is None else comparison ).lower ()
137139 operation = rule .get ("op" )
138140 if operation == "equals" :
139141 query = query .filter (normalized == expected )
@@ -205,6 +207,46 @@ def _decode_query_cursor(cursor: str, expected_values: int) -> list:
205207 raise HTTPException (status_code = 400 , detail = "Invalid workbook row cursor" )
206208
207209
210+ def _workbook_cursor_scope (query , ordering : list , view_id : Optional [str ]) -> str :
211+ # Bind row anchors to the owned workbook, effective filters and ordering.
212+ compiled = [expression .compile () for expression in (query .statement , * ordering )]
213+ identity = [view_id , * [(str (item ), item .params ) for item in compiled ]]
214+ return hashlib .sha256 (json .dumps (identity , sort_keys = True ).encode ()).hexdigest ()
215+
216+
217+ def _cursor_values_digest (values : list ) -> str :
218+ return hashlib .sha256 (json .dumps (values , separators = ("," , ":" )).encode ()).hexdigest ()
219+
220+
221+ def _encode_workbook_query_cursor (values : list , row_id : int , scope : str ) -> str :
222+ cursor = _encode_query_cursor (values )
223+ if len (cursor ) <= 512 :
224+ return cursor
225+ # Large cell values must not produce an unusable URL. Recover their tuple
226+ # from an owned row on continuation instead of copying the text into it.
227+ payload = json .dumps ({"v" : 2 , "row" : row_id , "scope" : scope , "digest" : _cursor_values_digest (values )}, separators = ("," , ":" )).encode ()
228+ return base64 .urlsafe_b64encode (payload ).decode ().rstrip ("=" )
229+
230+
231+ def _decode_workbook_query_cursor (cursor : str , query , cursor_terms : list , scope : str ) -> list :
232+ try :
233+ raw = base64 .urlsafe_b64decode (cursor + "=" * (- len (cursor ) % 4 ))
234+ payload = json .loads (raw )
235+ if isinstance (payload , dict ) and payload .get ("v" ) == 2 :
236+ row_id = payload .get ("row" )
237+ if type (row_id ) is not int or row_id < 1 or payload .get ("scope" ) != scope :
238+ raise ValueError
239+ values = query .with_entities (* (expression for expression , _ in cursor_terms )).filter (
240+ WorkbookRow .id == row_id
241+ ).first ()
242+ if values is None or payload .get ("digest" ) != _cursor_values_digest (list (values )):
243+ raise ValueError
244+ return list (values )
245+ except (ValueError , TypeError , json .JSONDecodeError , binascii .Error ):
246+ raise HTTPException (status_code = 400 , detail = "Invalid workbook row cursor; restart pagination" )
247+ return _decode_query_cursor (cursor , len (cursor_terms ))
248+
249+
208250def _encode_connector_run_cursor (run : ConnectorRun ) -> str :
209251 return _encode_query_cursor ([run .created_at .isoformat (), run .id ])
210252
@@ -552,11 +594,12 @@ async def get_workbook(
552594 query , ordering , cursor_terms = _workbook_rows_query (db , wb , view_id , search )
553595 query_total = query .with_entities (sa_func .count (WorkbookRow .id )).scalar () or 0
554596 using_cursor = cursor_mode or cursor is not None
597+ cursor_scope = _workbook_cursor_scope (query , ordering , view_id ) if len (cursor_terms ) > 2 else ""
555598 if cursor :
556599 if len (cursor_terms ) == 2 :
557600 cursor_values = list (_decode_row_cursor (cursor ))
558601 else :
559- cursor_values = _decode_query_cursor (cursor , len ( cursor_terms ) )
602+ cursor_values = _decode_workbook_query_cursor (cursor , query , cursor_terms , cursor_scope )
560603 query = query .filter (_cursor_after_filter (cursor_terms , cursor_values ))
561604 if using_cursor :
562605 fetched = query .order_by (* ordering ).limit (page_size + 1 ).all ()
@@ -575,7 +618,7 @@ async def get_workbook(
575618 cursor_values = db .query (
576619 * (expression for expression , _ in cursor_terms )
577620 ).filter (WorkbookRow .id == last_row .id ).one ()
578- next_cursor = _encode_query_cursor (list (cursor_values ))
621+ next_cursor = _encode_workbook_query_cursor (list (cursor_values ), last_row . id , cursor_scope )
579622
580623 # Older v2 mirrors omitted research metadata. Recover it read-only from
581624 # the matching cell receipt, never from a different workbook/tenant or
@@ -2070,7 +2113,10 @@ def _identity(d: dict) -> str:
20702113 )
20712114 except Exception as _e :
20722115 logger .warning ("on_row_added emit (add_rows) failed: %s" , _e )
2073- return {"added" : added , "skipped_duplicates" : skipped , "total_rows" : max_pos + added + 1 }
2116+ total_rows = db .query (sa_func .count (WorkbookRow .id )).filter (
2117+ WorkbookRow .workbook_id == wb .id
2118+ ).scalar () or 0
2119+ return {"added" : added , "skipped_duplicates" : skipped , "total_rows" : total_rows }
20742120
20752121
20762122@router .delete ("/{workbook_id}/rows" )
0 commit comments