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.
- gslides_automator/__init__.py +43 -0
- gslides_automator/__main__.py +7 -0
- gslides_automator/auth.py +103 -0
- gslides_automator/cli.py +96 -0
- gslides_automator/drive_layout.py +233 -0
- gslides_automator/generate_data.py +937 -0
- gslides_automator/generate_report.py +2761 -0
- gslides_automator-0.4.0.dist-info/METADATA +131 -0
- gslides_automator-0.4.0.dist-info/RECORD +13 -0
- gslides_automator-0.4.0.dist-info/WHEEL +5 -0
- gslides_automator-0.4.0.dist-info/entry_points.txt +2 -0
- gslides_automator-0.4.0.dist-info/licenses/LICENSE.txt +21 -0
- gslides_automator-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|