gslides-automator 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,2761 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+ """
4
+ Script to dynamically generate Google Slides presentations from Google Sheets.
5
+ Processes multiple entity spreadsheets from a Google Drive folder.
6
+ For each spreadsheet, reads sheets named <type>-<placeholder> (chart/table/picture),
7
+ copies a template presentation, and replaces placeholders with linked assets from the sheets.
8
+ """
9
+
10
+ import gspread
11
+ import os
12
+ import sys
13
+ import re
14
+ import time
15
+ import json
16
+ import argparse
17
+ from typing import Optional, Set
18
+ from googleapiclient.discovery import build
19
+ from googleapiclient.errors import HttpError
20
+ from google.auth.transport.requests import Request
21
+
22
+ _TABLE_SLIDE_PROCEED_DECISION: Optional[bool] = None # Session-level choice for table slide regeneration
23
+
24
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
25
+ PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
26
+ sys.path.insert(0, PROJECT_ROOT)
27
+ try:
28
+ from gslides_automator.auth import get_oauth_credentials
29
+ from gslides_automator.drive_layout import (
30
+ DriveLayout,
31
+ load_entities_with_slides,
32
+ resolve_layout,
33
+ )
34
+ except ImportError: # Fallback for package-relative execution
35
+ from .auth import get_oauth_credentials
36
+ from .drive_layout import DriveLayout, load_entities_with_slides, resolve_layout
37
+
38
+ def retry_with_exponential_backoff(func, max_retries=5, initial_delay=1, max_delay=60, backoff_factor=2):
39
+ """
40
+ Retry a function with exponential backoff on 429 (Too Many Requests) and 5xx (Server) errors.
41
+
42
+ Args:
43
+ func: Function to retry (should be a callable that takes no arguments)
44
+ max_retries: Maximum number of retry attempts (default: 5)
45
+ initial_delay: Initial delay in seconds before first retry (default: 1)
46
+ max_delay: Maximum delay in seconds between retries (default: 60)
47
+ backoff_factor: Factor to multiply delay by after each retry (default: 2)
48
+
49
+ Returns:
50
+ The return value of func() if successful
51
+
52
+ Raises:
53
+ HttpError: If the error is not retryable or if max_retries is exceeded
54
+ Exception: Any other exception raised by func()
55
+ """
56
+ delay = initial_delay
57
+
58
+ for attempt in range(max_retries + 1):
59
+ try:
60
+ return func()
61
+ except HttpError as error:
62
+ status = error.resp.status
63
+ # Check if it's a retryable error (429 Too Many Requests or 5xx Server Errors)
64
+ is_retryable = (status == 429) or (500 <= status < 600)
65
+
66
+ if is_retryable:
67
+ if attempt < max_retries:
68
+ # Calculate wait time with exponential backoff
69
+ wait_time = min(delay, max_delay)
70
+ if status == 429:
71
+ error_msg = "Rate limit exceeded (429)"
72
+ else:
73
+ error_msg = f"Server error ({status})"
74
+ print(f" ⚠️ {error_msg}. Retrying in {wait_time:.1f} seconds... (attempt {attempt + 1}/{max_retries})")
75
+ time.sleep(wait_time)
76
+ delay *= backoff_factor
77
+ else:
78
+ if status == 429:
79
+ error_msg = "Rate limit exceeded (429)"
80
+ else:
81
+ error_msg = f"Server error ({status})"
82
+ print(f" ✗ {error_msg}. Max retries ({max_retries}) reached.")
83
+ raise
84
+ else:
85
+ # For non-retryable errors, re-raise immediately
86
+ raise
87
+ except Exception as e:
88
+ # For non-HttpError exceptions, re-raise immediately
89
+ raise
90
+
91
+ def list_entity_folders(parent_folder_id, creds):
92
+ """
93
+ List all entity subfolders in the parent folder.
94
+
95
+ Args:
96
+ parent_folder_id: ID of the parent folder containing entity folders
97
+ creds: Service account credentials
98
+
99
+ Returns:
100
+ list: List of tuples (folder_id, folder_name)
101
+ """
102
+ drive_service = build('drive', 'v3', credentials=creds)
103
+ folders = []
104
+
105
+ try:
106
+ # Query for folders in the parent folder
107
+ query = f"mimeType='application/vnd.google-apps.folder' and '{parent_folder_id}' in parents and trashed=false"
108
+
109
+ results = drive_service.files().list(
110
+ q=query,
111
+ fields='files(id, name)',
112
+ pageSize=1000,
113
+ supportsAllDrives=True,
114
+ includeItemsFromAllDrives=True
115
+ ).execute()
116
+
117
+ items = results.get('files', [])
118
+
119
+ for item in items:
120
+ folders.append((item['id'], item['name']))
121
+
122
+ return folders
123
+
124
+ except HttpError as error:
125
+ print(f"Error listing entity folders: {error}")
126
+ return []
127
+
128
+ def list_spreadsheets_in_folder(folder_id, creds):
129
+ """
130
+ List all Google Sheets files in a Google Drive folder.
131
+
132
+ Args:
133
+ folder_id: ID of the folder to search
134
+ creds: Service account credentials
135
+
136
+ Returns:
137
+ list: List of tuples (spreadsheet_id, spreadsheet_name)
138
+ """
139
+ drive_service = build('drive', 'v3', credentials=creds)
140
+ spreadsheets = []
141
+
142
+ try:
143
+ # Query for Google Sheets files in the folder
144
+ query = f"mimeType='application/vnd.google-apps.spreadsheet' and '{folder_id}' in parents and trashed=false"
145
+
146
+ results = drive_service.files().list(
147
+ q=query,
148
+ fields='files(id, name)',
149
+ pageSize=1000,
150
+ supportsAllDrives=True,
151
+ includeItemsFromAllDrives=True
152
+ ).execute()
153
+
154
+ items = results.get('files', [])
155
+
156
+ for item in items:
157
+ spreadsheets.append((item['id'], item['name']))
158
+
159
+ return spreadsheets
160
+
161
+ except HttpError as error:
162
+ print(f"Error listing spreadsheets in folder: {error}")
163
+ return []
164
+
165
+ def parse_sheet_name(sheet_name):
166
+ """
167
+ Parse sheet name to extract placeholder type and name using hyphen prefixes.
168
+
169
+ Args:
170
+ sheet_name: Name of the sheet (e.g., "chart-pass-percentage")
171
+
172
+ Returns:
173
+ tuple: (placeholder_type, placeholder_name) or None if pattern doesn't match
174
+ """
175
+ pattern = r'^(chart|table|picture)-(.+)$'
176
+ match = re.match(pattern, sheet_name)
177
+ if match:
178
+ placeholder_type = match.group(1)
179
+ placeholder_name = match.group(2)
180
+ return placeholder_type, placeholder_name
181
+ return None
182
+
183
+ def get_entity_name_from_common_data(spreadsheet_id, creds):
184
+ """
185
+ Read entity_name from the 'common_data' sheet in the spreadsheet.
186
+
187
+ Args:
188
+ spreadsheet_id: ID of the spreadsheet
189
+ creds: Service account credentials
190
+
191
+ Returns:
192
+ str: Entity name from the first data row (row 2) in the 'entity_name' column,
193
+ or None if the sheet doesn't exist
194
+ """
195
+ gspread_client = gspread.authorize(creds)
196
+
197
+ try:
198
+ # Open the spreadsheet
199
+ spreadsheet = gspread_client.open_by_key(spreadsheet_id)
200
+
201
+ # Find the 'common_data' sheet
202
+ try:
203
+ common_data_sheet = spreadsheet.worksheet('common_data')
204
+ except gspread.exceptions.WorksheetNotFound:
205
+ print("Error: 'common_data' sheet not found in spreadsheet")
206
+ return None
207
+
208
+ # Read the first data row (row 2, assuming row 1 is header)
209
+ # Get all values from the sheet
210
+ all_values = common_data_sheet.get_all_values()
211
+
212
+ if not all_values or len(all_values) < 2:
213
+ print("Error: 'common_data' sheet has no data rows")
214
+ return None
215
+
216
+ # Find the 'entity_name' column index from header row (row 1, index 0)
217
+ header_row = all_values[0]
218
+ try:
219
+ entity_name_col_index = header_row.index('entity_name')
220
+ except ValueError:
221
+ print("Error: 'entity_name' column not found in 'common_data' sheet")
222
+ return None
223
+
224
+ # Get the entity name from the first data row (row 2, index 1)
225
+ data_row = all_values[1]
226
+ if len(data_row) <= entity_name_col_index:
227
+ print("Error: 'entity_name' column is empty in 'common_data' sheet")
228
+ return None
229
+
230
+ entity_name = data_row[entity_name_col_index].strip()
231
+ if not entity_name:
232
+ print("Error: 'entity_name' is empty in 'common_data' sheet")
233
+ return None
234
+
235
+ return entity_name
236
+
237
+ except Exception as e:
238
+ print(f"Error reading entity_name from 'common_data' sheet: {e}")
239
+ return None
240
+
241
+ def read_data_from_sheet(spreadsheet_id, sheet_name, creds):
242
+ """
243
+ Read data from a data sheet where column 1 contains keys and column 2 contains values.
244
+
245
+ Args:
246
+ spreadsheet_id: ID of the spreadsheet
247
+ sheet_name: Name of the data sheet (e.g., "data")
248
+ creds: Service account credentials
249
+
250
+ Returns:
251
+ dict: Dictionary mapping keys (column 1) to values (column 2) from each row,
252
+ or None if the sheet doesn't exist or has errors
253
+ """
254
+ gspread_client = gspread.authorize(creds)
255
+
256
+ try:
257
+ # Open the spreadsheet
258
+ spreadsheet = gspread_client.open_by_key(spreadsheet_id)
259
+
260
+ # Find the data sheet
261
+ try:
262
+ data_sheet = spreadsheet.worksheet(sheet_name)
263
+ except gspread.exceptions.WorksheetNotFound:
264
+ print(f"Error: Data sheet '{sheet_name}' not found in spreadsheet")
265
+ return None
266
+
267
+ # Read all values from the sheet
268
+ all_values = data_sheet.get_all_values()
269
+
270
+ if not all_values:
271
+ print(f"Error: Data sheet '{sheet_name}' has no data rows")
272
+ return None
273
+
274
+ # Create dictionary mapping column 1 (key) to column 2 (value) from each row
275
+ data_dict = {}
276
+ for row in all_values:
277
+ if len(row) >= 2: # Ensure row has at least 2 columns
278
+ key = row[0].strip()
279
+ value = row[1].strip() if len(row) > 1 else ""
280
+ if key: # Only process rows with non-empty keys
281
+ data_dict[key] = value
282
+
283
+ if not data_dict:
284
+ print(f"Error: No valid key-value pairs found in data sheet '{sheet_name}'")
285
+ return None
286
+
287
+ return data_dict
288
+
289
+ except Exception as e:
290
+ print(f"Error reading data from sheet '{sheet_name}': {e}")
291
+ return None
292
+
293
+ def read_table_from_sheet(spreadsheet_id, sheet_name, creds):
294
+ """
295
+ Read 2D table data from a sheet. Returns a list of rows (list of strings).
296
+ Keeps the raw values; formatting is preserved in Slides by reusing existing cell styles.
297
+ """
298
+ gspread_client = gspread.authorize(creds)
299
+
300
+ try:
301
+ spreadsheet = gspread_client.open_by_key(spreadsheet_id)
302
+ try:
303
+ worksheet = spreadsheet.worksheet(sheet_name)
304
+ except gspread.exceptions.WorksheetNotFound:
305
+ print(f" ⚠️ Table sheet '{sheet_name}' not found in spreadsheet")
306
+ return None
307
+
308
+ values = worksheet.get_all_values()
309
+ return values or []
310
+ except Exception as e:
311
+ print(f" ⚠️ Error reading table data from sheet '{sheet_name}': {e}")
312
+ return None
313
+
314
+ def delete_existing_presentation(entity_name, output_folder_id, creds):
315
+ """
316
+ Delete an existing presentation for a entity if it exists in the output folder.
317
+
318
+ Args:
319
+ entity_name: Name of the entity (e.g., "Madurai")
320
+ output_folder_id: ID of the folder to search for the presentation
321
+ creds: Service account credentials
322
+
323
+ Returns:
324
+ bool: True if a presentation was found and deleted, False otherwise
325
+ """
326
+ drive_service = build('drive', 'v3', credentials=creds)
327
+
328
+ try:
329
+ # Search for existing presentation with the expected name
330
+ expected_filename = f"{entity_name}.gslides"
331
+ query = f"'{output_folder_id}' in parents and name='{expected_filename}' and mimeType='application/vnd.google-apps.presentation' and trashed=false"
332
+
333
+ def list_files():
334
+ return drive_service.files().list(
335
+ q=query,
336
+ fields='files(id, name)',
337
+ pageSize=10,
338
+ supportsAllDrives=True,
339
+ includeItemsFromAllDrives=True
340
+ ).execute()
341
+
342
+ results = retry_with_exponential_backoff(list_files)
343
+
344
+ files = results.get('files', [])
345
+ if files:
346
+ # Delete all matching files (should typically be just one)
347
+ for file in files:
348
+ # First check if file is accessible
349
+ try:
350
+ file_check = drive_service.files().get(
351
+ fileId=file['id'],
352
+ fields='id, name',
353
+ supportsAllDrives=True
354
+ ).execute()
355
+ except HttpError as check_error:
356
+ if check_error.resp.status == 404:
357
+ try:
358
+ from .auth import get_service_account_email
359
+ service_account_email = get_service_account_email()
360
+ print(f" ⚠️ Presentation '{file['name']}' not accessible to service account.")
361
+ print(f" Service account email: {service_account_email}")
362
+ print(f" Please ensure the file is shared with this service account with 'Editor' permissions.")
363
+ except Exception:
364
+ print(f" ⚠️ Presentation '{file['name']}' not accessible to service account.")
365
+ print(f" Please ensure the file is shared with your service account with 'Editor' permissions.")
366
+ continue
367
+ else:
368
+ print(f" ⚠️ Error checking presentation access: {check_error}")
369
+ continue
370
+
371
+ def delete_file():
372
+ return drive_service.files().delete(
373
+ fileId=file['id'],
374
+ supportsAllDrives=True
375
+ ).execute()
376
+
377
+ try:
378
+ retry_with_exponential_backoff(delete_file)
379
+ print(f" ✓ Deleted existing presentation: {file['name']} (ID: {file['id']})")
380
+ except HttpError as error:
381
+ if error.resp.status == 404:
382
+ try:
383
+ from .auth import get_service_account_email
384
+ service_account_email = get_service_account_email()
385
+ print(f" ⚠️ Error deleting presentation '{file['name']}': File not found or not accessible.")
386
+ print(f" Service account email: {service_account_email}")
387
+ print(f" Please ensure the file is shared with this service account with 'Editor' permissions.")
388
+ except Exception:
389
+ print(f" ⚠️ Error deleting presentation '{file['name']}': File not found or not accessible.")
390
+ print(f" Please ensure the file is shared with your service account with 'Editor' permissions.")
391
+ elif error.resp.status == 403:
392
+ try:
393
+ from .auth import get_service_account_email
394
+ service_account_email = get_service_account_email()
395
+ print(f" ⚠️ Error deleting presentation '{file['name']}': Permission denied.")
396
+ print(f" Service account email: {service_account_email}")
397
+ print(f" Please ensure the file is shared with this service account with 'Editor' permissions.")
398
+ except Exception:
399
+ print(f" ⚠️ Error deleting presentation '{file['name']}': Permission denied.")
400
+ print(f" Please ensure the file is shared with your service account with 'Editor' permissions.")
401
+ else:
402
+ print(f" ⚠️ Error deleting existing presentation {file['name']}: {error}")
403
+ return False
404
+ return True
405
+ else:
406
+ print(f" ℹ️ No existing presentation found for '{entity_name}'")
407
+ return False
408
+
409
+ except HttpError as error:
410
+ print(f" ⚠️ Error searching for existing presentation: {error}")
411
+ return False
412
+
413
+ def find_existing_presentation(entity_name, output_folder_id, creds):
414
+ """
415
+ Find an existing presentation for a entity in the output folder without deleting it.
416
+
417
+ Args:
418
+ entity_name: Name of the entity (e.g., "Madurai")
419
+ output_folder_id: ID of the folder to search for the presentation
420
+ creds: Service account credentials
421
+
422
+ Returns:
423
+ str: Presentation ID if found, None otherwise
424
+ """
425
+ drive_service = build('drive', 'v3', credentials=creds)
426
+
427
+ try:
428
+ # Search for existing presentation with the expected name
429
+ expected_filename = f"{entity_name}.gslides"
430
+ query = f"'{output_folder_id}' in parents and name='{expected_filename}' and mimeType='application/vnd.google-apps.presentation' and trashed=false"
431
+
432
+ def list_files():
433
+ return drive_service.files().list(
434
+ q=query,
435
+ fields='files(id, name)',
436
+ pageSize=10,
437
+ supportsAllDrives=True,
438
+ includeItemsFromAllDrives=True
439
+ ).execute()
440
+
441
+ results = retry_with_exponential_backoff(list_files)
442
+
443
+ files = results.get('files', [])
444
+ if files:
445
+ # Return the first matching file ID
446
+ file_id = files[0]['id']
447
+ # Verify file is accessible
448
+ try:
449
+ file_check = drive_service.files().get(
450
+ fileId=file_id,
451
+ fields='id, name',
452
+ supportsAllDrives=True
453
+ ).execute()
454
+ return file_id
455
+ except HttpError as check_error:
456
+ if check_error.resp.status == 404:
457
+ try:
458
+ from .auth import get_service_account_email
459
+ from .auth import get_service_account_email
460
+ service_account_email = get_service_account_email()
461
+ print(f" ⚠️ Presentation '{files[0]['name']}' not accessible to service account.")
462
+ print(f" Service account email: {service_account_email}")
463
+ print(f" Please ensure the file is shared with this service account with 'Editor' permissions.")
464
+ except Exception:
465
+ print(f" ⚠️ Presentation '{files[0]['name']}' not accessible to service account.")
466
+ print(f" Please ensure the file is shared with your service account with 'Editor' permissions.")
467
+ else:
468
+ print(f" ⚠️ Error checking presentation access: {check_error}")
469
+ return None
470
+ else:
471
+ return None
472
+
473
+ except HttpError as error:
474
+ print(f" ⚠️ Error searching for existing presentation: {error}")
475
+ return None
476
+
477
+ def replace_slides_from_template(presentation_id, template_id, slide_numbers, creds):
478
+ """
479
+ Replace specified slides in the presentation with slides from the template.
480
+ This deletes the target slides and recreates them by copying elements from template.
481
+
482
+ Args:
483
+ presentation_id: ID of the target presentation
484
+ template_id: ID of the template presentation
485
+ slide_numbers: Set of slide numbers (1-based) to replace
486
+ creds: Service account credentials
487
+
488
+ Returns:
489
+ bool: True if successful, False otherwise
490
+ """
491
+ import copy
492
+ import uuid
493
+
494
+ slides_service = build('slides', 'v1', credentials=creds)
495
+
496
+ try:
497
+ # Get template and target presentations
498
+ template_presentation = slides_service.presentations().get(presentationId=template_id).execute()
499
+ template_slides = template_presentation.get('slides', [])
500
+
501
+ target_presentation = slides_service.presentations().get(presentationId=presentation_id).execute()
502
+ target_slides = target_presentation.get('slides', [])
503
+
504
+ if not template_slides or not target_slides:
505
+ print(f" ⚠️ Template or target presentation has no slides.")
506
+ return False
507
+
508
+ max_slide = max(slide_numbers)
509
+ if len(template_slides) < max_slide or len(target_slides) < max_slide:
510
+ print(f" ⚠️ Template or target has fewer slides than requested.")
511
+ return False
512
+
513
+ # Tables cannot be safely recreated slide-by-slide because formatting would be lost.
514
+ # Check if any slides to be regenerated contain tables.
515
+ table_slides = []
516
+ for slide_number in slide_numbers:
517
+ slide_index = slide_number - 1
518
+ target_slide = target_slides[slide_index]
519
+ if any('table' in element for element in target_slide.get('pageElements', [])):
520
+ table_slides.append(slide_number)
521
+
522
+ # If any such slides, warn user and prompt for confirmation on first occurrence.
523
+ # On subsequent calls in the same session, reuse the stored decision (still warn but do not re-prompt).
524
+ if table_slides:
525
+ global _TABLE_SLIDE_PROCEED_DECISION
526
+ slide_list = ', '.join(str(s) for s in sorted(table_slides))
527
+ print(f"⚠️ Slide(s) {slide_list} contain table elements.")
528
+ print(" Per-slide regeneration is not supported for slides with tables, as tables cannot be recreated with proper formatting via the API.")
529
+ print(" You may lose table formatting or experience unexpected behavior if you choose to proceed.")
530
+ if _TABLE_SLIDE_PROCEED_DECISION is None:
531
+ proceed = None
532
+ while proceed not in ("y", "yes", "n", "no"):
533
+ proceed = input("Do you wish to continue anyway? (y/N): ").strip().lower() or "n"
534
+ _TABLE_SLIDE_PROCEED_DECISION = proceed in ("y", "yes")
535
+ print(" Your choice will be remembered for all future entities in this session.")
536
+ elif not _TABLE_SLIDE_PROCEED_DECISION:
537
+ print("✗ Cancelling processing as per stored user preference.")
538
+ return False
539
+ else:
540
+ print(" Proceeding automatically based on stored preference to continue despite tables.")
541
+
542
+ # Delete target slides first (in reverse order to maintain indices)
543
+ delete_requests = []
544
+ for slide_number in sorted(slide_numbers, reverse=True):
545
+ slide_index = slide_number - 1
546
+ target_slide_id = target_slides[slide_index].get('objectId')
547
+ if target_slide_id:
548
+ delete_requests.append({
549
+ 'deleteObject': {
550
+ 'objectId': target_slide_id
551
+ }
552
+ })
553
+
554
+ if delete_requests:
555
+ slides_service.presentations().batchUpdate(
556
+ presentationId=presentation_id,
557
+ body={'requests': delete_requests}
558
+ ).execute()
559
+
560
+ # Now create new slides and copy elements from template
561
+ for slide_number in sorted(slide_numbers):
562
+ slide_index = slide_number - 1
563
+ template_slide = template_slides[slide_index]
564
+
565
+ # Create new blank slide
566
+ create_result = slides_service.presentations().batchUpdate(
567
+ presentationId=presentation_id,
568
+ body={'requests': [{
569
+ 'createSlide': {
570
+ 'insertionIndex': slide_index,
571
+ 'slideLayoutReference': {
572
+ 'predefinedLayout': 'BLANK'
573
+ }
574
+ }
575
+ }]}
576
+ ).execute()
577
+
578
+ new_slide_id = create_result['replies'][0]['createSlide']['objectId']
579
+
580
+ # Copy page elements from template slide
581
+ template_elements = template_slide.get('pageElements', [])
582
+ if template_elements:
583
+ copy_requests = []
584
+ for element in template_elements:
585
+ if 'shape' in element:
586
+ shape = element.get('shape', {})
587
+ shape_type = shape.get('shapeType', 'TEXT_BOX')
588
+ transform = element.get('transform', {})
589
+ size = element.get('size', {})
590
+
591
+ new_element_id = str(uuid.uuid4()).replace('-', '')[:26]
592
+
593
+ # Get shape properties to preserve formatting - copy all writable properties
594
+ shape_properties = {}
595
+ if 'shapeProperties' in shape:
596
+ # Copy all shapeProperties from template, but filter to only writable top-level fields
597
+ all_props = copy.deepcopy(shape.get('shapeProperties', {}))
598
+ # List of writable top-level fields in shapeProperties (excluding read-only and nested fields)
599
+ # Note: solidFill, gradientFill, etc. are nested under shapeBackgroundFill, not top-level
600
+ writable_top_level_fields = [
601
+ 'outline', 'shadow', 'link', 'contentAlignment',
602
+ 'shapeBackgroundFill' # This contains nested fill properties
603
+ ]
604
+ # Only include writable top-level fields
605
+ for field in writable_top_level_fields:
606
+ if field in all_props:
607
+ shape_properties[field] = all_props[field]
608
+
609
+ # contentAlignment can be directly on the shape object (not in shapeProperties)
610
+ # When reading from template, it might be on the shape itself
611
+ # We need to include it in shapeProperties for the update request
612
+ if 'contentAlignment' in shape and 'contentAlignment' not in shape_properties:
613
+ shape_properties['contentAlignment'] = shape.get('contentAlignment')
614
+
615
+ # Also check for contentAlignment at element level (if not already found)
616
+ if 'contentAlignment' in element and 'contentAlignment' not in shape_properties:
617
+ shape_properties['contentAlignment'] = element.get('contentAlignment')
618
+
619
+ create_shape_request = {
620
+ 'createShape': {
621
+ 'objectId': new_element_id,
622
+ 'shapeType': shape_type,
623
+ 'elementProperties': {
624
+ 'pageObjectId': new_slide_id,
625
+ 'size': size,
626
+ 'transform': transform
627
+ }
628
+ }
629
+ }
630
+
631
+ copy_requests.append(create_shape_request)
632
+
633
+ # Update shape properties to preserve formatting
634
+ if shape_properties:
635
+ copy_requests.append({
636
+ 'updateShapeProperties': {
637
+ 'objectId': new_element_id,
638
+ 'shapeProperties': shape_properties,
639
+ 'fields': ','.join(shape_properties.keys())
640
+ }
641
+ })
642
+
643
+ # Copy text content with formatting if present
644
+ if 'text' in shape:
645
+ text_obj = shape.get('text', {})
646
+ text_elements = text_obj.get('textElements', [])
647
+
648
+ # Get default paragraph style if present
649
+ default_paragraph_style = None
650
+ if 'paragraphStyle' in text_obj:
651
+ default_paragraph_style = text_obj['paragraphStyle']
652
+
653
+ # Whitelist of writable text style fields
654
+ writable_text_style_fields = [
655
+ 'bold', 'italic', 'underline', 'strikethrough',
656
+ 'fontFamily', 'fontSize', 'foregroundColor', 'backgroundColor',
657
+ 'weightedFontFamily' # Font weight (object with fontFamily and weight)
658
+ ]
659
+
660
+ # Helper function to filter text style to only writable fields
661
+ def filter_text_style(text_style):
662
+ """Filter text style to only include writable fields."""
663
+ if not text_style:
664
+ return {}
665
+ filtered = {}
666
+ for field in writable_text_style_fields:
667
+ if field in text_style:
668
+ filtered[field] = text_style[field]
669
+ return filtered
670
+
671
+ # Whitelist of writable paragraph style fields
672
+ writable_paragraph_fields = [
673
+ 'alignment', 'direction', 'spacingMode', 'spaceAbove', 'spaceBelow',
674
+ 'lineSpacing', 'indentFirstLine', 'indentStart', 'indentEnd'
675
+ ]
676
+
677
+ # Helper function to filter paragraph style to only writable fields
678
+ def filter_paragraph_style(para_style):
679
+ """Filter paragraph style to only include writable fields."""
680
+ if not para_style:
681
+ return {}
682
+ filtered = {}
683
+ for field in writable_paragraph_fields:
684
+ if field in para_style:
685
+ filtered[field] = para_style[field]
686
+ return filtered
687
+
688
+ # Phase 1: Collect textRuns and paragraphStyles into in-memory structures
689
+ collected_text_runs = [] # Array of {startIndex, endIndex, content, style}
690
+ collected_paragraph_markers = [] # Array of {endIndex, style} - will be converted to paragraphStyles later
691
+
692
+ for te in text_elements:
693
+ if 'textRun' in te:
694
+ text_run = te['textRun']
695
+ content = text_run.get('content', '')
696
+ style = text_run.get('style', {})
697
+
698
+ # Get startIndex and endIndex from the element
699
+ start_index = te.get('startIndex', 0)
700
+ end_index = te.get('endIndex', start_index + len(content) if content else start_index)
701
+
702
+ if content:
703
+ collected_text_runs.append({
704
+ 'startIndex': start_index,
705
+ 'endIndex': end_index,
706
+ 'content': content,
707
+ 'style': style
708
+ })
709
+
710
+ elif 'paragraphMarker' in te:
711
+ para_marker = te['paragraphMarker']
712
+ para_style = para_marker.get('style', {}) if 'style' in para_marker else None
713
+
714
+ # Use paragraph-specific style if available, otherwise use default
715
+ paragraph_style = para_style if para_style else default_paragraph_style
716
+
717
+ if paragraph_style:
718
+ # Get endIndex from the element (marks end of paragraph)
719
+ end_index = te.get('endIndex', 0)
720
+
721
+ collected_paragraph_markers.append({
722
+ 'endIndex': end_index,
723
+ 'style': paragraph_style
724
+ })
725
+
726
+ # Convert paragraphMarkers to paragraphStyles with startIndex/endIndex
727
+ # Sort by endIndex to process in order
728
+ collected_paragraph_markers.sort(key=lambda x: x['endIndex'])
729
+ collected_paragraph_styles = []
730
+
731
+ for i, para_marker in enumerate(collected_paragraph_markers):
732
+ # Determine startIndex: previous paragraph's endIndex, or 0 for first paragraph
733
+ start_index = 0
734
+ if i > 0:
735
+ start_index = collected_paragraph_styles[i - 1]['endIndex']
736
+
737
+ collected_paragraph_styles.append({
738
+ 'startIndex': start_index,
739
+ 'endIndex': para_marker['endIndex'],
740
+ 'style': para_marker['style']
741
+ })
742
+
743
+ # Handle last paragraph if it doesn't have a paragraph marker
744
+ # Find the maximum endIndex from textRuns to determine if there's unhandled text
745
+ max_text_index = 0
746
+ if collected_text_runs:
747
+ max_text_index = max(tr['endIndex'] for tr in collected_text_runs)
748
+
749
+ # Check if there's text after the last paragraph marker
750
+ last_para_end = collected_paragraph_styles[-1]['endIndex'] if collected_paragraph_styles else 0
751
+ if max_text_index > last_para_end and default_paragraph_style:
752
+ collected_paragraph_styles.append({
753
+ 'startIndex': last_para_end,
754
+ 'endIndex': max_text_index,
755
+ 'style': default_paragraph_style
756
+ })
757
+
758
+ # Phase 2: Generate API requests based on collected data
759
+
760
+ # Sort textRuns by startIndex to build text in correct order
761
+ collected_text_runs.sort(key=lambda x: x['startIndex'])
762
+
763
+ # Build full text content by concatenating textRuns
764
+ full_text_content = ''.join(tr['content'] for tr in collected_text_runs)
765
+
766
+ # Insert text if there's any content
767
+ if full_text_content:
768
+ copy_requests.append({
769
+ 'insertText': {
770
+ 'objectId': new_element_id,
771
+ 'insertionIndex': 0,
772
+ 'text': full_text_content
773
+ }
774
+ })
775
+
776
+ # Apply text styles for each textRun using original indices
777
+ for text_run in collected_text_runs:
778
+ style = text_run.get('style', {})
779
+ if style:
780
+ style_update = filter_text_style(style)
781
+ if style_update:
782
+ copy_requests.append({
783
+ 'updateTextStyle': {
784
+ 'objectId': new_element_id,
785
+ 'textRange': {
786
+ 'type': 'FIXED_RANGE',
787
+ 'startIndex': text_run['startIndex'],
788
+ 'endIndex': text_run['endIndex']
789
+ },
790
+ 'style': style_update,
791
+ 'fields': ','.join(style_update.keys())
792
+ }
793
+ })
794
+
795
+ # Apply paragraph styles for each paragraph
796
+ for para_info in collected_paragraph_styles:
797
+ para_style = para_info['style']
798
+ para_start = para_info['startIndex']
799
+ para_end = para_info['endIndex']
800
+
801
+ if para_end > para_start: # Only apply if paragraph has content
802
+ para_style_update = filter_paragraph_style(para_style)
803
+ if para_style_update:
804
+ copy_requests.append({
805
+ 'updateParagraphStyle': {
806
+ 'objectId': new_element_id,
807
+ 'textRange': {
808
+ 'type': 'FIXED_RANGE',
809
+ 'startIndex': para_start,
810
+ 'endIndex': para_end
811
+ },
812
+ 'style': para_style_update,
813
+ 'fields': ','.join(para_style_update.keys())
814
+ }
815
+ })
816
+
817
+ # If no paragraph markers found, apply default paragraph style to entire text
818
+ if not collected_paragraph_styles and default_paragraph_style and full_text_content:
819
+ para_style_update = filter_paragraph_style(default_paragraph_style)
820
+ if para_style_update:
821
+ copy_requests.append({
822
+ 'updateParagraphStyle': {
823
+ 'objectId': new_element_id,
824
+ 'textRange': {
825
+ 'type': 'ALL'
826
+ },
827
+ 'style': para_style_update,
828
+ 'fields': ','.join(para_style_update.keys())
829
+ }
830
+ })
831
+
832
+ # Remove trailing newline if present (extra newline issue)
833
+ if full_text_content and full_text_content.endswith('\n'):
834
+ # Calculate the index of the trailing newline
835
+ text_length = len(full_text_content)
836
+ copy_requests.append({
837
+ 'deleteText': {
838
+ 'objectId': new_element_id,
839
+ 'textRange': {
840
+ 'type': 'FIXED_RANGE',
841
+ 'startIndex': text_length - 1,
842
+ 'endIndex': text_length
843
+ }
844
+ }
845
+ })
846
+
847
+ elif 'table' in element:
848
+ table = element.get('table', {})
849
+ transform = element.get('transform', {})
850
+ size = element.get('size', {})
851
+
852
+ table_rows = table.get('tableRows', [])
853
+ table_columns = table.get('tableColumns', [])
854
+
855
+ row_count = len(table_rows)
856
+ column_count = len(table_columns)
857
+
858
+ if row_count == 0 or column_count == 0:
859
+ print(f" ⚠️ Warning: Table element missing rows or columns, skipping")
860
+ continue
861
+
862
+ new_table_id = str(uuid.uuid4()).replace('-', '')[:26]
863
+
864
+ # Create table with the same dimensions and positioning
865
+ copy_requests.append({
866
+ 'createTable': {
867
+ 'objectId': new_table_id,
868
+ 'elementProperties': {
869
+ 'pageObjectId': new_slide_id,
870
+ 'size': size,
871
+ 'transform': transform
872
+ },
873
+ 'rows': row_count,
874
+ 'columns': column_count
875
+ }
876
+ })
877
+
878
+ # Copy column widths if present
879
+ for col_idx, column in enumerate(table_columns):
880
+ col_props = column.get('tableColumnProperties', {})
881
+ if col_props:
882
+ filtered_col_props = {}
883
+ if 'columnWidth' in col_props:
884
+ filtered_col_props['columnWidth'] = col_props['columnWidth']
885
+
886
+ if filtered_col_props:
887
+ copy_requests.append({
888
+ 'updateTableColumnProperties': {
889
+ 'objectId': new_table_id,
890
+ 'columnIndices': [col_idx],
891
+ 'tableColumnProperties': filtered_col_props,
892
+ 'fields': ','.join(filtered_col_props.keys())
893
+ }
894
+ })
895
+
896
+ # Helper to filter writable cell properties
897
+ def filter_table_cell_properties(cell_props):
898
+ if not cell_props:
899
+ return {}
900
+
901
+ writable_fields = [
902
+ 'tableCellBackgroundFill',
903
+ 'contentAlignment',
904
+ 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
905
+ 'tableCellBorderBottom', 'tableCellBorderTop',
906
+ 'tableCellBorderLeft', 'tableCellBorderRight'
907
+ ]
908
+
909
+ filtered = {}
910
+ for field in writable_fields:
911
+ if field in cell_props:
912
+ filtered[field] = cell_props[field]
913
+ return filtered
914
+
915
+ # Reuse text/paragraph style filters for table text
916
+ writable_text_style_fields = [
917
+ 'bold', 'italic', 'underline', 'strikethrough',
918
+ 'fontFamily', 'fontSize', 'foregroundColor', 'backgroundColor',
919
+ 'weightedFontFamily'
920
+ ]
921
+
922
+ def filter_text_style(text_style):
923
+ if not text_style:
924
+ return {}
925
+ filtered = {}
926
+ for field in writable_text_style_fields:
927
+ if field in text_style:
928
+ filtered[field] = text_style[field]
929
+ return filtered
930
+
931
+ writable_paragraph_fields = [
932
+ 'alignment', 'direction', 'spacingMode', 'spaceAbove', 'spaceBelow',
933
+ 'lineSpacing', 'indentFirstLine', 'indentStart', 'indentEnd'
934
+ ]
935
+
936
+ def filter_paragraph_style(para_style):
937
+ if not para_style:
938
+ return {}
939
+ filtered = {}
940
+ for field in writable_paragraph_fields:
941
+ if field in para_style:
942
+ filtered[field] = para_style[field]
943
+ return filtered
944
+
945
+ # Copy row heights and cell content/properties
946
+ for row_idx, row in enumerate(table_rows):
947
+ # Row height if available (minRowHeight is the writable field)
948
+ row_props = row.get('tableRowProperties', {})
949
+ row_height = row_props.get('minRowHeight', row.get('rowHeight'))
950
+ if row_height:
951
+ copy_requests.append({
952
+ 'updateTableRowProperties': {
953
+ 'objectId': new_table_id,
954
+ 'rowIndices': [row_idx],
955
+ 'tableRowProperties': {
956
+ 'minRowHeight': row_height
957
+ },
958
+ 'fields': 'minRowHeight'
959
+ }
960
+ })
961
+
962
+ table_cells = row.get('tableCells', [])
963
+ for col_idx, cell in enumerate(table_cells):
964
+ cell_location = {
965
+ 'rowIndex': row_idx,
966
+ 'columnIndex': col_idx
967
+ }
968
+
969
+ # Copy cell properties
970
+ cell_props = cell.get('tableCellProperties', {})
971
+ filtered_cell_props = filter_table_cell_properties(cell_props)
972
+ if filtered_cell_props:
973
+ copy_requests.append({
974
+ 'updateTableCellProperties': {
975
+ 'objectId': new_table_id,
976
+ 'tableRange': {
977
+ 'location': cell_location,
978
+ 'rowSpan': 1,
979
+ 'columnSpan': 1
980
+ },
981
+ 'tableCellProperties': filtered_cell_props,
982
+ 'fields': ','.join(filtered_cell_props.keys())
983
+ }
984
+ })
985
+
986
+ # Copy text content and formatting inside the cell
987
+ cell_text = cell.get('text', {})
988
+ text_elements = cell_text.get('textElements', [])
989
+ default_paragraph_style = cell_text.get('paragraphStyle')
990
+
991
+ collected_text_runs = []
992
+ collected_paragraph_markers = []
993
+
994
+ for te in text_elements:
995
+ if 'textRun' in te:
996
+ text_run = te['textRun']
997
+ content = text_run.get('content', '')
998
+ style = text_run.get('style', {})
999
+ start_index = te.get('startIndex', 0)
1000
+ end_index = te.get('endIndex', start_index + len(content) if content else start_index)
1001
+
1002
+ if content:
1003
+ collected_text_runs.append({
1004
+ 'startIndex': start_index,
1005
+ 'endIndex': end_index,
1006
+ 'content': content,
1007
+ 'style': style
1008
+ })
1009
+
1010
+ elif 'paragraphMarker' in te:
1011
+ para_marker = te['paragraphMarker']
1012
+ para_style = para_marker.get('style', {}) if 'style' in para_marker else None
1013
+ paragraph_style = para_style if para_style else default_paragraph_style
1014
+
1015
+ if paragraph_style:
1016
+ end_index = te.get('endIndex', 0)
1017
+ collected_paragraph_markers.append({
1018
+ 'endIndex': end_index,
1019
+ 'style': paragraph_style
1020
+ })
1021
+
1022
+ collected_paragraph_markers.sort(key=lambda x: x['endIndex'])
1023
+ collected_paragraph_styles = []
1024
+
1025
+ for i, para_marker in enumerate(collected_paragraph_markers):
1026
+ start_index = 0
1027
+ if i > 0:
1028
+ start_index = collected_paragraph_styles[i - 1]['endIndex']
1029
+
1030
+ collected_paragraph_styles.append({
1031
+ 'startIndex': start_index,
1032
+ 'endIndex': para_marker['endIndex'],
1033
+ 'style': para_marker['style']
1034
+ })
1035
+
1036
+ max_text_index = 0
1037
+ if collected_text_runs:
1038
+ max_text_index = max(tr['endIndex'] for tr in collected_text_runs)
1039
+
1040
+ last_para_end = collected_paragraph_styles[-1]['endIndex'] if collected_paragraph_styles else 0
1041
+ if max_text_index > last_para_end and default_paragraph_style:
1042
+ collected_paragraph_styles.append({
1043
+ 'startIndex': last_para_end,
1044
+ 'endIndex': max_text_index,
1045
+ 'style': default_paragraph_style
1046
+ })
1047
+
1048
+ collected_text_runs.sort(key=lambda x: x['startIndex'])
1049
+ full_text_content = ''.join(tr['content'] for tr in collected_text_runs)
1050
+
1051
+ if full_text_content:
1052
+ copy_requests.append({
1053
+ 'insertText': {
1054
+ 'objectId': new_table_id,
1055
+ 'cellLocation': cell_location,
1056
+ 'insertionIndex': 0,
1057
+ 'text': full_text_content
1058
+ }
1059
+ })
1060
+
1061
+ for text_run in collected_text_runs:
1062
+ style = text_run.get('style', {})
1063
+ style_update = filter_text_style(style)
1064
+ if style_update:
1065
+ copy_requests.append({
1066
+ 'updateTextStyle': {
1067
+ 'objectId': new_table_id,
1068
+ 'cellLocation': cell_location,
1069
+ 'textRange': {
1070
+ 'type': 'FIXED_RANGE',
1071
+ 'startIndex': text_run['startIndex'],
1072
+ 'endIndex': text_run['endIndex']
1073
+ },
1074
+ 'style': style_update,
1075
+ 'fields': ','.join(style_update.keys())
1076
+ }
1077
+ })
1078
+
1079
+ for para_info in collected_paragraph_styles:
1080
+ para_style = para_info['style']
1081
+ para_start = para_info['startIndex']
1082
+ para_end = para_info['endIndex']
1083
+
1084
+ if para_end > para_start:
1085
+ para_style_update = filter_paragraph_style(para_style)
1086
+ if para_style_update:
1087
+ copy_requests.append({
1088
+ 'updateParagraphStyle': {
1089
+ 'objectId': new_table_id,
1090
+ 'cellLocation': cell_location,
1091
+ 'textRange': {
1092
+ 'type': 'FIXED_RANGE',
1093
+ 'startIndex': para_start,
1094
+ 'endIndex': para_end
1095
+ },
1096
+ 'style': para_style_update,
1097
+ 'fields': ','.join(para_style_update.keys())
1098
+ }
1099
+ })
1100
+
1101
+ if not collected_paragraph_styles and default_paragraph_style and full_text_content:
1102
+ para_style_update = filter_paragraph_style(default_paragraph_style)
1103
+ if para_style_update:
1104
+ copy_requests.append({
1105
+ 'updateParagraphStyle': {
1106
+ 'objectId': new_table_id,
1107
+ 'cellLocation': cell_location,
1108
+ 'textRange': {
1109
+ 'type': 'ALL'
1110
+ },
1111
+ 'style': para_style_update,
1112
+ 'fields': ','.join(para_style_update.keys())
1113
+ }
1114
+ })
1115
+
1116
+ if full_text_content and full_text_content.endswith('\n'):
1117
+ text_length = len(full_text_content)
1118
+ copy_requests.append({
1119
+ 'deleteText': {
1120
+ 'objectId': new_table_id,
1121
+ 'cellLocation': cell_location,
1122
+ 'textRange': {
1123
+ 'type': 'FIXED_RANGE',
1124
+ 'startIndex': text_length - 1,
1125
+ 'endIndex': text_length
1126
+ }
1127
+ }
1128
+ })
1129
+
1130
+ elif 'image' in element:
1131
+ # Handle image elements - copy them from template
1132
+ image = element.get('image', {})
1133
+ transform = element.get('transform', {})
1134
+ size = element.get('size', {})
1135
+
1136
+ # Get image URL from the template element
1137
+ # Image can have sourceUrl or contentUrl
1138
+ image_url = None
1139
+ if 'sourceUrl' in image:
1140
+ image_url = image['sourceUrl']
1141
+ elif 'contentUrl' in image:
1142
+ image_url = image['contentUrl']
1143
+
1144
+ if image_url:
1145
+ create_image_request = {
1146
+ 'createImage': {
1147
+ 'url': image_url,
1148
+ 'elementProperties': {
1149
+ 'pageObjectId': new_slide_id,
1150
+ 'size': size,
1151
+ 'transform': transform
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ copy_requests.append(create_image_request)
1157
+ else:
1158
+ print(f" ⚠️ Warning: Image element found but no URL available, skipping")
1159
+
1160
+ # Execute copy requests in batches
1161
+ if copy_requests:
1162
+ batch_size = 50
1163
+ for i in range(0, len(copy_requests), batch_size):
1164
+ batch = copy_requests[i:i+batch_size]
1165
+ slides_service.presentations().batchUpdate(
1166
+ presentationId=presentation_id,
1167
+ body={'requests': batch}
1168
+ ).execute()
1169
+
1170
+ print(f" ✓ Replaced {len(slide_numbers)} slide(s) from template")
1171
+ return True
1172
+
1173
+ except Exception as error:
1174
+ print(f" ✗ Error replacing slides from template: {error}")
1175
+ import traceback
1176
+ traceback.print_exc()
1177
+ return False
1178
+
1179
+ def copy_template_presentation(spreadsheet_name, template_id, output_folder_id, creds):
1180
+ """
1181
+ Copy the template presentation, rename it, and move it to the output folder.
1182
+
1183
+ Args:
1184
+ spreadsheet_name: Name to use for the new presentation (e.g., "Madurai")
1185
+ template_id: ID of the template presentation
1186
+ output_folder_id: ID of the folder to save the presentation
1187
+ creds: Service account credentials
1188
+
1189
+ Returns:
1190
+ str: ID of the copied presentation
1191
+ """
1192
+ drive_service = build('drive', 'v3', credentials=creds)
1193
+
1194
+ # Copy the template
1195
+ print(f"Copying template presentation...")
1196
+ copied_file = drive_service.files().copy(
1197
+ fileId=template_id,
1198
+ body={'name': f"{spreadsheet_name}.gslides"},
1199
+ supportsAllDrives=True
1200
+ ).execute()
1201
+
1202
+ new_presentation_id = copied_file.get('id')
1203
+ print(f"Created presentation: {spreadsheet_name}.gslides (ID: {new_presentation_id})")
1204
+
1205
+ # Move to output folder
1206
+ print(f"Moving presentation to output folder...")
1207
+ file_metadata = drive_service.files().get(fileId=new_presentation_id, fields='parents', supportsAllDrives=True).execute()
1208
+ previous_parents = ",".join(file_metadata.get('parents', []))
1209
+
1210
+ if previous_parents:
1211
+ drive_service.files().update(
1212
+ fileId=new_presentation_id,
1213
+ addParents=output_folder_id,
1214
+ removeParents=previous_parents,
1215
+ fields='id, parents',
1216
+ supportsAllDrives=True
1217
+ ).execute()
1218
+ else:
1219
+ # If no previous parents, just add to the folder
1220
+ drive_service.files().update(
1221
+ fileId=new_presentation_id,
1222
+ addParents=output_folder_id,
1223
+ fields='id, parents',
1224
+ supportsAllDrives=True
1225
+ ).execute()
1226
+
1227
+ return new_presentation_id
1228
+
1229
+ def get_chart_id_from_sheet(spreadsheet_id, sheet_name, creds):
1230
+ """
1231
+ Get the first chart ID from a sheet.
1232
+
1233
+ Args:
1234
+ spreadsheet_id: ID of the spreadsheet
1235
+ sheet_name: Name of the sheet containing the chart
1236
+ creds: Service account credentials
1237
+
1238
+ Returns:
1239
+ int: Chart ID, or None if not found
1240
+ """
1241
+ sheets_service = build('sheets', 'v4', credentials=creds)
1242
+
1243
+ try:
1244
+ # Get the spreadsheet to find charts
1245
+ spreadsheet = sheets_service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
1246
+
1247
+ # Find the sheet and get its charts
1248
+ for sheet in spreadsheet.get('sheets', []):
1249
+ if sheet['properties']['title'] == sheet_name:
1250
+ # Charts are stored in the 'charts' property of the sheet
1251
+ charts = sheet.get('charts', [])
1252
+ if charts:
1253
+ # Return the first chart's ID
1254
+ return charts[0].get('chartId')
1255
+ break
1256
+
1257
+ return None
1258
+
1259
+ except HttpError as error:
1260
+ print(f"Error getting chart from sheet '{sheet_name}': {error}")
1261
+ return None
1262
+
1263
+ def get_image_file_from_folder(entity_folder_id, picture_name, creds):
1264
+ """
1265
+ Get the image file ID from the entity folder by matching the expected filename pattern.
1266
+ Images are named like: picture-<picture_name>.<extension>
1267
+
1268
+ Args:
1269
+ entity_folder_id: ID of the entity folder containing the image files
1270
+ picture_name: Name of the picture placeholder (e.g., "block_wise_school_performance_count")
1271
+ creds: Service account credentials
1272
+
1273
+ Returns:
1274
+ str: Image file ID that can be used to get public URL, or None if not found
1275
+ """
1276
+ drive_service = build('drive', 'v3', credentials=creds)
1277
+
1278
+ try:
1279
+ # Construct expected filename: picture-<picture_name>
1280
+ expected_filename_base = f"picture-{picture_name}"
1281
+
1282
+ # Try different image extensions
1283
+ image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg']
1284
+
1285
+ image_mime_types = [
1286
+ 'image/png',
1287
+ 'image/jpeg',
1288
+ 'image/jpg',
1289
+ 'image/gif',
1290
+ 'image/bmp',
1291
+ 'image/webp',
1292
+ 'image/svg+xml'
1293
+ ]
1294
+
1295
+ # Build query to search for image files matching the expected filename pattern
1296
+ mime_query = " or ".join([f"mimeType='{mime}'" for mime in image_mime_types])
1297
+
1298
+ # Search for files in the entity folder that match the expected filename
1299
+ # The file name should be: expected_filename_base + extension
1300
+ for ext in image_extensions:
1301
+ image_filename = expected_filename_base + ext
1302
+ query = f"'{entity_folder_id}' in parents and name='{image_filename}' and trashed=false and ({mime_query})"
1303
+
1304
+ try:
1305
+ results = drive_service.files().list(
1306
+ q=query,
1307
+ fields='files(id, name, mimeType)',
1308
+ pageSize=10,
1309
+ supportsAllDrives=True,
1310
+ includeItemsFromAllDrives=True
1311
+ ).execute()
1312
+
1313
+ files = results.get('files', [])
1314
+ if files:
1315
+ # Return the file ID - we'll grant public access in replace_textbox_with_image
1316
+ return files[0]['id']
1317
+ except HttpError as e:
1318
+ # Continue to next extension if this one fails
1319
+ continue
1320
+
1321
+ # If exact match not found, try a more flexible search
1322
+ # Look for files that start with the expected filename base
1323
+ query = f"'{entity_folder_id}' in parents and name contains '{expected_filename_base}' and trashed=false and ({mime_query})"
1324
+
1325
+ try:
1326
+ results = drive_service.files().list(
1327
+ q=query,
1328
+ fields='files(id, name, mimeType)',
1329
+ pageSize=10,
1330
+ supportsAllDrives=True,
1331
+ includeItemsFromAllDrives=True
1332
+ ).execute()
1333
+
1334
+ files = results.get('files', [])
1335
+ if files:
1336
+ # Return the file ID - we'll grant public access in replace_textbox_with_image
1337
+ return files[0]['id']
1338
+ except HttpError:
1339
+ pass
1340
+
1341
+ # If no image found, return None
1342
+ print(f" ⚠️ No image file found matching 'picture-{picture_name}' in entity folder")
1343
+ return None
1344
+
1345
+ except HttpError as error:
1346
+ print(f"Error getting image file for 'picture-{picture_name}': {error}")
1347
+ return None
1348
+ except Exception as error:
1349
+ print(f"Error getting image file for 'picture-{picture_name}': {error}")
1350
+ return None
1351
+
1352
+ def replace_textbox_with_chart(presentation_id, slide_id, slide_number, textbox_element, spreadsheet_id, sheet_name, creds):
1353
+ """
1354
+ Replace a textbox element with a linked chart from a sheet.
1355
+ Maintains the z-order position of the original textbox.
1356
+
1357
+ Args:
1358
+ presentation_id: ID of the presentation
1359
+ slide_id: ID of the slide
1360
+ slide_number: Slide number (1-based)
1361
+ textbox_element: The textbox page element to replace
1362
+ spreadsheet_id: ID of the spreadsheet containing the chart
1363
+ sheet_name: Name of the sheet containing the chart
1364
+ creds: Service account credentials
1365
+
1366
+ Returns:
1367
+ bool: True if successful, False otherwise
1368
+ """
1369
+ slides_service = build('slides', 'v1', credentials=creds)
1370
+ sheets_service = build('sheets', 'v4', credentials=creds)
1371
+
1372
+ # Get the slide to find the z-order index of the textbox
1373
+ presentation = slides_service.presentations().get(presentationId=presentation_id).execute()
1374
+ presentation_slides = presentation.get('slides', [])
1375
+
1376
+ # Find the slide and get its pageElements
1377
+ slide = None
1378
+ for s in presentation_slides:
1379
+ if s.get('objectId') == slide_id:
1380
+ slide = s
1381
+ break
1382
+
1383
+ if not slide:
1384
+ print(f"Error: Slide {slide_id} not found")
1385
+ return False
1386
+
1387
+ # Find the z-order index of the textbox element
1388
+ textbox_object_id = textbox_element.get('objectId')
1389
+ page_elements = slide.get('pageElements', [])
1390
+ z_order_index = None
1391
+
1392
+ for idx, element in enumerate(page_elements):
1393
+ if element.get('objectId') == textbox_object_id:
1394
+ z_order_index = idx
1395
+ break
1396
+
1397
+ if z_order_index is None:
1398
+ print(f"Warning: Could not find textbox element in slide {slide_number}, z-order may not be preserved")
1399
+ # Continue anyway, but z-order won't be preserved
1400
+
1401
+ # Get position and size from textbox
1402
+ transform = textbox_element.get('transform', {})
1403
+
1404
+ # Extract translate values (position)
1405
+ translate_x = transform.get('translateX', 0)
1406
+ translate_y = transform.get('translateY', 0)
1407
+
1408
+ # Handle both numeric and object formats
1409
+ if isinstance(translate_x, dict):
1410
+ translate_x = translate_x.get('magnitude', 0)
1411
+ if isinstance(translate_y, dict):
1412
+ translate_y = translate_y.get('magnitude', 0)
1413
+
1414
+ # Extract scale factors
1415
+ scale_x = transform.get('scaleX', 1)
1416
+ scale_y = transform.get('scaleY', 1)
1417
+
1418
+ # Handle both numeric and object formats for scale
1419
+ if isinstance(scale_x, dict):
1420
+ scale_x = scale_x.get('magnitude', 1)
1421
+ if isinstance(scale_y, dict):
1422
+ scale_y = scale_y.get('magnitude', 1)
1423
+
1424
+ # Get base size from size field
1425
+ size = textbox_element.get('size', {})
1426
+ width_obj = size.get('width', {})
1427
+ height_obj = size.get('height', {})
1428
+
1429
+ base_width = width_obj.get('magnitude', 4000000) if isinstance(width_obj, dict) else (width_obj if width_obj else 4000000)
1430
+ base_height = height_obj.get('magnitude', 3000000) if isinstance(height_obj, dict) else (height_obj if height_obj else 3000000)
1431
+
1432
+ # Calculate actual rendered size (base size * scale)
1433
+ actual_width = base_width * scale_x
1434
+ actual_height = base_height * scale_y
1435
+
1436
+ # Get the sheet ID for the chart
1437
+ spreadsheet = sheets_service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
1438
+ sheet_id = None
1439
+
1440
+ for sheet in spreadsheet.get('sheets', []):
1441
+ if sheet['properties']['title'] == sheet_name:
1442
+ sheet_id = sheet['properties']['sheetId']
1443
+ break
1444
+
1445
+ if sheet_id is None:
1446
+ print(f"Warning: Sheet '{sheet_name}' not found in spreadsheet")
1447
+ return False
1448
+
1449
+ # Find existing chart in the sheet
1450
+ chart_id = get_chart_id_from_sheet(spreadsheet_id, sheet_name, creds)
1451
+
1452
+ if chart_id is None:
1453
+ print(f"Error: No chart found in sheet '{sheet_name}'. Chart must exist in the sheet.")
1454
+ return False
1455
+
1456
+ # Prepare requests to delete textbox and insert chart
1457
+ requests = [
1458
+ {
1459
+ 'deleteObject': {
1460
+ 'objectId': textbox_element.get('objectId')
1461
+ }
1462
+ },
1463
+ {
1464
+ 'createSheetsChart': {
1465
+ 'spreadsheetId': spreadsheet_id,
1466
+ 'chartId': chart_id,
1467
+ 'linkingMode': 'LINKED',
1468
+ 'elementProperties': {
1469
+ 'pageObjectId': slide_id,
1470
+ 'size': {
1471
+ 'height': {'magnitude': actual_height, 'unit': 'EMU'},
1472
+ 'width': {'magnitude': actual_width, 'unit': 'EMU'}
1473
+ },
1474
+ 'transform': {
1475
+ 'scaleX': 1,
1476
+ 'scaleY': 1,
1477
+ 'translateX': translate_x,
1478
+ 'translateY': translate_y,
1479
+ 'unit': 'EMU'
1480
+ }
1481
+ }
1482
+ }
1483
+ }
1484
+ ]
1485
+
1486
+ # Execute the batch update with retry logic
1487
+ body = {'requests': requests}
1488
+
1489
+ def execute_batch_update():
1490
+ return slides_service.presentations().batchUpdate(
1491
+ presentationId=presentation_id,
1492
+ body=body
1493
+ ).execute()
1494
+
1495
+ try:
1496
+ response = retry_with_exponential_backoff(execute_batch_update)
1497
+
1498
+ # Get the objectId of the newly created chart and restore z-order
1499
+ if z_order_index is not None:
1500
+ # Extract the objectId from the response
1501
+ replies = response.get('replies', [])
1502
+ new_chart_object_id = None
1503
+
1504
+ for reply in replies:
1505
+ if 'createSheetsChart' in reply:
1506
+ new_chart_object_id = reply['createSheetsChart'].get('objectId')
1507
+ break
1508
+
1509
+ if new_chart_object_id:
1510
+ # After deletion, the z-order indices shift, so we need to adjust
1511
+ # Since we deleted the element at z_order_index, the new element is at the end
1512
+ # We need to move it back to z_order_index
1513
+ # Get current slide state to find the correct new index
1514
+ updated_presentation = slides_service.presentations().get(presentationId=presentation_id).execute()
1515
+ updated_slides = updated_presentation.get('slides', [])
1516
+
1517
+ for s in updated_slides:
1518
+ if s.get('objectId') == slide_id:
1519
+ updated_page_elements = s.get('pageElements', [])
1520
+ # Find the current index of the new chart
1521
+ current_index = None
1522
+ for idx, element in enumerate(updated_page_elements):
1523
+ if element.get('objectId') == new_chart_object_id:
1524
+ current_index = idx
1525
+ break
1526
+
1527
+ # If found and it's not already at the correct position, move it
1528
+ if current_index is not None and current_index != z_order_index:
1529
+ # Calculate how many positions to move
1530
+ # After deletion, elements after the deleted one shift down by 1
1531
+ # The new element is at the end, we need to move it to z_order_index
1532
+ positions_to_move = current_index - z_order_index
1533
+
1534
+ if positions_to_move > 0:
1535
+ # Need to move backward (toward front of array = lower z-order)
1536
+ # Use SEND_BACKWARD the required number of times
1537
+ order_requests = []
1538
+ for _ in range(positions_to_move):
1539
+ order_requests.append({
1540
+ 'updatePageElementsZOrder': {
1541
+ 'pageElementObjectIds': [new_chart_object_id],
1542
+ 'operation': 'SEND_BACKWARD'
1543
+ }
1544
+ })
1545
+
1546
+ def execute_order_update():
1547
+ return slides_service.presentations().batchUpdate(
1548
+ presentationId=presentation_id,
1549
+ body={'requests': order_requests}
1550
+ ).execute()
1551
+
1552
+ try:
1553
+ retry_with_exponential_backoff(execute_order_update)
1554
+ except HttpError as order_error:
1555
+ print(f" ⚠️ Warning: Could not restore z-order position: {order_error}")
1556
+ break
1557
+
1558
+ print(f" ✓ Replaced textbox with chart from sheet '{sheet_name}' in slide {slide_number}")
1559
+ return True
1560
+ except HttpError as error:
1561
+ print(f"Error replacing textbox with chart in slide {slide_number}: {error}")
1562
+ return False
1563
+
1564
+ def replace_textbox_with_image(presentation_id, slide_id, slide_number, textbox_element, image_url_or_file_id, creds):
1565
+ """
1566
+ Replace a textbox element with an image, resizing it to match the textbox dimensions.
1567
+ Maintains the z-order position of the original textbox.
1568
+
1569
+ Args:
1570
+ presentation_id: ID of the presentation
1571
+ slide_id: ID of the slide
1572
+ slide_number: Slide number (1-based)
1573
+ textbox_element: The textbox page element to replace
1574
+ image_url_or_file_id: Full image URL (with access token if from Drive) or Drive file ID
1575
+ creds: Service account credentials
1576
+
1577
+ Returns:
1578
+ bool: True if successful, False otherwise
1579
+ """
1580
+ slides_service = build('slides', 'v1', credentials=creds)
1581
+ drive_service = build('drive', 'v3', credentials=creds)
1582
+
1583
+ # Get the slide to find the z-order index of the textbox
1584
+ presentation = slides_service.presentations().get(presentationId=presentation_id).execute()
1585
+ presentation_slides = presentation.get('slides', [])
1586
+
1587
+ # Find the slide and get its pageElements
1588
+ slide = None
1589
+ for s in presentation_slides:
1590
+ if s.get('objectId') == slide_id:
1591
+ slide = s
1592
+ break
1593
+
1594
+ if not slide:
1595
+ print(f"Error: Slide {slide_id} not found")
1596
+ return False
1597
+
1598
+ # Find the z-order index of the textbox element
1599
+ textbox_object_id = textbox_element.get('objectId')
1600
+ page_elements = slide.get('pageElements', [])
1601
+ z_order_index = None
1602
+
1603
+ for idx, element in enumerate(page_elements):
1604
+ if element.get('objectId') == textbox_object_id:
1605
+ z_order_index = idx
1606
+ break
1607
+
1608
+ if z_order_index is None:
1609
+ print(f"Warning: Could not find textbox element in slide {slide_number}, z-order may not be preserved")
1610
+ # Continue anyway, but z-order won't be preserved
1611
+
1612
+ # Get position and size from textbox
1613
+ transform = textbox_element.get('transform', {})
1614
+
1615
+ # Extract translate values (position)
1616
+ translate_x = transform.get('translateX', 0)
1617
+ translate_y = transform.get('translateY', 0)
1618
+
1619
+ # Handle both numeric and object formats
1620
+ if isinstance(translate_x, dict):
1621
+ translate_x = translate_x.get('magnitude', 0)
1622
+ if isinstance(translate_y, dict):
1623
+ translate_y = translate_y.get('magnitude', 0)
1624
+
1625
+ # Extract scale factors
1626
+ scale_x = transform.get('scaleX', 1)
1627
+ scale_y = transform.get('scaleY', 1)
1628
+
1629
+ # Handle both numeric and object formats for scale
1630
+ if isinstance(scale_x, dict):
1631
+ scale_x = scale_x.get('magnitude', 1)
1632
+ if isinstance(scale_y, dict):
1633
+ scale_y = scale_y.get('magnitude', 1)
1634
+
1635
+ # Get base size from size field
1636
+ size = textbox_element.get('size', {})
1637
+ width_obj = size.get('width', {})
1638
+ height_obj = size.get('height', {})
1639
+
1640
+ base_width = width_obj.get('magnitude', 4000000) if isinstance(width_obj, dict) else (width_obj if width_obj else 4000000)
1641
+ base_height = height_obj.get('magnitude', 3000000) if isinstance(height_obj, dict) else (height_obj if height_obj else 3000000)
1642
+
1643
+ # Calculate actual rendered size (base size * scale)
1644
+ actual_width = base_width * scale_x
1645
+ actual_height = base_height * scale_y
1646
+
1647
+ # Determine if image_url_or_file_id is a URL or a Drive file ID
1648
+ is_url = image_url_or_file_id.startswith('http://') or image_url_or_file_id.startswith('https://')
1649
+
1650
+ image_url = image_url_or_file_id
1651
+ had_public_permission = False
1652
+ permission_id = None
1653
+
1654
+ if not is_url:
1655
+ # It's a Drive file ID - temporarily grant public access
1656
+ file_id = image_url_or_file_id
1657
+
1658
+ try:
1659
+ # First, check if file already has public access
1660
+ try:
1661
+ permissions = drive_service.permissions().list(
1662
+ fileId=file_id,
1663
+ fields='permissions(id,type,role)',
1664
+ supportsAllDrives=True
1665
+ ).execute()
1666
+
1667
+ # Check if 'anyone' permission already exists
1668
+ has_public_access = False
1669
+ for perm in permissions.get('permissions', []):
1670
+ if perm.get('type') == 'anyone' and perm.get('role') in ['reader', 'viewer']:
1671
+ has_public_access = True
1672
+ permission_id = perm.get('id')
1673
+ break
1674
+ except HttpError:
1675
+ # If we can't check permissions, assume it's not public
1676
+ has_public_access = False
1677
+
1678
+ # If not publicly accessible, try to grant temporary public access
1679
+ if not has_public_access:
1680
+ try:
1681
+ permission = {
1682
+ 'type': 'anyone',
1683
+ 'role': 'reader'
1684
+ }
1685
+ result = drive_service.permissions().create(
1686
+ fileId=file_id,
1687
+ body=permission,
1688
+ supportsAllDrives=True
1689
+ ).execute()
1690
+ permission_id = result.get('id')
1691
+ had_public_permission = True
1692
+ print(f" ℹ️ Temporarily granted public access to image file for insertion")
1693
+ except HttpError as perm_error:
1694
+ # If we can't modify permissions, check if file has a shareable link
1695
+ print(f" ⚠️ Cannot modify file permissions (app lacks write access). Checking for existing shareable link...")
1696
+ # Try to get webContentLink - this might work if file is already shared
1697
+ try:
1698
+ file_metadata = drive_service.files().get(
1699
+ fileId=file_id,
1700
+ fields='webContentLink,webViewLink',
1701
+ supportsAllDrives=True
1702
+ ).execute()
1703
+
1704
+ web_content_link = file_metadata.get('webContentLink')
1705
+ if web_content_link:
1706
+ # Extract file ID from webContentLink and construct direct download URL
1707
+ image_url = f"https://drive.google.com/uc?export=download&id={file_id}"
1708
+ print(f" ℹ️ Using existing shareable link (file may need to be manually shared)")
1709
+ else:
1710
+ # No shareable link available
1711
+ raise ValueError("File is not publicly accessible and app cannot modify permissions. Please manually share the file with 'Anyone with the link' access.")
1712
+ except Exception as link_error:
1713
+ raise ValueError(f"File is not publicly accessible. Please manually share the image file (ID: {file_id}) with 'Anyone with the link' access, or grant the app write access to modify permissions. Error: {perm_error}")
1714
+
1715
+ # Get the public URL for the image
1716
+ file_metadata = drive_service.files().get(
1717
+ fileId=file_id,
1718
+ fields='webContentLink',
1719
+ supportsAllDrives=True
1720
+ ).execute()
1721
+
1722
+ web_content_link = file_metadata.get('webContentLink')
1723
+ if web_content_link:
1724
+ # Convert webContentLink to direct download URL
1725
+ # webContentLink format: https://drive.google.com/uc?id=FILE_ID&export=download
1726
+ # We need: https://drive.google.com/uc?export=download&id=FILE_ID
1727
+ image_url = f"https://drive.google.com/uc?export=download&id={file_id}"
1728
+ else:
1729
+ # Fallback to constructing URL manually
1730
+ image_url = f"https://drive.google.com/uc?export=download&id={file_id}"
1731
+
1732
+ except (HttpError, ValueError) as e:
1733
+ error_msg = str(e)
1734
+ if "manually share" in error_msg.lower() or "cannot modify" in error_msg.lower():
1735
+ print(f" ⚠️ {error_msg}")
1736
+ else:
1737
+ print(f" ⚠️ Error setting up image file permissions: {e}")
1738
+ # Fallback to basic Drive URL (may not work if file is not public)
1739
+ image_url = f"https://drive.google.com/uc?export=download&id={file_id}"
1740
+ print(f" ⚠️ Attempting to use file URL anyway (may fail if file is not publicly accessible)")
1741
+
1742
+ # Prepare requests to delete textbox and insert image
1743
+ create_image_request = {
1744
+ 'createImage': {
1745
+ 'url': image_url,
1746
+ 'elementProperties': {
1747
+ 'pageObjectId': slide_id,
1748
+ 'size': {
1749
+ 'height': {'magnitude': actual_height, 'unit': 'EMU'},
1750
+ 'width': {'magnitude': actual_width, 'unit': 'EMU'}
1751
+ },
1752
+ 'transform': {
1753
+ 'scaleX': 1,
1754
+ 'scaleY': 1,
1755
+ 'translateX': translate_x,
1756
+ 'translateY': translate_y,
1757
+ 'unit': 'EMU'
1758
+ }
1759
+ }
1760
+ }
1761
+ }
1762
+
1763
+ requests = [
1764
+ {
1765
+ 'deleteObject': {
1766
+ 'objectId': textbox_element.get('objectId')
1767
+ }
1768
+ },
1769
+ create_image_request
1770
+ ]
1771
+
1772
+ # Execute the batch update with retry logic
1773
+ body = {'requests': requests}
1774
+
1775
+ def execute_batch_update():
1776
+ return slides_service.presentations().batchUpdate(
1777
+ presentationId=presentation_id,
1778
+ body=body
1779
+ ).execute()
1780
+
1781
+ try:
1782
+ response = retry_with_exponential_backoff(execute_batch_update)
1783
+
1784
+ # Get the objectId of the newly created image and restore z-order
1785
+ if z_order_index is not None:
1786
+ # Extract the objectId from the response
1787
+ replies = response.get('replies', [])
1788
+ new_image_object_id = None
1789
+
1790
+ for reply in replies:
1791
+ if 'createImage' in reply:
1792
+ new_image_object_id = reply['createImage'].get('objectId')
1793
+ break
1794
+
1795
+ if new_image_object_id:
1796
+ # After deletion, the z-order indices shift, so we need to adjust
1797
+ # Since we deleted the element at z_order_index, the new element is at the end
1798
+ # We need to move it back to z_order_index
1799
+ # Get current slide state to find the correct new index
1800
+ updated_presentation = slides_service.presentations().get(presentationId=presentation_id).execute()
1801
+ updated_slides = updated_presentation.get('slides', [])
1802
+
1803
+ for s in updated_slides:
1804
+ if s.get('objectId') == slide_id:
1805
+ updated_page_elements = s.get('pageElements', [])
1806
+ # Find the current index of the new image
1807
+ current_index = None
1808
+ for idx, element in enumerate(updated_page_elements):
1809
+ if element.get('objectId') == new_image_object_id:
1810
+ current_index = idx
1811
+ break
1812
+
1813
+ # If found and it's not already at the correct position, move it
1814
+ if current_index is not None and current_index != z_order_index:
1815
+ # Calculate how many positions to move
1816
+ # After deletion, elements after the deleted one shift down by 1
1817
+ # The new element is at the end, we need to move it to z_order_index
1818
+ positions_to_move = current_index - z_order_index
1819
+
1820
+ if positions_to_move > 0:
1821
+ # Need to move backward (toward front of array = lower z-order)
1822
+ # Use SEND_BACKWARD the required number of times
1823
+ order_requests = []
1824
+ for _ in range(positions_to_move):
1825
+ order_requests.append({
1826
+ 'updatePageElementsZOrder': {
1827
+ 'pageElementObjectIds': [new_image_object_id],
1828
+ 'operation': 'SEND_BACKWARD'
1829
+ }
1830
+ })
1831
+
1832
+ def execute_order_update():
1833
+ return slides_service.presentations().batchUpdate(
1834
+ presentationId=presentation_id,
1835
+ body={'requests': order_requests}
1836
+ ).execute()
1837
+
1838
+ try:
1839
+ retry_with_exponential_backoff(execute_order_update)
1840
+ except HttpError as order_error:
1841
+ print(f" ⚠️ Warning: Could not restore z-order position: {order_error}")
1842
+ break
1843
+
1844
+ print(f" ✓ Replaced textbox with image in slide {slide_number}")
1845
+ return True
1846
+ except HttpError as error:
1847
+ print(f"Error replacing textbox with image in slide {slide_number}: {error}")
1848
+ return False
1849
+ finally:
1850
+ # Always revoke the temporary public permission, whether insertion succeeded or failed
1851
+ if had_public_permission and permission_id and not is_url:
1852
+ try:
1853
+ drive_service.permissions().delete(
1854
+ fileId=image_url_or_file_id,
1855
+ permissionId=permission_id,
1856
+ supportsAllDrives=True
1857
+ ).execute()
1858
+ print(f" ℹ️ Revoked temporary public access from image file")
1859
+ except HttpError as revoke_error:
1860
+ print(f" ⚠️ Warning: Could not revoke temporary public access: {revoke_error}")
1861
+
1862
+ def replace_multiple_placeholders_in_textbox(presentation_id, slide_number, textbox_element, placeholder_map, creds):
1863
+ """
1864
+ Replace multiple placeholders in a single textbox efficiently.
1865
+ Preserves the text style from each deleted placeholder text.
1866
+
1867
+ Args:
1868
+ presentation_id: ID of the presentation
1869
+ slide_number: Slide number (1-based)
1870
+ textbox_element: The textbox page element
1871
+ placeholder_map: Dictionary mapping placeholder text to replacement text
1872
+ (e.g., {'{{percentage}}': '97.5', '{{entity_rank}}': '31'})
1873
+ creds: Service account credentials
1874
+
1875
+ Returns:
1876
+ bool: True if successful, False otherwise
1877
+ """
1878
+ slides_service = build('slides', 'v1', credentials=creds)
1879
+
1880
+ shape_id = textbox_element.get('objectId')
1881
+
1882
+ # Find the exact range of all placeholders in the text
1883
+ text_content = textbox_element['shape']['text']
1884
+ text_elements = text_content.get('textElements', [])
1885
+
1886
+ # Build full text to find placeholder positions
1887
+ full_text = ''
1888
+ for element in text_elements:
1889
+ if 'textRun' in element:
1890
+ full_text += element['textRun'].get('content', '')
1891
+
1892
+ # Whitelist of writable text style fields
1893
+ writable_text_style_fields = [
1894
+ 'bold', 'italic', 'underline', 'strikethrough',
1895
+ 'fontFamily', 'fontSize', 'foregroundColor', 'backgroundColor',
1896
+ 'weightedFontFamily' # Font weight (object with fontFamily and weight)
1897
+ ]
1898
+
1899
+ # Helper function to filter text style to only writable fields
1900
+ def filter_text_style(text_style):
1901
+ """Filter text style to only include writable fields."""
1902
+ if not text_style:
1903
+ return {}
1904
+ filtered = {}
1905
+ for field in writable_text_style_fields:
1906
+ if field in text_style:
1907
+ filtered[field] = text_style[field]
1908
+ return filtered
1909
+
1910
+ # Helper function to extract text style from a position
1911
+ def get_style_at_position(position):
1912
+ """Extract text style from the textRun at the given position."""
1913
+ for element in text_elements:
1914
+ if 'textRun' in element:
1915
+ element_start = element.get('startIndex', 0)
1916
+ element_end = element.get('endIndex', element_start + len(element['textRun'].get('content', '')))
1917
+
1918
+ # Check if this textRun overlaps with the position
1919
+ if element_start <= position < element_end:
1920
+ text_run = element['textRun']
1921
+ return text_run.get('style', {})
1922
+ return {}
1923
+
1924
+ # Find all placeholders and their positions
1925
+ placeholder_positions = []
1926
+ for placeholder_text, replacement_text in placeholder_map.items():
1927
+ start_pos = 0
1928
+ while True:
1929
+ pos = full_text.find(placeholder_text, start_pos)
1930
+ if pos == -1:
1931
+ break
1932
+ # Extract style from the first character of the placeholder
1933
+ placeholder_style = get_style_at_position(pos)
1934
+ filtered_style = filter_text_style(placeholder_style)
1935
+
1936
+ placeholder_positions.append({
1937
+ 'placeholder': placeholder_text,
1938
+ 'replacement': replacement_text,
1939
+ 'start': pos,
1940
+ 'end': pos + len(placeholder_text),
1941
+ 'style': filtered_style
1942
+ })
1943
+ start_pos = pos + 1
1944
+
1945
+ if not placeholder_positions:
1946
+ return False
1947
+
1948
+ # Sort by position in reverse order (end to start) to maintain indices during replacement
1949
+ placeholder_positions.sort(key=lambda x: x['start'], reverse=True)
1950
+
1951
+ # Build batch requests for all replacements
1952
+ requests = []
1953
+ for placeholder_info in placeholder_positions:
1954
+ placeholder_text = placeholder_info['placeholder']
1955
+ replacement_text = placeholder_info['replacement']
1956
+ start_index = placeholder_info['start']
1957
+ end_index = placeholder_info['end']
1958
+ filtered_style = placeholder_info['style']
1959
+
1960
+ # Delete the placeholder text and insert replacement text
1961
+ requests.append({
1962
+ 'deleteText': {
1963
+ 'objectId': shape_id,
1964
+ 'textRange': {
1965
+ 'type': 'FIXED_RANGE',
1966
+ 'startIndex': start_index,
1967
+ 'endIndex': end_index
1968
+ }
1969
+ }
1970
+ })
1971
+ requests.append({
1972
+ 'insertText': {
1973
+ 'objectId': shape_id,
1974
+ 'insertionIndex': start_index,
1975
+ 'text': replacement_text
1976
+ }
1977
+ })
1978
+
1979
+ # If we have a style to apply, add an updateTextStyle request
1980
+ if filtered_style:
1981
+ replacement_end = start_index + len(replacement_text)
1982
+ requests.append({
1983
+ 'updateTextStyle': {
1984
+ 'objectId': shape_id,
1985
+ 'textRange': {
1986
+ 'type': 'FIXED_RANGE',
1987
+ 'startIndex': start_index,
1988
+ 'endIndex': replacement_end
1989
+ },
1990
+ 'style': filtered_style,
1991
+ 'fields': ','.join(filtered_style.keys())
1992
+ }
1993
+ })
1994
+
1995
+ # Execute the batch update with retry logic
1996
+ body = {'requests': requests}
1997
+
1998
+ def execute_batch_update():
1999
+ return slides_service.presentations().batchUpdate(
2000
+ presentationId=presentation_id,
2001
+ body=body
2002
+ ).execute()
2003
+
2004
+ try:
2005
+ response = retry_with_exponential_backoff(execute_batch_update)
2006
+ replaced_count = len(placeholder_positions)
2007
+ print(f" ✓ Replaced {replaced_count} placeholder(s) in slide {slide_number}")
2008
+ return True
2009
+ except HttpError as error:
2010
+ print(f"Error replacing multiple placeholders in slide {slide_number}: {error}")
2011
+ return False
2012
+
2013
+ def populate_table_with_data(slides_service, presentation_id, slide_number, table_element, table_data):
2014
+ """
2015
+ Populate a Slides table element with data while preserving existing text formatting.
2016
+ """
2017
+ table = table_element.get('table', {})
2018
+ table_id = table_element.get('objectId')
2019
+ table_rows = table.get('tableRows', [])
2020
+
2021
+ if not table_rows or not table_id:
2022
+ print(f" ⚠️ Table on slide {slide_number} has no rows or objectId, skipping")
2023
+ return False
2024
+
2025
+ num_rows = len(table_rows)
2026
+ num_cols = 0
2027
+ for row in table_rows:
2028
+ num_cols = max(num_cols, len(row.get('tableCells', [])))
2029
+
2030
+ if num_cols == 0:
2031
+ print(f" ⚠️ Table on slide {slide_number} has no columns, skipping")
2032
+ return False
2033
+
2034
+ # Determine data dimensions
2035
+ data_rows = len(table_data)
2036
+ data_cols = max((len(r) for r in table_data), default=0)
2037
+
2038
+ # If we need more rows, add them before populating
2039
+ if data_rows > num_rows:
2040
+ rows_to_add = data_rows - num_rows
2041
+ print(f" ℹ️ Adding {rows_to_add} row(s) to table on slide {slide_number} to accommodate data")
2042
+
2043
+ # Google Slides API limits: max 20 rows per insertTableRows request
2044
+ max_rows_per_request = 20
2045
+ current_row_index = num_rows - 1 # Start inserting after the last existing row
2046
+ remaining_rows = rows_to_add
2047
+
2048
+ try:
2049
+ # Break insertion into batches of max 20 rows
2050
+ while remaining_rows > 0:
2051
+ rows_in_batch = min(remaining_rows, max_rows_per_request)
2052
+
2053
+ insert_request = {
2054
+ 'insertTableRows': {
2055
+ 'tableObjectId': table_id,
2056
+ 'cellLocation': {
2057
+ 'rowIndex': current_row_index,
2058
+ 'columnIndex': 0
2059
+ },
2060
+ 'insertBelow': True,
2061
+ 'number': rows_in_batch
2062
+ }
2063
+ }
2064
+
2065
+ def execute_insert_rows():
2066
+ return slides_service.presentations().batchUpdate(
2067
+ presentationId=presentation_id,
2068
+ body={'requests': [insert_request]}
2069
+ ).execute()
2070
+
2071
+ retry_with_exponential_backoff(execute_insert_rows)
2072
+
2073
+ # Update for next batch: move insertion point and reduce remaining count
2074
+ current_row_index += rows_in_batch
2075
+ remaining_rows -= rows_in_batch
2076
+
2077
+ # Update num_rows to reflect the new table size
2078
+ num_rows = data_rows
2079
+ except HttpError as error:
2080
+ print(f" ⚠️ Error adding rows to table on slide {slide_number}: {error}")
2081
+ return False
2082
+
2083
+ # Warn if data has more columns than table (we can't add columns easily)
2084
+ if data_cols > num_cols:
2085
+ print(f" ⚠️ Table data for slide {slide_number} has more columns ({data_cols}) than the table ({num_cols}). Extra columns will be ignored.")
2086
+
2087
+ # Reuse the first available text style in a cell so formatting stays consistent
2088
+ writable_text_style_fields = [
2089
+ 'bold', 'italic', 'underline', 'strikethrough',
2090
+ 'fontFamily', 'fontSize', 'foregroundColor', 'backgroundColor',
2091
+ 'weightedFontFamily'
2092
+ ]
2093
+
2094
+ def filter_text_style(text_style):
2095
+ if not text_style:
2096
+ return {}
2097
+ filtered = {}
2098
+ for field in writable_text_style_fields:
2099
+ if field in text_style:
2100
+ filtered[field] = text_style[field]
2101
+ return filtered
2102
+
2103
+ def get_first_text_style(cell):
2104
+ text_elements = cell.get('text', {}).get('textElements', [])
2105
+ for element in text_elements:
2106
+ if 'textRun' in element:
2107
+ style = element['textRun'].get('style', {})
2108
+ filtered = filter_text_style(style)
2109
+ if filtered:
2110
+ return filtered
2111
+ return {}
2112
+
2113
+ def cell_has_text(cell):
2114
+ """Check if a cell has any text content."""
2115
+ if not cell:
2116
+ return False
2117
+ text_elements = cell.get('text', {}).get('textElements', [])
2118
+ for element in text_elements:
2119
+ if 'textRun' in element:
2120
+ content = element['textRun'].get('content', '')
2121
+ if content and content.strip():
2122
+ return True
2123
+ return False
2124
+
2125
+ # Get reference text style from last existing row for new rows
2126
+ reference_text_style = {}
2127
+ if table_rows:
2128
+ last_row = table_rows[-1]
2129
+ last_row_cells = last_row.get('tableCells', [])
2130
+ if last_row_cells:
2131
+ reference_text_style = get_first_text_style(last_row_cells[0])
2132
+
2133
+ requests = []
2134
+ for r in range(num_rows):
2135
+ # Get row cells if this is an existing row
2136
+ row_cells = []
2137
+ if r < len(table_rows):
2138
+ row_cells = table_rows[r].get('tableCells', [])
2139
+
2140
+ for c in range(num_cols):
2141
+ # Skip if column doesn't exist in this row
2142
+ if r < len(table_rows) and c >= len(row_cells):
2143
+ continue
2144
+
2145
+ # Get cell for style reference (only for existing rows)
2146
+ cell = None
2147
+ if r < len(table_rows) and c < len(row_cells):
2148
+ cell = row_cells[c]
2149
+
2150
+ # Get value from data
2151
+ value = ''
2152
+ if r < len(table_data) and c < len(table_data[r]):
2153
+ value = str(table_data[r][c]) if table_data[r][c] is not None else ''
2154
+
2155
+ # Use cell style if available (existing rows), otherwise use reference style (new rows)
2156
+ if cell:
2157
+ text_style = get_first_text_style(cell)
2158
+ else:
2159
+ text_style = reference_text_style
2160
+
2161
+ # Clear existing text only if cell has text content
2162
+ # Skip deleteText for empty cells to avoid API errors
2163
+ if cell and cell_has_text(cell):
2164
+ requests.append({
2165
+ 'deleteText': {
2166
+ 'objectId': table_id,
2167
+ 'cellLocation': {
2168
+ 'rowIndex': r,
2169
+ 'columnIndex': c
2170
+ },
2171
+ 'textRange': {'type': 'ALL'}
2172
+ }
2173
+ })
2174
+
2175
+ # Insert new value (skip insert for empty strings)
2176
+ if value:
2177
+ requests.append({
2178
+ 'insertText': {
2179
+ 'objectId': table_id,
2180
+ 'cellLocation': {
2181
+ 'rowIndex': r,
2182
+ 'columnIndex': c
2183
+ },
2184
+ 'insertionIndex': 0,
2185
+ 'text': value
2186
+ }
2187
+ })
2188
+
2189
+ if text_style:
2190
+ requests.append({
2191
+ 'updateTextStyle': {
2192
+ 'objectId': table_id,
2193
+ 'cellLocation': {
2194
+ 'rowIndex': r,
2195
+ 'columnIndex': c
2196
+ },
2197
+ 'textRange': {
2198
+ 'type': 'FIXED_RANGE',
2199
+ 'startIndex': 0,
2200
+ 'endIndex': len(value)
2201
+ },
2202
+ 'style': text_style,
2203
+ 'fields': ','.join(text_style.keys())
2204
+ }
2205
+ })
2206
+
2207
+ if not requests:
2208
+ return True
2209
+
2210
+ # Execute in batches to avoid request limits
2211
+ batch_size = 50
2212
+ try:
2213
+ for i in range(0, len(requests), batch_size):
2214
+ slides_service.presentations().batchUpdate(
2215
+ presentationId=presentation_id,
2216
+ body={'requests': requests[i:i + batch_size]}
2217
+ ).execute()
2218
+ print(f" ✓ Populated table on slide {slide_number}")
2219
+ return True
2220
+ except HttpError as error:
2221
+ print(f" ⚠️ Error populating table on slide {slide_number}: {error}")
2222
+ return False
2223
+
2224
+ def process_all_slides(presentation_id, sheet_mappings, spreadsheet_id, entity_name, data_sheet, entity_folder_id, creds, slides: Optional[Set[int]] = None):
2225
+ """
2226
+ Process all slides in the presentation, replacing placeholders based on sheet mappings.
2227
+
2228
+ Args:
2229
+ presentation_id: ID of the presentation
2230
+ sheet_mappings: List of sheet mapping dictionaries with 'placeholder_type', 'placeholder_name', 'sheet_name'
2231
+ spreadsheet_id: ID of the spreadsheet
2232
+ entity_name: Name of the entity for text placeholder replacement
2233
+ data_sheet: Dictionary with text replacement values from the 'data' tab
2234
+ entity_folder_id: ID of the entity folder containing image files
2235
+ creds: Service account credentials
2236
+ slides: Optional set of slide numbers to process. If None, processes all slides.
2237
+
2238
+ Returns:
2239
+ bool: True if successful, False otherwise
2240
+ """
2241
+ slides_service = build('slides', 'v1', credentials=creds)
2242
+
2243
+ try:
2244
+ # Get the presentation
2245
+ presentation = slides_service.presentations().get(presentationId=presentation_id).execute()
2246
+ presentation_slides = presentation.get('slides', [])
2247
+
2248
+ # Build lookup dictionaries keyed by placeholder name
2249
+ chart_mapping_by_name = {}
2250
+ table_mapping_by_name = {}
2251
+ for mapping in sheet_mappings:
2252
+ placeholder_type = mapping['placeholder_type']
2253
+ placeholder_name = mapping['placeholder_name']
2254
+ if placeholder_type == 'chart':
2255
+ chart_mapping_by_name[f"chart-{placeholder_name}"] = mapping
2256
+ elif placeholder_type == 'table':
2257
+ table_mapping_by_name[placeholder_name] = mapping
2258
+
2259
+ # Text placeholder to look for
2260
+ entity_placeholder = "{{entity_name}}"
2261
+ table_placeholder_pattern = r'^\{\{table-([^}]+)\}\}$'
2262
+ table_data_cache = {}
2263
+ table_decisions = {}
2264
+
2265
+ # Loop through all slides
2266
+ for slide_index, slide in enumerate(presentation_slides):
2267
+ slide_number = slide_index + 1 # 1-based slide number (used only for messaging)
2268
+
2269
+ if slides and slide_number not in slides:
2270
+ print(f"\nSkipping slide {slide_number} (not requested)")
2271
+ continue
2272
+
2273
+ slide_id = slide.get('objectId')
2274
+
2275
+ print(f"\nProcessing slide {slide_number}...")
2276
+
2277
+ # Loop through all elements in the slide
2278
+ for page_element in slide.get('pageElements', []):
2279
+ # Process tables first
2280
+ if 'table' in page_element:
2281
+ table_obj = page_element.get('table', {})
2282
+ table_rows = table_obj.get('tableRows', [])
2283
+ if not table_rows or not table_rows[0].get('tableCells'):
2284
+ continue
2285
+
2286
+ first_cell = table_rows[0].get('tableCells')[0]
2287
+ text_elements = first_cell.get('text', {}).get('textElements', [])
2288
+ top_left_text = ''
2289
+ for element in text_elements:
2290
+ if 'textRun' in element:
2291
+ top_left_text += element['textRun'].get('content', '')
2292
+ top_left_text = top_left_text.strip()
2293
+
2294
+ table_match = re.match(table_placeholder_pattern, top_left_text)
2295
+ if not table_match:
2296
+ continue
2297
+
2298
+ table_name = table_match.group(1).strip()
2299
+ mapping = table_mapping_by_name.get(table_name)
2300
+ sheet_name = mapping['sheet_name'] if mapping else f"table-{table_name}"
2301
+
2302
+ if table_name not in table_data_cache:
2303
+ table_values = read_table_from_sheet(spreadsheet_id, sheet_name, creds)
2304
+ table_data_cache[table_name] = table_values
2305
+ else:
2306
+ # Duplicate reference detected
2307
+ if table_name not in table_decisions:
2308
+ response = input(f"Multiple references to table '{table_name}' detected. Continue replacing everywhere? [y/N]: ").strip().lower()
2309
+ table_decisions[table_name] = response in ('y', 'yes')
2310
+ if not table_decisions.get(table_name, False):
2311
+ print(f" ✗ Stopping at duplicate table '{table_name}' per user choice.")
2312
+ return False
2313
+ table_values = table_data_cache[table_name]
2314
+
2315
+ if table_values is None:
2316
+ print(f" ⚠️ Skipping table '{table_name}' on slide {slide_number} due to missing data")
2317
+ continue
2318
+
2319
+ success = populate_table_with_data(
2320
+ slides_service=slides_service,
2321
+ presentation_id=presentation_id,
2322
+ slide_number=slide_number,
2323
+ table_element=page_element,
2324
+ table_data=table_values
2325
+ )
2326
+ if not success:
2327
+ print(f" ⚠️ Failed to populate table '{table_name}' on slide {slide_number}")
2328
+ continue
2329
+
2330
+ # Only process text elements (shapes with text)
2331
+ if 'shape' in page_element and 'text' in page_element['shape']:
2332
+ text_content = page_element['shape']['text'].get('textElements', [])
2333
+ full_text = ''
2334
+
2335
+ # Build full text from all text elements
2336
+ for element in text_content:
2337
+ if 'textRun' in element:
2338
+ full_text += element['textRun'].get('content', '')
2339
+
2340
+ full_text_stripped = full_text.strip()
2341
+
2342
+ # Check for chart placeholders: {{chart-placeholder_name}} format
2343
+ chart_pattern = r'\{\{(chart-[^}]+)\}\}'
2344
+ chart_match = re.match(chart_pattern, full_text_stripped)
2345
+ if chart_match:
2346
+ placeholder_name = chart_match.group(1).strip()
2347
+ if placeholder_name in chart_mapping_by_name:
2348
+ mapping = chart_mapping_by_name[placeholder_name]
2349
+ success = replace_textbox_with_chart(
2350
+ presentation_id=presentation_id,
2351
+ slide_id=slide_id,
2352
+ slide_number=slide_number,
2353
+ textbox_element=page_element,
2354
+ spreadsheet_id=spreadsheet_id,
2355
+ sheet_name=mapping['sheet_name'],
2356
+ creds=creds
2357
+ )
2358
+ if not success:
2359
+ print(f" ⚠️ Failed to replace chart placeholder: {placeholder_name}")
2360
+ else:
2361
+ print(f" ⚠️ No mapping found for chart placeholder: {placeholder_name} in slide {slide_number}")
2362
+ continue # Skip further processing for chart placeholders
2363
+
2364
+ # Check for {{picture-placeholder_name}} format and replace with image
2365
+ picture_pattern = r'\{\{picture-([^}]+)\}\}'
2366
+ picture_match = re.search(picture_pattern, full_text)
2367
+ if picture_match:
2368
+ placeholder_name = picture_match.group(1).strip()
2369
+ # Search for image file in entity folder: picture-<placeholder_name>
2370
+ image_url_or_file_id = get_image_file_from_folder(
2371
+ entity_folder_id=entity_folder_id,
2372
+ picture_name=placeholder_name,
2373
+ creds=creds
2374
+ )
2375
+ if image_url_or_file_id:
2376
+ # Replace textbox with image
2377
+ success = replace_textbox_with_image(
2378
+ presentation_id=presentation_id,
2379
+ slide_id=slide_id,
2380
+ slide_number=slide_number,
2381
+ textbox_element=page_element,
2382
+ image_url_or_file_id=image_url_or_file_id,
2383
+ creds=creds
2384
+ )
2385
+ if not success:
2386
+ print(f" ⚠️ Failed to replace picture placeholder: {placeholder_name}")
2387
+ else:
2388
+ print(f" ⚠️ No image found matching 'picture-{placeholder_name}' in entity folder")
2389
+ continue # Skip further processing for picture placeholders
2390
+
2391
+ # Collect all placeholders to replace (entity_name + data sheet placeholders)
2392
+ placeholder_map = {}
2393
+
2394
+ # Add entity_name placeholder if present
2395
+ if entity_placeholder in full_text:
2396
+ placeholder_map[entity_placeholder] = entity_name
2397
+
2398
+ # Check if there's a data sheet for this slide and add data placeholders
2399
+ if data_sheet:
2400
+ placeholder_pattern = r'\{\{([^}]+)\}\}'
2401
+ found_placeholders = re.findall(placeholder_pattern, full_text)
2402
+
2403
+ for placeholder_name in found_placeholders:
2404
+ full_placeholder = f"{{{{{placeholder_name}}}}}"
2405
+ if placeholder_name != 'entity_name' and placeholder_name in data_sheet:
2406
+ replacement_value = data_sheet[placeholder_name]
2407
+ placeholder_map[full_placeholder] = replacement_value
2408
+
2409
+ # Replace all placeholders in one batch operation
2410
+ if placeholder_map:
2411
+ success = replace_multiple_placeholders_in_textbox(
2412
+ presentation_id=presentation_id,
2413
+ slide_number=slide_number,
2414
+ textbox_element=page_element,
2415
+ placeholder_map=placeholder_map,
2416
+ creds=creds
2417
+ )
2418
+ if not success:
2419
+ print(f" ⚠️ Failed to replace placeholders in slide {slide_number}")
2420
+
2421
+ return True
2422
+
2423
+ except HttpError as error:
2424
+ print(f"Error processing slides: {error}")
2425
+ return False
2426
+
2427
+ def process_spreadsheet(spreadsheet_id, spreadsheet_name, template_id, output_folder_id, entity_folder_id, creds, slides: Optional[Set[int]] = None):
2428
+ """
2429
+ Process a spreadsheet to generate a Google Slides presentation.
2430
+
2431
+ Args:
2432
+ spreadsheet_id: ID of the source spreadsheet
2433
+ spreadsheet_name: Name to use for the output presentation
2434
+ template_id: ID of the template presentation
2435
+ output_folder_id: ID of the folder to save the presentation
2436
+ entity_folder_id: ID of the entity folder containing image files
2437
+ creds: Service account credentials
2438
+ slides: Optional set of slide numbers to process. If None, processes all slides.
2439
+
2440
+ Returns:
2441
+ str: ID of the created presentation, or None if failed
2442
+ """
2443
+ print(f"\n{'='*80}")
2444
+ print(f"Processing spreadsheet: {spreadsheet_name}")
2445
+ print(f"{'='*80}\n")
2446
+
2447
+ # Initialize services
2448
+ gspread_client = gspread.authorize(creds)
2449
+
2450
+ # Use entity_name from file/folder name
2451
+ entity_name = spreadsheet_name
2452
+
2453
+ try:
2454
+ # Get all worksheets from the spreadsheet
2455
+ print("Reading spreadsheet worksheets...")
2456
+ spreadsheet = gspread_client.open_by_key(spreadsheet_id)
2457
+ worksheets = spreadsheet.worksheets()
2458
+
2459
+ # Filter and parse sheets matching the pattern (no slide numbers)
2460
+ sheet_mappings = []
2461
+ for worksheet in worksheets:
2462
+ sheet_name = worksheet.title
2463
+ parsed = parse_sheet_name(sheet_name)
2464
+ if parsed:
2465
+ placeholder_type, placeholder_name = parsed
2466
+ sheet_mappings.append({
2467
+ 'sheet_name': sheet_name,
2468
+ 'placeholder_type': placeholder_type,
2469
+ 'placeholder_name': placeholder_name
2470
+ })
2471
+ print(f" Found: {sheet_name} -> Type: {placeholder_type}, Placeholder: {placeholder_name}")
2472
+
2473
+ if not sheet_mappings:
2474
+ print("⚠️ No sheets matching the pattern <type>:<placeholder> found!")
2475
+ else:
2476
+ print(f"\nFound {len(sheet_mappings)} sheet mappings\n")
2477
+
2478
+ # Read the shared 'data' sheet for text placeholders
2479
+ print("Reading data sheet...")
2480
+ try:
2481
+ data_sheet = read_data_from_sheet(spreadsheet_id, "data", creds)
2482
+ if data_sheet:
2483
+ print(f" Loaded {len(data_sheet)} placeholder(s): {', '.join(data_sheet.keys())}")
2484
+ else:
2485
+ print(" ⚠️ No data found in 'data' sheet")
2486
+ except Exception as e:
2487
+ print(f" ⚠️ Failed to read 'data' sheet: {e}")
2488
+ data_sheet = None
2489
+
2490
+ incremental_update = slides is not None
2491
+
2492
+ if incremental_update:
2493
+ print("Checking for existing presentation (incremental slide regeneration)...")
2494
+ presentation_id = find_existing_presentation(entity_name, output_folder_id, creds)
2495
+ if presentation_id:
2496
+ print(f" ✓ Using existing presentation: {entity_name}.gslides (ID: {presentation_id})")
2497
+ try:
2498
+ refreshed = replace_slides_from_template(
2499
+ presentation_id=presentation_id,
2500
+ template_id=template_id,
2501
+ slide_numbers=slides,
2502
+ creds=creds,
2503
+ )
2504
+ except ValueError as e:
2505
+ print(f" ✗ {e}")
2506
+ return None
2507
+
2508
+ if not refreshed:
2509
+ print(" ✗ Failed to refresh requested slides from template.")
2510
+ return None
2511
+ else:
2512
+ print(" ⚠️ No existing presentation found; creating new from template.")
2513
+ presentation_id = copy_template_presentation(
2514
+ entity_name, template_id, output_folder_id, creds
2515
+ )
2516
+ else:
2517
+ # Delete existing presentation if it exists then recreate
2518
+ print("Checking for existing presentation...")
2519
+ delete_existing_presentation(entity_name, output_folder_id, creds)
2520
+
2521
+ # Copy template presentation (use entity_name from file/folder name)
2522
+ presentation_id = copy_template_presentation(
2523
+ entity_name, template_id, output_folder_id, creds
2524
+ )
2525
+
2526
+ # Process all slides and replace placeholders
2527
+ success = process_all_slides(
2528
+ presentation_id=presentation_id,
2529
+ sheet_mappings=sheet_mappings,
2530
+ spreadsheet_id=spreadsheet_id,
2531
+ entity_name=entity_name,
2532
+ data_sheet=data_sheet,
2533
+ entity_folder_id=entity_folder_id,
2534
+ creds=creds,
2535
+ slides=slides,
2536
+ )
2537
+
2538
+ if not success:
2539
+ print("Warning: Some placeholders may not have been replaced successfully")
2540
+
2541
+ print(f"\n{'='*80}")
2542
+ print(f"✓ Presentation created successfully!")
2543
+ print(f" Presentation ID: {presentation_id}")
2544
+ print(f" View at: https://docs.google.com/presentation/d/{presentation_id}/edit")
2545
+ print(f"{'='*80}\n")
2546
+
2547
+ return presentation_id
2548
+
2549
+ except Exception as e:
2550
+ print(f"\nError processing spreadsheet: {e}")
2551
+ import traceback
2552
+ traceback.print_exc()
2553
+ return None
2554
+
2555
+ def generate_report(creds=None, layout: DriveLayout = None, input_folder_id=None, template_id=None, output_folder_id=None):
2556
+ """
2557
+ Generate Google Slides presentations from Google Sheets for entities marked for generation in entities.csv.
2558
+
2559
+ Args:
2560
+ creds: Google OAuth credentials. If None, will be obtained automatically.
2561
+ layout: DriveLayout object containing configuration. Required if not using individual folder IDs.
2562
+ input_folder_id: Input folder ID. If None, uses layout.l1_data_id.
2563
+ template_id: Template presentation ID. If None, uses layout.report_template_id.
2564
+ output_folder_id: Output folder ID. If None, uses layout.l2_report_id.
2565
+
2566
+ Returns:
2567
+ dict: Dictionary with 'successful' (list of tuples (entity_name, presentation_id))
2568
+ and 'failed' (list of tuples (entity_name, error_message))
2569
+
2570
+ Raises:
2571
+ FileNotFoundError: If service account credentials are not found
2572
+ ValueError: If neither layout nor all individual folder IDs are provided
2573
+ Exception: Other errors during processing
2574
+ """
2575
+ if creds is None:
2576
+ creds = get_oauth_credentials()
2577
+
2578
+ # Use layout if provided, otherwise use individual parameters
2579
+ if layout is not None:
2580
+ if input_folder_id is None:
2581
+ input_folder_id = layout.l1_data_id
2582
+ if template_id is None:
2583
+ template_id = layout.report_template_id
2584
+ if output_folder_id is None:
2585
+ output_folder_id = layout.l2_report_id
2586
+ elif input_folder_id is None or template_id is None or output_folder_id is None:
2587
+ raise ValueError("Either layout (DriveLayout) must be provided, or all of input_folder_id, template_id, and output_folder_id must be provided.")
2588
+
2589
+ print(f"Input folder: {input_folder_id}")
2590
+ print(f"Template: {template_id}")
2591
+ print(f"Output folder: {output_folder_id}\n")
2592
+
2593
+ # Determine which entities to process
2594
+ if layout and layout.entities_csv_id:
2595
+ target_entities = load_entities_with_slides(layout.entities_csv_id, creds)
2596
+ print(f"Loaded {len(target_entities)} entities with generate=Y from entities.csv\n")
2597
+ if not target_entities:
2598
+ print("✗ No entities marked with generate=Y in entities.csv.")
2599
+ return {'successful': [], 'failed': []}
2600
+ else:
2601
+ print("\n✗ No entities CSV ID found in layout.")
2602
+ return {'successful': [], 'failed': []}
2603
+
2604
+ # List all entity folders in the input folder
2605
+ print("Scanning input folder for entity folders...")
2606
+ all_entity_folders = list_entity_folders(input_folder_id, creds)
2607
+
2608
+ if not all_entity_folders:
2609
+ print("No entity folders found in the input folder!")
2610
+ return {'successful': [], 'failed': []}
2611
+
2612
+ # Filter to only include target entities
2613
+ entity_folders = []
2614
+ for folder_id, folder_name in all_entity_folders:
2615
+ if folder_name in target_entities:
2616
+ entity_folders.append((folder_id, folder_name))
2617
+
2618
+ if not entity_folders:
2619
+ print(f"✗ No matching entity folders found for entities marked generate=Y.")
2620
+ print(f" Available folders: {', '.join([name for _, name in all_entity_folders])}")
2621
+ print(f" Requested entities: {', '.join(target_entities)}")
2622
+ return {'successful': [], 'failed': []}
2623
+
2624
+ print(f"Found {len(entity_folders)} matching entity folder(s) to process:\n")
2625
+ for folder_id, folder_name in entity_folders:
2626
+ print(f" - {folder_name} (ID: {folder_id})")
2627
+ print()
2628
+
2629
+ # Process each entity folder
2630
+ successful = []
2631
+ failed = []
2632
+
2633
+ for entity_folder_id, entity_name in entity_folders:
2634
+ try:
2635
+ # Find spreadsheets in this entity folder
2636
+ print(f"\nProcessing entity: {entity_name}")
2637
+ spreadsheets = list_spreadsheets_in_folder(entity_folder_id, creds)
2638
+
2639
+ if not spreadsheets:
2640
+ print(f" ⚠️ No spreadsheets found in entity folder '{entity_name}'")
2641
+ failed.append((entity_name, "No spreadsheets found"))
2642
+ continue
2643
+
2644
+ if len(spreadsheets) > 1:
2645
+ print(f" ⚠️ Multiple spreadsheets found in entity folder '{entity_name}', processing the first one")
2646
+
2647
+ # Process the first spreadsheet in the entity folder
2648
+ spreadsheet_id, spreadsheet_name = spreadsheets[0]
2649
+
2650
+ slides_to_process = target_entities.get(entity_name)
2651
+ slide_list_msg = "all slides" if not slides_to_process else ", ".join(str(n) for n in sorted(slides_to_process))
2652
+ print(f" Slides to generate: {slide_list_msg}")
2653
+
2654
+ # Process the spreadsheet
2655
+ presentation_id = process_spreadsheet(
2656
+ spreadsheet_id=spreadsheet_id,
2657
+ spreadsheet_name=entity_name,
2658
+ template_id=template_id,
2659
+ output_folder_id=output_folder_id,
2660
+ entity_folder_id=entity_folder_id,
2661
+ creds=creds,
2662
+ slides=slides_to_process,
2663
+ )
2664
+
2665
+ if presentation_id:
2666
+ successful.append((entity_name, presentation_id))
2667
+ else:
2668
+ failed.append((entity_name, "Processing returned None"))
2669
+
2670
+ except Exception as e:
2671
+ print(f"\n{'='*80}")
2672
+ print(f"✗ Error processing entity '{entity_name}': {e}")
2673
+ print(f"{'='*80}\n")
2674
+ failed.append((entity_name, str(e)))
2675
+ import traceback
2676
+ traceback.print_exc()
2677
+ # Continue processing other entities
2678
+ continue
2679
+
2680
+ # Print summary
2681
+ print("\n" + "=" * 80)
2682
+ print("PROCESSING SUMMARY")
2683
+ print("=" * 80)
2684
+ print(f"Total entities processed: {len(entity_folders)}")
2685
+ print(f"Successful: {len(successful)}")
2686
+ print(f"Failed: {len(failed)}")
2687
+ print()
2688
+
2689
+ if successful:
2690
+ print("Successfully processed entities:")
2691
+ for entity_name, presentation_id in successful:
2692
+ print(f" ✓ {entity_name}")
2693
+ print(f" View at: https://docs.google.com/presentation/d/{presentation_id}/edit")
2694
+ print()
2695
+
2696
+ if failed:
2697
+ print("Failed entities:")
2698
+ for entity_name, error in failed:
2699
+ print(f" ✗ {entity_name}: {error}")
2700
+ print()
2701
+
2702
+ print("=" * 80)
2703
+
2704
+ return {'successful': successful, 'failed': failed}
2705
+
2706
+
2707
+ def main():
2708
+ """
2709
+ Main function to process all spreadsheets in the input folder and generate presentations (CLI entry point).
2710
+ """
2711
+ parser = argparse.ArgumentParser(
2712
+ description='Generate Google Slides presentations from Google Sheets for entities with generate=Y in entities.csv'
2713
+ )
2714
+ parser.add_argument(
2715
+ '--shared-drive-url',
2716
+ required=True,
2717
+ help='Shared Drive root URL or ID that contains L1/L2 data, templates and entities file.',
2718
+ )
2719
+ parser.add_argument(
2720
+ '--service-account-credentials',
2721
+ default=None,
2722
+ help='Path to the service account JSON key file.',
2723
+ )
2724
+ args = parser.parse_args()
2725
+
2726
+ print("Google Slide Automator - Report Generation")
2727
+ print("=" * 80)
2728
+
2729
+ try:
2730
+ # Get credentials
2731
+ print("Authenticating...")
2732
+ creds = get_oauth_credentials(service_account_credentials=args.service_account_credentials)
2733
+
2734
+ layout = resolve_layout(args.shared_drive_url, creds)
2735
+
2736
+ # Call the main function
2737
+ generate_report(
2738
+ creds=creds,
2739
+ layout=layout
2740
+ )
2741
+
2742
+ except ValueError as e:
2743
+ print(f"\nError: {e}")
2744
+ except FileNotFoundError as e:
2745
+ print(f"\nError: {e}")
2746
+ if "credentials file" in str(e):
2747
+ print("\nTo set up service account credentials:")
2748
+ print("1. Go to Google Cloud Console (https://console.cloud.google.com/)")
2749
+ print("2. Create a new project or select an existing one")
2750
+ print("3. Enable Google Sheets API, Google Slides API, and Google Drive API")
2751
+ print("4. Go to 'Credentials' → 'Create Credentials' → 'Service account'")
2752
+ print("5. Create a service account and download the JSON key file")
2753
+ from .auth import PROJECT_ROOT as AUTH_PROJECT_ROOT
2754
+ print(f"6. Save the JSON key file as 'service-account-credentials.json' in: {AUTH_PROJECT_ROOT}")
2755
+ except Exception as e:
2756
+ print(f"\nError: {e}")
2757
+ import traceback
2758
+ traceback.print_exc()
2759
+
2760
+ if __name__ == "__main__":
2761
+ main()