@roomi-fields/notebooklm-mcp 1.3.6 → 1.5.1

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.
Files changed (284) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +71 -34
  3. package/deployment/INDEX.md +292 -0
  4. package/deployment/PACKAGE-FILES.txt +180 -0
  5. package/deployment/QUICK-START.md +100 -0
  6. package/deployment/docs/01-INSTALL.md +611 -0
  7. package/deployment/docs/02-CONFIGURATION.md +404 -0
  8. package/deployment/docs/03-API.md +1691 -0
  9. package/deployment/docs/04-N8N-INTEGRATION.md +373 -0
  10. package/deployment/docs/05-TROUBLESHOOTING.md +429 -0
  11. package/deployment/docs/06-NOTEBOOK-LIBRARY.md +692 -0
  12. package/deployment/docs/07-AUTO-DISCOVERY.md +236 -0
  13. package/deployment/docs/08-WSL-USAGE.md +363 -0
  14. package/deployment/docs/09-MULTI-INTERFACE.md +293 -0
  15. package/deployment/docs/10-CONTENT-MANAGEMENT.md +421 -0
  16. package/deployment/docs/11-MULTI-ACCOUNT.md +295 -0
  17. package/deployment/docs/README.md +207 -0
  18. package/deployment/scripts/README.md +564 -0
  19. package/deployment/scripts/install.ps1 +114 -0
  20. package/deployment/scripts/setup-auth.ps1 +217 -0
  21. package/deployment/scripts/start-server.ps1 +72 -0
  22. package/deployment/scripts/stop-server.ps1 +51 -0
  23. package/deployment/scripts/test-api.ps1 +651 -0
  24. package/deployment/scripts/test-auth.ps1 +323 -0
  25. package/deployment/scripts/test-auto-discovery.ps1 +295 -0
  26. package/deployment/scripts/test-cors.ps1 +398 -0
  27. package/deployment/scripts/test-errors.ps1 +581 -0
  28. package/deployment/scripts/test-server.ps1 +140 -0
  29. package/deployment/scripts/test-sessions.ps1 +426 -0
  30. package/deployment/scripts/test-validation.ps1 +299 -0
  31. package/dist/accounts/account-manager.d.ts +163 -0
  32. package/dist/accounts/account-manager.d.ts.map +1 -0
  33. package/dist/accounts/account-manager.js +614 -0
  34. package/dist/accounts/account-manager.js.map +1 -0
  35. package/dist/accounts/auto-login-manager.d.ts +62 -0
  36. package/dist/accounts/auto-login-manager.d.ts.map +1 -0
  37. package/dist/accounts/auto-login-manager.js +537 -0
  38. package/dist/accounts/auto-login-manager.js.map +1 -0
  39. package/dist/accounts/crypto.d.ts +45 -0
  40. package/dist/accounts/crypto.d.ts.map +1 -0
  41. package/dist/accounts/crypto.js +138 -0
  42. package/dist/accounts/crypto.js.map +1 -0
  43. package/dist/accounts/index.d.ts +14 -0
  44. package/dist/accounts/index.d.ts.map +1 -0
  45. package/dist/accounts/index.js +14 -0
  46. package/dist/accounts/index.js.map +1 -0
  47. package/dist/accounts/types.d.ts +103 -0
  48. package/dist/accounts/types.d.ts.map +1 -0
  49. package/dist/accounts/types.js +7 -0
  50. package/dist/accounts/types.js.map +1 -0
  51. package/dist/auth/auth-manager.d.ts +9 -2
  52. package/dist/auth/auth-manager.d.ts.map +1 -1
  53. package/dist/auth/auth-manager.js +60 -6
  54. package/dist/auth/auth-manager.js.map +1 -1
  55. package/dist/auto-discovery/auto-discovery.d.ts.map +1 -1
  56. package/dist/auto-discovery/auto-discovery.js +2 -1
  57. package/dist/auto-discovery/auto-discovery.js.map +1 -1
  58. package/dist/cli/accounts.d.ts +13 -0
  59. package/dist/cli/accounts.d.ts.map +1 -0
  60. package/dist/cli/accounts.js +195 -0
  61. package/dist/cli/accounts.js.map +1 -0
  62. package/dist/config.d.ts +1 -0
  63. package/dist/config.d.ts.map +1 -1
  64. package/dist/config.js +24 -0
  65. package/dist/config.js.map +1 -1
  66. package/dist/content/content-generator.d.ts +153 -0
  67. package/dist/content/content-generator.d.ts.map +1 -0
  68. package/dist/content/content-generator.js +637 -0
  69. package/dist/content/content-generator.js.map +1 -0
  70. package/dist/content/content-manager.d.ts +364 -0
  71. package/dist/content/content-manager.d.ts.map +1 -0
  72. package/dist/content/content-manager.js +3841 -0
  73. package/dist/content/content-manager.js.map +1 -0
  74. package/dist/content/content-templates.d.ts +183 -0
  75. package/dist/content/content-templates.d.ts.map +1 -0
  76. package/dist/content/content-templates.js +719 -0
  77. package/dist/content/content-templates.js.map +1 -0
  78. package/dist/content/index.d.ts +14 -0
  79. package/dist/content/index.d.ts.map +1 -0
  80. package/dist/content/index.js +14 -0
  81. package/dist/content/index.js.map +1 -0
  82. package/dist/content/types.d.ts +285 -0
  83. package/dist/content/types.d.ts.map +1 -0
  84. package/dist/content/types.js +10 -0
  85. package/dist/content/types.js.map +1 -0
  86. package/dist/errors.d.ts +1 -1
  87. package/dist/errors.d.ts.map +1 -1
  88. package/dist/errors.js.map +1 -1
  89. package/dist/http-wrapper.d.ts +7 -0
  90. package/dist/http-wrapper.d.ts.map +1 -1
  91. package/dist/http-wrapper.js +449 -29
  92. package/dist/http-wrapper.js.map +1 -1
  93. package/dist/i18n/en.json +120 -0
  94. package/dist/i18n/fr.json +120 -0
  95. package/dist/i18n/index.d.ts +168 -0
  96. package/dist/i18n/index.d.ts.map +1 -0
  97. package/dist/i18n/index.js +213 -0
  98. package/dist/i18n/index.js.map +1 -0
  99. package/dist/index.js +26 -2
  100. package/dist/index.js.map +1 -1
  101. package/dist/library/notebook-library.d.ts +4 -0
  102. package/dist/library/notebook-library.d.ts.map +1 -1
  103. package/dist/library/notebook-library.js +20 -3
  104. package/dist/library/notebook-library.js.map +1 -1
  105. package/dist/session/browser-session.d.ts +35 -8
  106. package/dist/session/browser-session.d.ts.map +1 -1
  107. package/dist/session/browser-session.js +243 -28
  108. package/dist/session/browser-session.js.map +1 -1
  109. package/dist/session/session-manager.d.ts +6 -0
  110. package/dist/session/session-manager.d.ts.map +1 -1
  111. package/dist/session/session-manager.js +46 -14
  112. package/dist/session/session-manager.js.map +1 -1
  113. package/dist/session/shared-context-manager.d.ts +3 -3
  114. package/dist/session/shared-context-manager.d.ts.map +1 -1
  115. package/dist/session/shared-context-manager.js +10 -7
  116. package/dist/session/shared-context-manager.js.map +1 -1
  117. package/dist/stdio-http-proxy.d.ts +24 -0
  118. package/dist/stdio-http-proxy.d.ts.map +1 -0
  119. package/dist/stdio-http-proxy.js +592 -0
  120. package/dist/stdio-http-proxy.js.map +1 -0
  121. package/dist/tools/index.d.ts +106 -1
  122. package/dist/tools/index.d.ts.map +1 -1
  123. package/dist/tools/index.js +1028 -7
  124. package/dist/tools/index.js.map +1 -1
  125. package/dist/types.d.ts +81 -17
  126. package/dist/types.d.ts.map +1 -1
  127. package/dist/utils/citation-extractor.d.ts +66 -0
  128. package/dist/utils/citation-extractor.d.ts.map +1 -0
  129. package/dist/utils/citation-extractor.js +492 -0
  130. package/dist/utils/citation-extractor.js.map +1 -0
  131. package/dist/utils/page-utils.d.ts +8 -0
  132. package/dist/utils/page-utils.d.ts.map +1 -1
  133. package/dist/utils/page-utils.js +112 -8
  134. package/dist/utils/page-utils.js.map +1 -1
  135. package/docs/ADDING_A_LANGUAGE.md +209 -0
  136. package/docs/ARCHITECTURE_MIGRATION_STUDY.md +894 -0
  137. package/docs/CHROME_PROFILE_LIMITATION.md +15 -1
  138. package/docs/MULTI_ACCOUNT_SYSTEM.md +304 -0
  139. package/package.json +15 -12
  140. package/scripts/archive/add-and-activate-notebook.ps1 +31 -0
  141. package/scripts/archive/add-new-notebook.ps1 +25 -0
  142. package/scripts/archive/add-rom1pey.ps1 +2 -0
  143. package/scripts/archive/add-rpmonster.ps1 +2 -0
  144. package/scripts/archive/add-source-debug.ps1 +11 -0
  145. package/scripts/archive/add-source-e2e.ps1 +28 -0
  146. package/scripts/archive/add-source-visible.ps1 +11 -0
  147. package/scripts/archive/add-test-notebook.ps1 +13 -0
  148. package/scripts/archive/add-test-source.ps1 +50 -0
  149. package/scripts/archive/capture-screen.ps1 +11 -0
  150. package/scripts/archive/change-language.mjs +45 -0
  151. package/scripts/archive/change-language.ts +44 -0
  152. package/scripts/archive/check-account.ps1 +19 -0
  153. package/scripts/archive/check-notebook-2.ps1 +8 -0
  154. package/scripts/archive/check-test-notebook.ps1 +11 -0
  155. package/scripts/archive/create-notebook-auto.ps1 +31 -0
  156. package/scripts/archive/create-notebook.ps1 +8 -0
  157. package/scripts/archive/create-rom1pey-notebook.ps1 +19 -0
  158. package/scripts/archive/create-rom1pey.ps1 +8 -0
  159. package/scripts/archive/create-test-notebook-fresh.ps1 +21 -0
  160. package/scripts/archive/create-test-notebook.ps1 +16 -0
  161. package/scripts/archive/debug-add-source-auto.ps1 +29 -0
  162. package/scripts/archive/debug-add-source.ps1 +19 -0
  163. package/scripts/archive/debug-add-text-source.ps1 +47 -0
  164. package/scripts/archive/debug-home.ps1 +10 -0
  165. package/scripts/archive/debug-selectors.ps1 +55 -0
  166. package/scripts/archive/debug-sources-panel.ps1 +22 -0
  167. package/scripts/archive/debug-ui.ps1 +17 -0
  168. package/scripts/archive/discover-home.ps1 +26 -0
  169. package/scripts/archive/kill-automation-chrome.ps1 +37 -0
  170. package/scripts/archive/list-my-notebooks.ps1 +27 -0
  171. package/scripts/archive/navigate-home-visible.ps1 +23 -0
  172. package/scripts/archive/navigate-home.ps1 +15 -0
  173. package/scripts/archive/run-e2e-english.ps1 +111 -0
  174. package/scripts/archive/run-e2e-rom1pey-v2.ps1 +122 -0
  175. package/scripts/archive/run-e2e-rom1pey.ps1 +117 -0
  176. package/scripts/archive/setup-english-test.ps1 +36 -0
  177. package/scripts/archive/setup-test-notebook.ps1 +71 -0
  178. package/scripts/archive/simple-add-source.ps1 +14 -0
  179. package/scripts/archive/t10.ps1 +2 -0
  180. package/scripts/archive/t20.ps1 +4 -0
  181. package/scripts/archive/t30.ps1 +9 -0
  182. package/scripts/archive/t31.ps1 +11 -0
  183. package/scripts/archive/t32.ps1 +6 -0
  184. package/scripts/archive/t39.ps1 +5 -0
  185. package/scripts/archive/t40.ps1 +5 -0
  186. package/scripts/archive/t53.ps1 +12 -0
  187. package/scripts/archive/t54.ps1 +12 -0
  188. package/scripts/archive/t55.ps1 +11 -0
  189. package/scripts/archive/t9.ps1 +1 -0
  190. package/scripts/archive/test-access.ps1 +28 -0
  191. package/scripts/archive/test-add-delete-source.ps1 +64 -0
  192. package/scripts/archive/test-add-source-visible.ps1 +16 -0
  193. package/scripts/archive/test-add-source.ps1 +19 -0
  194. package/scripts/archive/test-add-text-debug.ps1 +28 -0
  195. package/scripts/archive/test-add-text-source.ps1 +8 -0
  196. package/scripts/archive/test-add-url-source.ps1 +7 -0
  197. package/scripts/archive/test-ask-ascii.ps1 +20 -0
  198. package/scripts/archive/test-ask-cnv.ps1 +20 -0
  199. package/scripts/archive/test-ask-headed.ps1 +51 -0
  200. package/scripts/archive/test-ask-ifs.ps1 +16 -0
  201. package/scripts/archive/test-ask-now.ps1 +24 -0
  202. package/scripts/archive/test-ask-real.ps1 +19 -0
  203. package/scripts/archive/test-ask-visible.ps1 +20 -0
  204. package/scripts/archive/test-create-notebook.ps1 +8 -0
  205. package/scripts/archive/test-create-then-add.ps1 +17 -0
  206. package/scripts/archive/test-delete-source.ps1 +41 -0
  207. package/scripts/archive/test-e2e-notebook.ps1 +21 -0
  208. package/scripts/archive/test-english-notebook.ps1 +20 -0
  209. package/scripts/archive/test-english.ps1 +7 -0
  210. package/scripts/archive/test-full-custom-instructions.ps1 +40 -0
  211. package/scripts/archive/test-full-infographic.ps1 +34 -0
  212. package/scripts/archive/test-full-language.ps1 +21 -0
  213. package/scripts/archive/test-full-presentation.ps1 +85 -0
  214. package/scripts/archive/test-full-report.ps1 +34 -0
  215. package/scripts/archive/test-full-source-selection.ps1 +35 -0
  216. package/scripts/archive/test-full-video-brief.ps1 +22 -0
  217. package/scripts/archive/test-full-video-explainer.ps1 +22 -0
  218. package/scripts/archive/test-full-video-styles.ps1 +37 -0
  219. package/scripts/archive/test-generate-report.ps1 +15 -0
  220. package/scripts/archive/test-generate-study-guide.ps1 +11 -0
  221. package/scripts/archive/test-headed-ask.ps1 +13 -0
  222. package/scripts/archive/test-headed-now.ps1 +9 -0
  223. package/scripts/archive/test-headed.ps1 +9 -0
  224. package/scripts/archive/test-hello.ps1 +7 -0
  225. package/scripts/archive/test-i18n-studio.ps1 +8 -0
  226. package/scripts/archive/test-i18n.ps1 +7 -0
  227. package/scripts/archive/test-manual-headed.ps1 +26 -0
  228. package/scripts/archive/test-mathieu-quota.ps1 +8 -0
  229. package/scripts/archive/test-notebook-1.ps1 +10 -0
  230. package/scripts/archive/test-notebook-2-sources.ps1 +12 -0
  231. package/scripts/archive/test-notebook1.ps1 +14 -0
  232. package/scripts/archive/test-personal-notebook.ps1 +14 -0
  233. package/scripts/archive/test-rate-limit.ps1 +19 -0
  234. package/scripts/archive/test-real-ask.ps1 +50 -0
  235. package/scripts/archive/test-real-ask2.ps1 +30 -0
  236. package/scripts/archive/test-rom1pey.ps1 +7 -0
  237. package/scripts/archive/test-rotation-complete.ps1 +14 -0
  238. package/scripts/archive/test-rotation.ps1 +8 -0
  239. package/scripts/archive/test-show-browser.ps1 +39 -0
  240. package/scripts/archive/test-update-notebook.ps1 +4 -0
  241. package/scripts/archive/verify-language-slow.ps1 +21 -0
  242. package/scripts/archive/verify-language.ps1 +15 -0
  243. package/scripts/check-server.ps1 +46 -0
  244. package/scripts/mcp-wsl-helper.sh +146 -0
  245. package/scripts/start-server.ps1 +94 -0
  246. package/scripts/stop-server.ps1 +30 -0
  247. package/scripts/switch-account-language.sh +191 -0
  248. package/scripts/test-account.ps1 +58 -0
  249. package/dist/__tests__/cleanup-manager.test.d.ts +0 -2
  250. package/dist/__tests__/cleanup-manager.test.d.ts.map +0 -1
  251. package/dist/__tests__/cleanup-manager.test.js +0 -341
  252. package/dist/__tests__/cleanup-manager.test.js.map +0 -1
  253. package/dist/__tests__/config-parsing.test.d.ts +0 -2
  254. package/dist/__tests__/config-parsing.test.d.ts.map +0 -1
  255. package/dist/__tests__/config-parsing.test.js +0 -338
  256. package/dist/__tests__/config-parsing.test.js.map +0 -1
  257. package/dist/__tests__/config.test.d.ts +0 -2
  258. package/dist/__tests__/config.test.d.ts.map +0 -1
  259. package/dist/__tests__/config.test.js +0 -267
  260. package/dist/__tests__/config.test.js.map +0 -1
  261. package/dist/__tests__/errors.test.d.ts +0 -2
  262. package/dist/__tests__/errors.test.d.ts.map +0 -1
  263. package/dist/__tests__/errors.test.js +0 -166
  264. package/dist/__tests__/errors.test.js.map +0 -1
  265. package/dist/__tests__/logger.test.d.ts +0 -2
  266. package/dist/__tests__/logger.test.d.ts.map +0 -1
  267. package/dist/__tests__/logger.test.js +0 -324
  268. package/dist/__tests__/logger.test.js.map +0 -1
  269. package/dist/__tests__/page-utils.test.d.ts +0 -2
  270. package/dist/__tests__/page-utils.test.d.ts.map +0 -1
  271. package/dist/__tests__/page-utils.test.js +0 -349
  272. package/dist/__tests__/page-utils.test.js.map +0 -1
  273. package/dist/__tests__/setup-verification.test.d.ts +0 -2
  274. package/dist/__tests__/setup-verification.test.d.ts.map +0 -1
  275. package/dist/__tests__/setup-verification.test.js +0 -15
  276. package/dist/__tests__/setup-verification.test.js.map +0 -1
  277. package/dist/__tests__/stealth-utils.test.d.ts +0 -2
  278. package/dist/__tests__/stealth-utils.test.d.ts.map +0 -1
  279. package/dist/__tests__/stealth-utils.test.js +0 -413
  280. package/dist/__tests__/stealth-utils.test.js.map +0 -1
  281. package/dist/__tests__/types.test.d.ts +0 -2
  282. package/dist/__tests__/types.test.d.ts.map +0 -1
  283. package/dist/__tests__/types.test.js +0 -461
  284. package/dist/__tests__/types.test.js.map +0 -1
@@ -0,0 +1,3841 @@
1
+ /**
2
+ * Content Manager
3
+ *
4
+ * Handles NotebookLM content operations:
5
+ * - Source/document upload
6
+ * - Content generation (audio, briefing, study guides, etc.)
7
+ * - Content listing and download
8
+ *
9
+ * Uses Playwright to interact with NotebookLM's web interface.
10
+ */
11
+ import path from 'path';
12
+ import { existsSync } from 'fs';
13
+ import { randomDelay, realisticClick, humanType } from '../utils/stealth-utils.js';
14
+ import { log } from '../utils/logger.js';
15
+ import { CONFIG } from '../config.js';
16
+ import { waitForLatestAnswer, snapshotAllResponses, isErrorMessage } from '../utils/page-utils.js';
17
+ import { setLocale, tAll } from '../i18n/index.js';
18
+ // Initialize i18n with configured locale
19
+ setLocale(CONFIG.uiLocale);
20
+ /**
21
+ * Build selectors for all supported locales
22
+ * @param template Selector template with {text} placeholder
23
+ * @param category i18n category (e.g., 'tabs', 'buttons')
24
+ * @param key i18n key within the category
25
+ * @returns Array of selectors for all locales
26
+ */
27
+ function i18nSelectors(template, category, key) {
28
+ const texts = tAll(category, key);
29
+ return texts.map((text) => template.replace('{text}', text));
30
+ }
31
+ import { ContentGenerator } from './content-generator.js';
32
+ // Note: UI selectors are defined inline in methods for better maintainability
33
+ // as NotebookLM's UI may change frequently
34
+ export class ContentManager {
35
+ page;
36
+ constructor(page) {
37
+ this.page = page;
38
+ }
39
+ // ============================================================================
40
+ // Source/Document Upload
41
+ // ============================================================================
42
+ /**
43
+ * Add a source to the current notebook
44
+ */
45
+ async addSource(input) {
46
+ log.info(`📄 Adding source: ${input.type}`);
47
+ // CRITICAL: Capture initial URL BEFORE any action
48
+ // NotebookLM may redirect when clicking "Add source" button!
49
+ const initialUrl = this.page.url();
50
+ const expectedNotebookUuid = initialUrl.match(/notebook\/([a-f0-9-]+)/)?.[1];
51
+ log.info(` 🎯 Target notebook UUID: ${expectedNotebookUuid || 'NOT FOUND'}`);
52
+ try {
53
+ // Click "Add source" button
54
+ await this.clickAddSource();
55
+ // Wait for upload dialog
56
+ await this.waitForUploadDialog();
57
+ // Select upload type and upload (pass expectedNotebookUuid for redirect detection)
58
+ switch (input.type) {
59
+ case 'file':
60
+ return await this.uploadFile(input, expectedNotebookUuid);
61
+ case 'url':
62
+ return await this.uploadUrl(input, expectedNotebookUuid);
63
+ case 'text':
64
+ return await this.uploadText(input, expectedNotebookUuid);
65
+ case 'google_drive':
66
+ return await this.uploadGoogleDrive(input, expectedNotebookUuid);
67
+ case 'youtube':
68
+ return await this.uploadYouTube(input, expectedNotebookUuid);
69
+ default:
70
+ return { success: false, error: `Unsupported source type: ${input.type}` };
71
+ }
72
+ }
73
+ catch (error) {
74
+ const errorMsg = error instanceof Error ? error.message : String(error);
75
+ log.error(`❌ Failed to add source: ${errorMsg}`);
76
+ return { success: false, error: errorMsg };
77
+ }
78
+ }
79
+ /**
80
+ * Click the "Add source" button
81
+ */
82
+ async clickAddSource() {
83
+ // First, ensure we're on the Sources panel (left panel)
84
+ await this.ensureSourcesPanel();
85
+ // Wait for panel to be ready (increased for reliability)
86
+ await randomDelay(800, 1200);
87
+ // Check if a dialog is already open and close it first
88
+ try {
89
+ const existingDialog = this.page.locator('[role="dialog"]');
90
+ if (await existingDialog.isVisible({ timeout: 500 })) {
91
+ log.info(' ⚠️ Dialog already open, closing first...');
92
+ await this.page.keyboard.press('Escape');
93
+ await randomDelay(500, 800);
94
+ }
95
+ }
96
+ catch {
97
+ // No existing dialog, continue
98
+ }
99
+ const addSourceSelectors = [
100
+ // NotebookLM current UI (Dec 2024) - aria-label based (most reliable)
101
+ 'button[aria-label="Add source"]',
102
+ 'button[aria-label="Ajouter une source"]', // French
103
+ 'button[aria-label*="Add source"]',
104
+ 'button[aria-label*="Ajouter une source"]',
105
+ 'button[aria-label*="add source" i]',
106
+ // Icon button with "add" icon specifically
107
+ 'button:has(mat-icon:has-text("add"))',
108
+ 'button:has(mat-icon:has-text("add_circle"))',
109
+ // Text-based patterns (bilingual via i18n)
110
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'addSource'),
111
+ // FAB buttons (floating action button for adding)
112
+ 'button.mat-fab',
113
+ 'button.mat-mini-fab',
114
+ // REMOVED generic selectors that match ANY icon button
115
+ ];
116
+ log.info(` 🔍 Looking for Add source button...`);
117
+ for (const selector of addSourceSelectors) {
118
+ try {
119
+ const button = this.page.locator(selector).first();
120
+ if (await button.isVisible({ timeout: 1000 })) {
121
+ log.info(` ✅ Found add source button: ${selector}`);
122
+ await realisticClick(this.page, selector, true);
123
+ await randomDelay(500, 1000);
124
+ return;
125
+ }
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ }
131
+ // Fallback: Try to find any button with "add" aria-label
132
+ log.info(` 🔍 Trying fallback: buttons with add-related aria-label...`);
133
+ try {
134
+ const addButtons = await this.page.locator('button[aria-label]').all();
135
+ for (const btn of addButtons) {
136
+ const ariaLabel = await btn.getAttribute('aria-label');
137
+ if (ariaLabel && /add|ajouter|upload|source/i.test(ariaLabel)) {
138
+ if (await btn.isVisible()) {
139
+ log.info(` ✅ Found add button via fallback: aria-label="${ariaLabel}"`);
140
+ await btn.click();
141
+ await randomDelay(500, 1000);
142
+ return;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ catch {
148
+ // Continue to debug
149
+ }
150
+ // Debug: log page content to help identify the correct selector
151
+ await this.debugPageContent();
152
+ throw new Error('Could not find "Add source" button');
153
+ }
154
+ /**
155
+ * Ensure we're on the Sources panel
156
+ */
157
+ async ensureSourcesPanel() {
158
+ log.info(` 📑 Ensuring Sources panel is active...`);
159
+ // First, close any open dialogs that might be blocking
160
+ try {
161
+ const openDialog = this.page.locator('[role="dialog"]');
162
+ if (await openDialog.isVisible({ timeout: 500 })) {
163
+ log.info(` ⚠️ Closing blocking dialog first...`);
164
+ await this.page.keyboard.press('Escape');
165
+ await randomDelay(300, 500);
166
+ }
167
+ }
168
+ catch {
169
+ /* no dialog */
170
+ }
171
+ const sourcesTabSelectors = [
172
+ // NotebookLM current UI (Dec 2024) - MDC tabs (bilingual FR/EN via i18n)
173
+ ...i18nSelectors('div.mdc-tab:has-text("{text}")', 'tabs', 'sources'),
174
+ ...i18nSelectors('.mat-mdc-tab:has-text("{text}")', 'tabs', 'sources'),
175
+ ...i18nSelectors('[role="tab"]:has-text("{text}")', 'tabs', 'sources'),
176
+ // First tab in the tab list (Sources is typically first)
177
+ '.mat-mdc-tab-list .mdc-tab:first-child',
178
+ ];
179
+ for (const selector of sourcesTabSelectors) {
180
+ try {
181
+ const tab = this.page.locator(selector).first();
182
+ if (await tab.isVisible({ timeout: 2000 })) {
183
+ // Check if already selected using multiple methods
184
+ const isSelected = await tab.getAttribute('aria-selected');
185
+ const hasActiveClass = (await tab.getAttribute('class'))?.includes('mdc-tab--active');
186
+ if (isSelected !== 'true' && !hasActiveClass) {
187
+ log.info(` 📑 Clicking Sources tab: ${selector}`);
188
+ // Use shorter timeout and force click if needed
189
+ await tab.click({ timeout: 5000 });
190
+ await randomDelay(500, 1000);
191
+ }
192
+ else {
193
+ log.info(` ✅ Sources tab already active`);
194
+ }
195
+ return;
196
+ }
197
+ }
198
+ catch (e) {
199
+ log.warning(` ⚠️ Selector failed: ${selector} - ${e}`);
200
+ continue;
201
+ }
202
+ }
203
+ // Sources panel might already be visible or not use tabs
204
+ log.info(` ℹ️ No Sources tab found, assuming already on sources panel`);
205
+ // Take debug screenshot to help identify the correct selectors
206
+ try {
207
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-sources-panel.png');
208
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
209
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
210
+ // Log all tab elements to help find the correct selector
211
+ const allTabs = await this.page.locator('[role="tab"], .mdc-tab, .mat-tab-label').all();
212
+ log.info(` 🔍 Found ${allTabs.length} tab-like elements:`);
213
+ for (let i = 0; i < Math.min(allTabs.length, 10); i++) {
214
+ const tab = allTabs[i];
215
+ const text = await tab.textContent();
216
+ const ariaLabel = await tab.getAttribute('aria-label');
217
+ const classes = await tab.getAttribute('class');
218
+ log.info(` Tab[${i}]: text="${text?.trim()}", aria="${ariaLabel}", class="${classes?.substring(0, 50)}..."`);
219
+ }
220
+ }
221
+ catch (e) {
222
+ log.warning(` ⚠️ Could not capture debug info: ${e}`);
223
+ }
224
+ }
225
+ /**
226
+ * Debug helper to log page content for selector debugging
227
+ */
228
+ async debugPageContent() {
229
+ try {
230
+ // Log all buttons on the page
231
+ const buttons = await this.page.locator('button').all();
232
+ log.info(` 🔍 DEBUG: Found ${buttons.length} buttons on page`);
233
+ for (let i = 0; i < Math.min(buttons.length, 10); i++) {
234
+ const btn = buttons[i];
235
+ const ariaLabel = await btn.getAttribute('aria-label');
236
+ const text = await btn.textContent();
237
+ const classes = await btn.getAttribute('class');
238
+ log.info(` 🔍 Button[${i}]: aria="${ariaLabel}", text="${text?.trim()}", class="${classes}"`);
239
+ }
240
+ // Take a screenshot for debugging
241
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-add-source.png');
242
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
243
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
244
+ }
245
+ catch (e) {
246
+ log.warning(` ⚠️ Debug failed: ${e}`);
247
+ }
248
+ }
249
+ /**
250
+ * Wait for upload dialog to appear
251
+ */
252
+ async waitForUploadDialog() {
253
+ const dialogSelectors = [
254
+ '[role="dialog"]',
255
+ '.upload-dialog',
256
+ '.modal',
257
+ '[data-dialog="upload"]',
258
+ ];
259
+ for (const selector of dialogSelectors) {
260
+ try {
261
+ await this.page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
262
+ log.info(` ✅ Upload dialog appeared`);
263
+ return;
264
+ }
265
+ catch {
266
+ continue;
267
+ }
268
+ }
269
+ // Dialog might not be a separate element - continue anyway
270
+ log.info(` ℹ️ No explicit dialog, continuing with upload...`);
271
+ }
272
+ /**
273
+ * Upload a local file
274
+ */
275
+ async uploadFile(input, expectedNotebookUuid) {
276
+ if (!input.filePath) {
277
+ return { success: false, error: 'File path is required' };
278
+ }
279
+ // Path traversal protection: resolve and validate the path
280
+ const resolvedPath = path.resolve(input.filePath);
281
+ const allowedDir = path.resolve(CONFIG.dataDir);
282
+ // Allow files from dataDir or current working directory
283
+ const cwd = path.resolve(process.cwd());
284
+ const isAllowed = resolvedPath.startsWith(allowedDir) || resolvedPath.startsWith(cwd);
285
+ if (!isAllowed) {
286
+ log.warning(` ⚠️ Path traversal attempt blocked: ${input.filePath}`);
287
+ return {
288
+ success: false,
289
+ error: 'File path not allowed: must be within data directory or current working directory',
290
+ };
291
+ }
292
+ if (!existsSync(resolvedPath)) {
293
+ return { success: false, error: `File not found: ${input.filePath}` };
294
+ }
295
+ log.info(` 📁 Uploading file: ${path.basename(resolvedPath)}`);
296
+ try {
297
+ // Click on file upload option (bilingual via i18n)
298
+ const fileTypeSelectors = [
299
+ ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'uploadFiles'),
300
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'upload'),
301
+ '[data-type="file"]',
302
+ ];
303
+ for (const selector of fileTypeSelectors) {
304
+ try {
305
+ const btn = this.page.locator(selector).first();
306
+ if (await btn.isVisible({ timeout: 1000 })) {
307
+ await btn.click();
308
+ await randomDelay(300, 500);
309
+ break;
310
+ }
311
+ }
312
+ catch {
313
+ continue;
314
+ }
315
+ }
316
+ // Find file input and upload
317
+ const fileInput = await this.page.waitForSelector('input[type="file"]', {
318
+ state: 'attached',
319
+ timeout: 5000,
320
+ });
321
+ if (!fileInput) {
322
+ throw new Error('File input not found');
323
+ }
324
+ await fileInput.setInputFiles(input.filePath);
325
+ log.info(` ✅ File selected`);
326
+ // Wait for upload to start
327
+ await randomDelay(1000, 2000);
328
+ // Click upload/confirm button
329
+ await this.clickUploadButton();
330
+ // Wait for processing
331
+ const result = await this.waitForSourceProcessing(input.title || path.basename(input.filePath), undefined, expectedNotebookUuid);
332
+ return result;
333
+ }
334
+ catch (error) {
335
+ const errorMsg = error instanceof Error ? error.message : String(error);
336
+ return { success: false, error: `File upload failed: ${errorMsg}` };
337
+ }
338
+ }
339
+ /**
340
+ * Upload from URL
341
+ */
342
+ async uploadUrl(input, expectedNotebookUuid) {
343
+ if (!input.url) {
344
+ return { success: false, error: 'URL is required' };
345
+ }
346
+ log.info(` 🌐 Adding URL: ${input.url}`);
347
+ try {
348
+ // Click on URL/Website option (bilingual selectors)
349
+ const urlTypeSelectors = [
350
+ ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'website'),
351
+ ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'link'),
352
+ ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'url'),
353
+ '[data-type="url"]',
354
+ '[aria-label*="website"]',
355
+ '[aria-label*="URL"]',
356
+ ];
357
+ log.info(` 🔍 Looking for URL option...`);
358
+ let foundUrlOption = false;
359
+ for (const selector of urlTypeSelectors) {
360
+ try {
361
+ const btn = this.page.locator(selector).first();
362
+ if (await btn.isVisible({ timeout: 500 })) {
363
+ log.info(` ✅ Found URL option: ${selector}`);
364
+ await btn.click();
365
+ await randomDelay(300, 500);
366
+ foundUrlOption = true;
367
+ break;
368
+ }
369
+ }
370
+ catch {
371
+ continue;
372
+ }
373
+ }
374
+ if (!foundUrlOption) {
375
+ log.info(` ℹ️ No URL option button found, looking for input directly`);
376
+ // DEBUG: List all visible buttons in the page
377
+ try {
378
+ const buttons = await this.page.locator('button').all();
379
+ log.info(` 🔍 DEBUG: Found ${buttons.length} buttons total`);
380
+ for (let i = 0; i < Math.min(buttons.length, 15); i++) {
381
+ const btn = buttons[i];
382
+ const visible = await btn.isVisible().catch(() => false);
383
+ if (visible) {
384
+ const text = await btn.textContent().catch(() => '');
385
+ const ariaLabel = await btn.getAttribute('aria-label').catch(() => '');
386
+ log.info(` 🔍 Button[${i}]: text="${text?.trim()}", aria="${ariaLabel}"`);
387
+ }
388
+ }
389
+ }
390
+ catch (e) {
391
+ log.warning(` ⚠️ Could not list buttons: ${e}`);
392
+ }
393
+ }
394
+ // Wait for input to appear after clicking option
395
+ await randomDelay(500, 1000);
396
+ // Find URL input (can be input OR textarea) - bilingual selectors
397
+ log.info(` 🔍 Looking for URL input...`);
398
+ const urlInputSelectors = [
399
+ // i18n placeholder selectors
400
+ ...i18nSelectors('input[placeholder*="{text}"]', 'placeholders', 'pasteUrl'),
401
+ ...i18nSelectors('textarea[placeholder*="{text}"]', 'placeholders', 'pasteUrl'),
402
+ ...i18nSelectors('input[placeholder*="{text}"]', 'placeholders', 'enterUrl'),
403
+ ...i18nSelectors('textarea[placeholder*="{text}"]', 'placeholders', 'enterUrl'),
404
+ ...i18nSelectors('input[placeholder*="{text}"]', 'placeholders', 'pasteLinks'),
405
+ ...i18nSelectors('textarea[placeholder*="{text}"]', 'placeholders', 'pasteLinks'),
406
+ // URL/http generic selectors (work in both languages)
407
+ 'input[placeholder*="URL"]',
408
+ 'textarea[placeholder*="URL"]',
409
+ 'input[placeholder*="url"]',
410
+ 'textarea[placeholder*="url"]',
411
+ 'input[placeholder*="http"]',
412
+ 'textarea[placeholder*="http"]',
413
+ 'input[name="url"]',
414
+ 'input[type="url"]',
415
+ // Fallback dialog selectors
416
+ '[role="dialog"] input[type="text"]',
417
+ '[role="dialog"] input:not([type="hidden"])',
418
+ '[role="dialog"] textarea',
419
+ '.mat-dialog-content input',
420
+ '.mat-dialog-content textarea',
421
+ '.mdc-dialog__content input',
422
+ '.mdc-dialog__content textarea',
423
+ ];
424
+ let urlInput = null;
425
+ for (const selector of urlInputSelectors) {
426
+ try {
427
+ const input = this.page.locator(selector).first();
428
+ if (await input.isVisible({ timeout: 500 })) {
429
+ urlInput = input;
430
+ log.info(` ✅ Found URL input: ${selector}`);
431
+ break;
432
+ }
433
+ }
434
+ catch {
435
+ continue;
436
+ }
437
+ }
438
+ // Fallback: find any visible input or textarea in the dialog
439
+ if (!urlInput) {
440
+ log.info(` 🔍 Trying fallback: any visible input/textarea in dialog...`);
441
+ try {
442
+ // Try inputs first
443
+ const allInputs = await this.page.locator('[role="dialog"] input').all();
444
+ for (const input of allInputs) {
445
+ if (await input.isVisible()) {
446
+ urlInput = input;
447
+ const placeholder = await input.getAttribute('placeholder');
448
+ log.info(` ✅ Found input via fallback: placeholder="${placeholder}"`);
449
+ break;
450
+ }
451
+ }
452
+ // Try textareas if no input found
453
+ if (!urlInput) {
454
+ const allTextareas = await this.page.locator('[role="dialog"] textarea').all();
455
+ for (const textarea of allTextareas) {
456
+ if (await textarea.isVisible()) {
457
+ urlInput = textarea;
458
+ const placeholder = await textarea.getAttribute('placeholder');
459
+ log.info(` ✅ Found textarea via fallback: placeholder="${placeholder}"`);
460
+ break;
461
+ }
462
+ }
463
+ }
464
+ }
465
+ catch {
466
+ /* ignore */
467
+ }
468
+ }
469
+ // Debug: list all inputs/textareas if still not found
470
+ if (!urlInput) {
471
+ log.warning(` ⚠️ URL input not found, listing dialog elements...`);
472
+ try {
473
+ const inputs = await this.page
474
+ .locator('[role="dialog"] input, [role="dialog"] textarea')
475
+ .all();
476
+ for (let i = 0; i < inputs.length; i++) {
477
+ const el = inputs[i];
478
+ const tag = await el.evaluate((e) => e.tagName?.toLowerCase() || 'unknown');
479
+ const type = await el.getAttribute('type');
480
+ const placeholder = await el.getAttribute('placeholder');
481
+ const visible = await el.isVisible();
482
+ log.info(` 🔍 Element[${i}]: tag=${tag}, type="${type}", placeholder="${placeholder}", visible=${visible}`);
483
+ }
484
+ }
485
+ catch (e) {
486
+ log.warning(` ⚠️ Could not list dialog elements: ${e}`);
487
+ }
488
+ throw new Error('URL input not found');
489
+ }
490
+ await urlInput.fill(input.url);
491
+ log.info(` ✅ URL entered`);
492
+ await randomDelay(300, 500);
493
+ // Click add/upload button
494
+ log.info(` 🔍 Looking for upload button...`);
495
+ await this.clickUploadButton();
496
+ // Wait for processing
497
+ const result = await this.waitForSourceProcessing(input.title || input.url, undefined, expectedNotebookUuid);
498
+ return result;
499
+ }
500
+ catch (error) {
501
+ const errorMsg = error instanceof Error ? error.message : String(error);
502
+ return { success: false, error: `URL upload failed: ${errorMsg}` };
503
+ }
504
+ }
505
+ /**
506
+ * Upload text content
507
+ */
508
+ async uploadText(input, expectedNotebookUuid) {
509
+ if (!input.text) {
510
+ return { success: false, error: 'Text content is required' };
511
+ }
512
+ log.info(` 📝 Adding text content (${input.text.length} chars)`);
513
+ try {
514
+ // Click on paste text option (bilingual FR/EN via i18n)
515
+ const textTypeSelectors = [
516
+ // Span element with pasted text label
517
+ ...i18nSelectors('span:has-text("{text}")', 'sourceTypes', 'pastedText'),
518
+ ...i18nSelectors(':has-text("{text}")', 'sourceTypes', 'pastedText'),
519
+ // Parent of the span (clickable area)
520
+ ...i18nSelectors('*:has(> span:has-text("{text}"))', 'sourceTypes', 'pastedText'),
521
+ // Generic fallbacks
522
+ 'span:has-text("Paste text")',
523
+ ':has-text("Paste text")',
524
+ '[data-type="text"]',
525
+ ];
526
+ log.info(` 🔍 Looking for paste text option...`);
527
+ let foundTextOption = false;
528
+ // Debug: Log all clickable elements in the dialog
529
+ try {
530
+ const dialogButtons = await this.page
531
+ .locator('[role="dialog"] button, [role="dialog"] [role="button"], [role="dialog"] a')
532
+ .all();
533
+ log.info(` 🔍 DEBUG: Found ${dialogButtons.length} clickable elements in dialog`);
534
+ for (let i = 0; i < Math.min(dialogButtons.length, 15); i++) {
535
+ const btn = dialogButtons[i];
536
+ const text = await btn.textContent();
537
+ log.info(` Element[${i}]: "${text?.trim()}"`);
538
+ }
539
+ }
540
+ catch (e) {
541
+ log.warning(` ⚠️ Could not debug dialog elements: ${e}`);
542
+ }
543
+ for (const selector of textTypeSelectors) {
544
+ try {
545
+ log.info(` 🔍 Trying selector: ${selector}`);
546
+ const btn = this.page.locator(selector).first();
547
+ if (await btn.isVisible({ timeout: 2000 })) {
548
+ log.info(` ✅ Found text option: ${selector}`);
549
+ await btn.click();
550
+ await randomDelay(500, 1000);
551
+ foundTextOption = true;
552
+ break;
553
+ }
554
+ }
555
+ catch {
556
+ continue;
557
+ }
558
+ }
559
+ if (!foundTextOption) {
560
+ log.warning(` ⚠️ No text option found - this will likely fail!`);
561
+ // Take screenshot for debugging
562
+ try {
563
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-text-option-not-found.png');
564
+ await this.page.screenshot({ path: screenshotPath });
565
+ log.info(` 📸 Debug screenshot: ${screenshotPath}`);
566
+ }
567
+ catch {
568
+ /* ignore */
569
+ }
570
+ }
571
+ // Find text input - must be in the dialog, not the chat input
572
+ log.info(` 🔍 Looking for text input in dialog...`);
573
+ // Wait for the paste dialog to fully appear
574
+ await randomDelay(500, 800);
575
+ // Try to find textarea specifically in the dialog context
576
+ const textInputSelectors = [
577
+ '[role="dialog"] textarea',
578
+ '.mat-dialog-container textarea',
579
+ '.mdc-dialog textarea',
580
+ // Fallback to any visible textarea that's not the chat input
581
+ 'textarea:not(.query-box-input)',
582
+ ];
583
+ let textInput = null;
584
+ for (const selector of textInputSelectors) {
585
+ try {
586
+ const el = await this.page.waitForSelector(selector, {
587
+ state: 'visible',
588
+ timeout: 3000,
589
+ });
590
+ if (el) {
591
+ log.info(` ✅ Found text input with: ${selector}`);
592
+ textInput = el;
593
+ break;
594
+ }
595
+ }
596
+ catch {
597
+ continue;
598
+ }
599
+ }
600
+ if (!textInput) {
601
+ // Debug: list all textareas on page
602
+ const allTextareas = await this.page.locator('textarea').all();
603
+ log.warning(` ⚠️ Found ${allTextareas.length} textareas on page`);
604
+ for (let i = 0; i < Math.min(allTextareas.length, 3); i++) {
605
+ const cls = await allTextareas[i].getAttribute('class');
606
+ const placeholder = await allTextareas[i].getAttribute('placeholder');
607
+ log.info(` textarea[${i}]: class="${cls}", placeholder="${placeholder}"`);
608
+ }
609
+ throw new Error('Text input not found in dialog');
610
+ }
611
+ await textInput.fill(input.text);
612
+ log.info(` ✅ Text entered (${input.text.length} chars)`);
613
+ // Set title if provided
614
+ log.info(` 🔍 Looking for title input...`);
615
+ if (input.title) {
616
+ const titleSelectors = [
617
+ 'input[placeholder*="title"]',
618
+ 'input[placeholder*="Title"]',
619
+ 'input[placeholder*="name"]',
620
+ 'input[placeholder*="Name"]',
621
+ 'input[name="title"]',
622
+ '[role="dialog"] input[type="text"]:not([readonly])',
623
+ ];
624
+ let titleSet = false;
625
+ for (const selector of titleSelectors) {
626
+ try {
627
+ const titleInput = this.page.locator(selector).first();
628
+ if (await titleInput.isVisible({ timeout: 500 })) {
629
+ await titleInput.fill(input.title);
630
+ log.info(` ✅ Title set: ${input.title} (via ${selector})`);
631
+ titleSet = true;
632
+ break;
633
+ }
634
+ }
635
+ catch {
636
+ continue;
637
+ }
638
+ }
639
+ if (!titleSet) {
640
+ log.warning(` ⚠️ Title input NOT found - source will have default name`);
641
+ // Debug: list all inputs in dialog
642
+ try {
643
+ const allInputs = await this.page.locator('[role="dialog"] input').all();
644
+ log.info(` 🔍 DEBUG: Found ${allInputs.length} inputs in dialog`);
645
+ for (let i = 0; i < Math.min(allInputs.length, 5); i++) {
646
+ const inp = allInputs[i];
647
+ const type = await inp.getAttribute('type');
648
+ const placeholder = await inp.getAttribute('placeholder');
649
+ log.info(` input[${i}]: type="${type}", placeholder="${placeholder}"`);
650
+ }
651
+ }
652
+ catch {
653
+ /* ignore */
654
+ }
655
+ }
656
+ }
657
+ await randomDelay(300, 500);
658
+ // DEBUG: Take screenshot before clicking upload button
659
+ try {
660
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-before-insert.png');
661
+ await this.page.screenshot({ path: screenshotPath });
662
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
663
+ }
664
+ catch (e) {
665
+ log.warning(` ⚠️ Could not take debug screenshot: ${e}`);
666
+ }
667
+ // DEBUG: Check if the "Insert" button is enabled (bilingual via i18n)
668
+ try {
669
+ const insertBtnSelectors = i18nSelectors('button:has-text("{text}")', 'buttons', 'insert');
670
+ for (const sel of insertBtnSelectors) {
671
+ const btn = this.page.locator(sel).first();
672
+ if (await btn.isVisible({ timeout: 500 })) {
673
+ const isDisabled = await btn.isDisabled();
674
+ const ariaDisabled = await btn.getAttribute('aria-disabled');
675
+ const classList = await btn.getAttribute('class');
676
+ log.info(` 🔍 Button "${sel}" - disabled: ${isDisabled}, aria-disabled: ${ariaDisabled}`);
677
+ log.info(` 🔍 Button classes: ${classList}`);
678
+ break;
679
+ }
680
+ }
681
+ }
682
+ catch (e) {
683
+ log.warning(` ⚠️ Could not check button state: ${e}`);
684
+ }
685
+ // Get first few words of text for later verification (NotebookLM uses text content as title)
686
+ const textPreview = input.text.slice(0, 30).trim();
687
+ log.info(` 📝 Text preview for verification: "${textPreview}..."`);
688
+ // Click add button
689
+ log.info(` 🔍 Looking for upload button...`);
690
+ await this.clickUploadButton();
691
+ // Wait for processing - NotebookLM names pasted text sources "Texte collé" in French or "Pasted text"
692
+ // We'll look for either the expected name or "Texte collé"
693
+ // Pass initialUuid to detect notebook redirection
694
+ const result = await this.waitForSourceProcessing(input.title || 'Texte collé', textPreview, expectedNotebookUuid);
695
+ return result;
696
+ }
697
+ catch (error) {
698
+ const errorMsg = error instanceof Error ? error.message : String(error);
699
+ return { success: false, error: `Text upload failed: ${errorMsg}` };
700
+ }
701
+ }
702
+ /**
703
+ * Upload from Google Drive
704
+ */
705
+ async uploadGoogleDrive(input, expectedNotebookUuid) {
706
+ if (!input.url) {
707
+ return { success: false, error: 'Google Drive URL is required' };
708
+ }
709
+ log.info(` 📂 Adding Google Drive source: ${input.url}`);
710
+ // Similar to URL upload but with Google Drive specific handling
711
+ return await this.uploadUrl({ ...input, type: 'url' }, expectedNotebookUuid);
712
+ }
713
+ /**
714
+ * Upload YouTube video
715
+ */
716
+ async uploadYouTube(input, expectedNotebookUuid) {
717
+ if (!input.url) {
718
+ return { success: false, error: 'YouTube URL is required' };
719
+ }
720
+ log.info(` 🎬 Adding YouTube video: ${input.url}`);
721
+ try {
722
+ // Wait for dialog to be fully ready
723
+ await randomDelay(500, 800);
724
+ // Click on YouTube option with expanded selectors
725
+ const ytSelectors = [
726
+ 'button:has-text("YouTube")',
727
+ '[data-type="youtube"]',
728
+ 'button[aria-label*="YouTube"]',
729
+ '[role="button"]:has-text("YouTube")',
730
+ // Material design buttons
731
+ '.mat-button:has-text("YouTube")',
732
+ '.mdc-button:has-text("YouTube")',
733
+ // List items that might be clickable
734
+ '[role="listitem"]:has-text("YouTube")',
735
+ '[role="option"]:has-text("YouTube")',
736
+ // Generic clickable elements with YouTube text
737
+ '[class*="option"]:has-text("YouTube")',
738
+ '[class*="source-type"]:has-text("YouTube")',
739
+ ];
740
+ let youtubeClicked = false;
741
+ for (const selector of ytSelectors) {
742
+ try {
743
+ const btn = this.page.locator(selector).first();
744
+ if (await btn.isVisible({ timeout: 1500 })) {
745
+ log.info(` ✅ Found YouTube option: ${selector}`);
746
+ await btn.click();
747
+ await randomDelay(500, 800);
748
+ youtubeClicked = true;
749
+ break;
750
+ }
751
+ }
752
+ catch {
753
+ continue;
754
+ }
755
+ }
756
+ if (!youtubeClicked) {
757
+ log.warning(' ⚠️ Could not click YouTube option, trying to proceed anyway...');
758
+ }
759
+ // Enter YouTube URL (can be input or textarea)
760
+ await randomDelay(500, 1000);
761
+ const ytInputSelectors = [
762
+ // French placeholders
763
+ 'input[placeholder*="Collez"]',
764
+ 'textarea[placeholder*="Collez"]',
765
+ 'input[placeholder*="YouTube"]',
766
+ 'textarea[placeholder*="YouTube"]',
767
+ // English placeholders
768
+ 'input[placeholder*="youtube" i]',
769
+ 'textarea[placeholder*="youtube" i]',
770
+ 'input[placeholder*="URL"]',
771
+ 'textarea[placeholder*="URL"]',
772
+ 'input[placeholder*="Paste"]',
773
+ 'textarea[placeholder*="Paste"]',
774
+ '[role="dialog"] input[type="text"]',
775
+ '[role="dialog"] textarea',
776
+ ];
777
+ let urlInput = null;
778
+ log.info(` 🔍 Looking for YouTube URL input...`);
779
+ for (const selector of ytInputSelectors) {
780
+ try {
781
+ const input = this.page.locator(selector).first();
782
+ if (await input.isVisible({ timeout: 500 })) {
783
+ urlInput = input;
784
+ log.info(` ✅ Found YouTube input: ${selector}`);
785
+ break;
786
+ }
787
+ }
788
+ catch {
789
+ continue;
790
+ }
791
+ }
792
+ // Fallback: any visible input/textarea in dialog
793
+ if (!urlInput) {
794
+ log.info(` 🔍 Trying fallback for YouTube input...`);
795
+ try {
796
+ const allInputs = await this.page
797
+ .locator('[role="dialog"] input, [role="dialog"] textarea')
798
+ .all();
799
+ for (const input of allInputs) {
800
+ if (await input.isVisible()) {
801
+ urlInput = input;
802
+ const placeholder = await input.getAttribute('placeholder');
803
+ log.info(` ✅ Found via fallback: placeholder="${placeholder}"`);
804
+ break;
805
+ }
806
+ }
807
+ }
808
+ catch {
809
+ /* ignore */
810
+ }
811
+ }
812
+ if (!urlInput) {
813
+ throw new Error('YouTube URL input not found');
814
+ }
815
+ await urlInput.fill(input.url);
816
+ log.info(` ✅ YouTube URL entered`);
817
+ await randomDelay(500, 1000);
818
+ await this.clickUploadButton();
819
+ const result = await this.waitForSourceProcessing(input.title || 'YouTube video', undefined, expectedNotebookUuid);
820
+ return result;
821
+ }
822
+ catch (error) {
823
+ const errorMsg = error instanceof Error ? error.message : String(error);
824
+ return { success: false, error: `YouTube upload failed: ${errorMsg}` };
825
+ }
826
+ }
827
+ /**
828
+ * Click the upload/add button
829
+ */
830
+ async clickUploadButton() {
831
+ const uploadBtnSelectors = [
832
+ // Primary action buttons (most likely) - bilingual via i18n
833
+ ...i18nSelectors('button.mdc-button--raised:has-text("{text}")', 'buttons', 'insert'),
834
+ ...i18nSelectors('button.mat-flat-button:has-text("{text}")', 'buttons', 'insert'),
835
+ ...i18nSelectors('button[color="primary"]:has-text("{text}")', 'buttons', 'insert'),
836
+ // Generic text patterns (bilingual via i18n)
837
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'insert'),
838
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'add'),
839
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'upload'),
840
+ 'button:has-text("Import")',
841
+ 'button:has-text("Save")',
842
+ 'button:has-text("Submit")',
843
+ // Form submit
844
+ 'button[type="submit"]',
845
+ // Dialog actions
846
+ '[role="dialog"] button:not(:has-text("Cancel")):not(:has-text("Close"))',
847
+ '.mat-dialog-actions button:not(:has-text("Cancel"))',
848
+ '.mdc-dialog__actions button:not(:has-text("Cancel"))',
849
+ ];
850
+ for (const selector of uploadBtnSelectors) {
851
+ try {
852
+ const btn = this.page.locator(selector).first();
853
+ if (await btn.isVisible({ timeout: 500 })) {
854
+ log.info(` ✅ Found upload button: ${selector}`);
855
+ await btn.click();
856
+ log.info(` ✅ Clicked upload button`);
857
+ return;
858
+ }
859
+ }
860
+ catch {
861
+ continue;
862
+ }
863
+ }
864
+ // Debug: list all buttons in dialog
865
+ log.warning(` ⚠️ No upload button found, listing dialog buttons...`);
866
+ try {
867
+ const dialogButtons = await this.page.locator('[role="dialog"] button').all();
868
+ for (let i = 0; i < Math.min(dialogButtons.length, 5); i++) {
869
+ const text = await dialogButtons[i].textContent();
870
+ log.info(` 🔍 Dialog button[${i}]: "${text?.trim()}"`);
871
+ }
872
+ }
873
+ catch {
874
+ // ignore
875
+ }
876
+ // Try pressing Enter as fallback
877
+ log.info(` ⌨️ Pressing Enter as fallback`);
878
+ await this.page.keyboard.press('Enter');
879
+ }
880
+ /**
881
+ * Wait for source to finish processing
882
+ * @param sourceName The name we expect the source to have
883
+ * @param _textPreview Optional first words of text (for text sources - NotebookLM may use this as name)
884
+ * @param expectedNotebookUuid Optional UUID of the notebook we expect to be on (to detect redirects)
885
+ */
886
+ async waitForSourceProcessing(sourceName, _textPreview, expectedNotebookUuid) {
887
+ log.info(` ⏳ Waiting for source processing: ${sourceName}`);
888
+ const timeout = 90000; // 1.5 minutes (sources can take time)
889
+ const startTime = Date.now();
890
+ // First, wait a bit for the dialog to close (indicates upload started)
891
+ await randomDelay(2000, 3000);
892
+ while (Date.now() - startTime < timeout) {
893
+ // Check for errors in the dialog or page
894
+ const errorSelectors = [
895
+ '.error-message',
896
+ '[role="alert"]:has-text("error")',
897
+ '[role="alert"]:has-text("Error")',
898
+ '.mdc-snackbar--error',
899
+ '[class*="error"]',
900
+ ];
901
+ for (const errorSelector of errorSelectors) {
902
+ try {
903
+ const errorEl = this.page.locator(errorSelector).first();
904
+ if (await errorEl.isVisible({ timeout: 500 })) {
905
+ const errorText = await errorEl.textContent();
906
+ return { success: false, error: errorText || 'Upload failed', status: 'failed' };
907
+ }
908
+ }
909
+ catch {
910
+ continue;
911
+ }
912
+ }
913
+ // Check if dialog is still open (might mean still processing)
914
+ const dialogSelectors = ['[role="dialog"]', '.mat-dialog-container', '.mdc-dialog'];
915
+ let dialogVisible = false;
916
+ for (const dialogSelector of dialogSelectors) {
917
+ try {
918
+ const dialog = this.page.locator(dialogSelector).first();
919
+ if (await dialog.isVisible({ timeout: 500 })) {
920
+ dialogVisible = true;
921
+ break;
922
+ }
923
+ }
924
+ catch {
925
+ continue;
926
+ }
927
+ }
928
+ // If dialog closed, check if source appears in the sources list
929
+ if (!dialogVisible) {
930
+ log.info(` ℹ️ Dialog closed, checking for source in list...`);
931
+ // CRITICAL: Verify we're still on the correct notebook after dialog closes
932
+ // NotebookLM sometimes redirects to a NEW notebook when adding text sources!
933
+ const currentUrl = this.page.url();
934
+ log.info(` 🔍 Current URL: ${currentUrl}`);
935
+ // Check if URL changed (different notebook UUID)
936
+ const currentUuid = currentUrl.match(/notebook\/([a-f0-9-]+)/)?.[1];
937
+ log.info(` 🆔 Current UUID: ${currentUuid || 'NOT FOUND'}`);
938
+ log.info(` 🆔 Expected UUID: ${expectedNotebookUuid || 'NOT PROVIDED'}`);
939
+ if (currentUuid && expectedNotebookUuid && currentUuid !== expectedNotebookUuid) {
940
+ log.error(` ❌ NOTEBOOK MISMATCH! NotebookLM redirected to a different notebook!`);
941
+ log.error(` ❌ Expected: ${expectedNotebookUuid}`);
942
+ log.error(` ❌ Got: ${currentUuid}`);
943
+ // Navigate back to the correct notebook and try to add source properly
944
+ log.warning(` ⚠️ This is a known NotebookLM behavior - text sources may create new notebooks`);
945
+ // Return failure with clear error message
946
+ return {
947
+ success: false,
948
+ error: `NotebookLM redirected to a different notebook (${currentUuid}) instead of the target (${expectedNotebookUuid}). This happens when NotebookLM creates a new notebook for pasted text. The source was added to an 'Untitled notebook' instead.`,
949
+ status: 'failed',
950
+ };
951
+ }
952
+ // Try to get notebook title for logging
953
+ try {
954
+ const titleSelectors = ['h1', '[class*="notebook-title"]', '[class*="title"]'];
955
+ for (const sel of titleSelectors) {
956
+ try {
957
+ const titleEl = this.page.locator(sel).first();
958
+ if (await titleEl.isVisible({ timeout: 500 })) {
959
+ const title = await titleEl.textContent();
960
+ if (title && title.length < 100) {
961
+ log.info(` 📓 Notebook title: "${title.trim()}"`);
962
+ break;
963
+ }
964
+ }
965
+ }
966
+ catch {
967
+ continue;
968
+ }
969
+ }
970
+ }
971
+ catch {
972
+ /* ignore */
973
+ }
974
+ // Take screenshot after dialog closed
975
+ try {
976
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-after-insert.png');
977
+ await this.page.screenshot({ path: screenshotPath });
978
+ log.info(` 📸 Debug screenshot (after click): ${screenshotPath}`);
979
+ }
980
+ catch {
981
+ /* ignore */
982
+ }
983
+ await randomDelay(1000, 2000);
984
+ // METHOD 1: Look for pasted text source in the SOURCES PANEL specifically (not anywhere on page)
985
+ // Use more specific selectors to avoid matching dialog content
986
+ // Support both French ("Texte collé") and English ("Pasted text") UI via i18n
987
+ const pastedTextSelectors = [
988
+ // Sources panel specific selectors (bilingual via i18n)
989
+ ...i18nSelectors('mat-checkbox:has-text("{text}")', 'sourceNames', 'pastedText'),
990
+ ...i18nSelectors('[class*="source"]:has-text("{text}")', 'sourceNames', 'pastedText'),
991
+ ...i18nSelectors(':has-text("{text}"):not([role="dialog"])', 'sourceNames', 'pastedText'),
992
+ ];
993
+ // Get localized pasted text names for detection
994
+ const pastedTextNames = tAll('sourceNames', 'pastedText');
995
+ for (const selector of pastedTextSelectors) {
996
+ try {
997
+ const el = this.page.locator(selector).first();
998
+ if (await el.isVisible({ timeout: 1000 })) {
999
+ log.success(` ✅ Found pasted text source: ${selector}`);
1000
+ // Detect source name from selector - find which locale's text is in the selector
1001
+ const detectedName = pastedTextNames.find((name) => selector.includes(name)) || pastedTextNames[0];
1002
+ return { success: true, sourceName: detectedName, status: 'ready' };
1003
+ }
1004
+ }
1005
+ catch {
1006
+ continue;
1007
+ }
1008
+ }
1009
+ // METHOD 2: Check for source in the sources panel by name
1010
+ const sourceListSelectors = [
1011
+ // Source items that might contain our source
1012
+ `[class*="source"]:has-text("${sourceName}")`,
1013
+ `[class*="Source"]:has-text("${sourceName}")`,
1014
+ // Generic list items
1015
+ '.source-list-item',
1016
+ '[class*="source-item"]',
1017
+ '[class*="SourceItem"]',
1018
+ // Material list
1019
+ 'mat-list-item',
1020
+ '.mat-list-item',
1021
+ // By count change (sources list exists)
1022
+ '[class*="sources"]',
1023
+ ];
1024
+ // Try to find the specific source by name
1025
+ for (const selector of sourceListSelectors.slice(0, 2)) {
1026
+ // Only name-based selectors
1027
+ try {
1028
+ const el = this.page.locator(selector).first();
1029
+ if (await el.isVisible({ timeout: 500 })) {
1030
+ log.success(` ✅ Source added successfully: ${sourceName}`);
1031
+ return { success: true, sourceName, status: 'ready' };
1032
+ }
1033
+ }
1034
+ catch {
1035
+ continue;
1036
+ }
1037
+ }
1038
+ // Wait a bit more and try again - sources can take time to appear
1039
+ log.info(` ⏳ Source not found yet, waiting 5 more seconds...`);
1040
+ await randomDelay(4000, 6000);
1041
+ // Try again for pasted text source with different variations
1042
+ for (const selector of pastedTextSelectors) {
1043
+ try {
1044
+ const el = this.page.locator(selector).first();
1045
+ if (await el.isVisible({ timeout: 1000 })) {
1046
+ log.success(` ✅ Found pasted text source after wait: ${selector}`);
1047
+ // Detect source name from selector - find which locale's text is in the selector
1048
+ const detectedName = pastedTextNames.find((name) => selector.includes(name)) || pastedTextNames[0];
1049
+ return { success: true, sourceName: detectedName, status: 'ready' };
1050
+ }
1051
+ }
1052
+ catch {
1053
+ continue;
1054
+ }
1055
+ }
1056
+ // Try one more time to find the source by name
1057
+ try {
1058
+ const sourceByName = this.page
1059
+ .locator(`[class*="source"]:has-text("${sourceName}")`)
1060
+ .first();
1061
+ if (await sourceByName.isVisible({ timeout: 2000 })) {
1062
+ log.success(` ✅ Source found after wait: ${sourceName}`);
1063
+ return { success: true, sourceName, status: 'ready' };
1064
+ }
1065
+ }
1066
+ catch {
1067
+ /* ignore */
1068
+ }
1069
+ // If still not found, this is a failure - don't assume success
1070
+ log.warning(` ⚠️ Dialog closed but source not found in list - upload likely failed`);
1071
+ return {
1072
+ success: false,
1073
+ sourceName,
1074
+ error: 'Source not found after upload - dialog closed but source not visible in list',
1075
+ status: 'failed',
1076
+ };
1077
+ }
1078
+ // Still in dialog - check for processing indicators
1079
+ const processingSelectors = [
1080
+ '.loading',
1081
+ '.spinner',
1082
+ '[class*="loading"]',
1083
+ '[class*="processing"]',
1084
+ 'mat-progress-bar',
1085
+ 'mat-spinner',
1086
+ '.mdc-linear-progress',
1087
+ ];
1088
+ let isProcessing = false;
1089
+ for (const procSelector of processingSelectors) {
1090
+ try {
1091
+ const proc = this.page.locator(procSelector).first();
1092
+ if (await proc.isVisible({ timeout: 500 })) {
1093
+ isProcessing = true;
1094
+ break;
1095
+ }
1096
+ }
1097
+ catch {
1098
+ continue;
1099
+ }
1100
+ }
1101
+ if (isProcessing) {
1102
+ log.info(` ⏳ Still processing...`);
1103
+ }
1104
+ await this.page.waitForTimeout(2000);
1105
+ }
1106
+ return { success: false, error: 'Timeout waiting for source processing', status: 'failed' };
1107
+ }
1108
+ // ============================================================================
1109
+ // Chat-Based Content Generation (New UI - Dec 2024)
1110
+ // ============================================================================
1111
+ /**
1112
+ * Send a message in the chat interface (without waiting for response)
1113
+ * This is the new way to generate content in NotebookLM
1114
+ * Uses the same typing and submission approach as ask_question for reliability
1115
+ */
1116
+ async sendChatMessage(message) {
1117
+ log.info(` 💬 Sending chat message: "${message.substring(0, 50)}..."`);
1118
+ // Find the chat input (same approach as BrowserSession.findChatInput)
1119
+ const chatInputSelectors = [
1120
+ 'textarea.query-box-input', // PRIMARY - same as Python implementation
1121
+ 'textarea[aria-label*="query"]',
1122
+ 'textarea[aria-label*="Zone de requête"]',
1123
+ ];
1124
+ let inputSelector = null;
1125
+ for (const selector of chatInputSelectors) {
1126
+ try {
1127
+ const input = await this.page.waitForSelector(selector, {
1128
+ state: 'visible',
1129
+ timeout: 3000,
1130
+ });
1131
+ if (input) {
1132
+ inputSelector = selector;
1133
+ log.info(` ✅ Found chat input: ${selector}`);
1134
+ break;
1135
+ }
1136
+ }
1137
+ catch {
1138
+ continue;
1139
+ }
1140
+ }
1141
+ if (!inputSelector) {
1142
+ throw new Error('Chat input not found');
1143
+ }
1144
+ // Clear any existing text first
1145
+ const inputEl = await this.page.$(inputSelector);
1146
+ if (inputEl) {
1147
+ await inputEl.click();
1148
+ await this.page.keyboard.press('Control+A');
1149
+ await this.page.keyboard.press('Backspace');
1150
+ await randomDelay(200, 400);
1151
+ }
1152
+ // Type the message with human-like behavior (same as BrowserSession.askQuestion)
1153
+ log.info(` ⌨️ Typing message with human-like behavior...`);
1154
+ await humanType(this.page, inputSelector, message, {
1155
+ withTypos: false, // No typos for prompts to avoid confusion
1156
+ wpm: 150, // Faster typing for long prompts
1157
+ });
1158
+ // Small pause before submitting
1159
+ await randomDelay(500, 1000);
1160
+ // Submit with Enter key (same as BrowserSession.askQuestion)
1161
+ log.info(` 📤 Submitting message...`);
1162
+ await this.page.keyboard.press('Enter');
1163
+ // Small pause after submit
1164
+ await randomDelay(1000, 1500);
1165
+ log.info(` ✅ Message sent`);
1166
+ }
1167
+ /**
1168
+ * Wait for generated content to appear in chat
1169
+ * Uses the same proven approach as /ask endpoint (waitForLatestAnswer with full timeout)
1170
+ */
1171
+ async waitForGeneratedContent(contentType, timeoutMs = 600000) {
1172
+ log.info(` ⏳ Waiting for ${contentType} response (up to ${timeoutMs / 60000} minutes)...`);
1173
+ // Scroll to bottom to ensure we see all messages
1174
+ await this.scrollChatToBottom();
1175
+ // Snapshot existing chat responses to ignore them
1176
+ const existingChatResponses = await snapshotAllResponses(this.page);
1177
+ log.info(` 📊 Ignoring ${existingChatResponses.length} existing chat responses`);
1178
+ // Use the same proven logic as /ask endpoint - wait for new chat response
1179
+ const response = await waitForLatestAnswer(this.page, {
1180
+ question: '', // Empty question since we already sent the message
1181
+ timeoutMs: timeoutMs,
1182
+ pollIntervalMs: 2000, // Poll every 2 seconds
1183
+ ignoreTexts: existingChatResponses,
1184
+ debug: true, // Enable debug to see what's happening
1185
+ });
1186
+ // Check if response is an error message from NotebookLM
1187
+ if (response && isErrorMessage(response)) {
1188
+ log.error(` ❌ NotebookLM returned an error: "${response}"`);
1189
+ throw new Error(`NotebookLM error: ${response}`);
1190
+ }
1191
+ if (response && response.length > 50) {
1192
+ log.success(` ✅ Content received (${response.length} chars)`);
1193
+ return { source: 'chat', content: response };
1194
+ }
1195
+ throw new Error(`Timeout waiting for ${contentType} generation after ${timeoutMs / 1000}s`);
1196
+ }
1197
+ /**
1198
+ * Scroll chat container to bottom to ensure latest messages are visible
1199
+ */
1200
+ async scrollChatToBottom() {
1201
+ try {
1202
+ // Try multiple selectors for the chat container
1203
+ const chatContainerSelectors = [
1204
+ '.chat-scroll-container',
1205
+ '.messages-container',
1206
+ '[class*="scroll"]',
1207
+ '.query-container',
1208
+ ];
1209
+ for (const selector of chatContainerSelectors) {
1210
+ const container = await this.page.$(selector);
1211
+ if (container) {
1212
+ await container.evaluate((el) => {
1213
+ el.scrollTop = el.scrollHeight;
1214
+ });
1215
+ log.debug(` 📜 Scrolled chat to bottom using ${selector}`);
1216
+ return;
1217
+ }
1218
+ }
1219
+ // Fallback: scroll the whole page
1220
+ await this.page.evaluate(`window.scrollTo(0, document.body.scrollHeight)`);
1221
+ log.debug(` 📜 Scrolled page to bottom (fallback)`);
1222
+ }
1223
+ catch (error) {
1224
+ log.debug(` ⚠️ Could not scroll: ${error}`);
1225
+ }
1226
+ }
1227
+ // ============================================================================
1228
+ // Content Generation
1229
+ // ============================================================================
1230
+ /**
1231
+ * Generate content (audio overview, presentation, report)
1232
+ *
1233
+ * Supported content types:
1234
+ * - audio_overview: Uses real Studio UI buttons for audio podcast generation
1235
+ * - video: Uses generic ContentGenerator for video generation (with format, language, style options)
1236
+ * - infographic: Uses generic ContentGenerator for infographic generation (with format options)
1237
+ * - presentation: Uses generic ContentGenerator for slides generation (with format options)
1238
+ * - report: Uses generic ContentGenerator for briefing document generation (with format options)
1239
+ * - data_table: Uses generic ContentGenerator for data table generation (with format options)
1240
+ *
1241
+ * NOTE: Other content types (study_guide, faq, timeline, table_of_contents)
1242
+ * were removed because they only sent chat prompts instead of clicking actual
1243
+ * NotebookLM Studio buttons.
1244
+ */
1245
+ async generateContent(input) {
1246
+ log.info(`🎨 Generating content: ${input.type}`);
1247
+ try {
1248
+ if (input.type === 'audio_overview') {
1249
+ return await this.generateAudioOverview(input);
1250
+ }
1251
+ // Use generic ContentGenerator for all other supported types
1252
+ if (input.type === 'video' ||
1253
+ input.type === 'infographic' ||
1254
+ input.type === 'presentation' ||
1255
+ input.type === 'report' ||
1256
+ input.type === 'data_table') {
1257
+ return await this.generateGenericContent(input);
1258
+ }
1259
+ // Unsupported content type
1260
+ return {
1261
+ success: false,
1262
+ contentType: input.type,
1263
+ error: `Unsupported content type: ${input.type}. Supported types: 'audio_overview', 'video', 'infographic', 'presentation', 'report', 'data_table'.`,
1264
+ };
1265
+ }
1266
+ catch (error) {
1267
+ const errorMsg = error instanceof Error ? error.message : String(error);
1268
+ log.error(`❌ Content generation failed: ${errorMsg}`);
1269
+ return { success: false, contentType: input.type, error: errorMsg };
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Generate content using the generic ContentGenerator
1274
+ *
1275
+ * This method handles all content types except audio_overview:
1276
+ * - video: With format (brief/explainer), language, and visual style options
1277
+ * - infographic: With format (horizontal/vertical) and language options
1278
+ * - presentation: With format (overview/detailed) and language options
1279
+ * - report: With format (summary/detailed) and language options
1280
+ * - data_table: With format (simple/detailed) and language options
1281
+ *
1282
+ * @param input Content generation input with all options
1283
+ * @returns Content generation result
1284
+ */
1285
+ async generateGenericContent(input) {
1286
+ log.info(`🎨 Generating ${input.type} via ContentGenerator...`);
1287
+ try {
1288
+ const generator = new ContentGenerator(this.page);
1289
+ const result = await generator.generate(input);
1290
+ if (result.success) {
1291
+ log.success(` ✅ ${input.type} generated successfully`);
1292
+ }
1293
+ else {
1294
+ log.error(` ❌ ${input.type} generation failed: ${result.error}`);
1295
+ }
1296
+ return result;
1297
+ }
1298
+ catch (error) {
1299
+ const errorMsg = error instanceof Error ? error.message : String(error);
1300
+ log.error(` ❌ ${input.type} generation failed: ${errorMsg}`);
1301
+ return {
1302
+ success: false,
1303
+ contentType: input.type,
1304
+ error: errorMsg,
1305
+ };
1306
+ }
1307
+ }
1308
+ /**
1309
+ * Generate Audio Overview (podcast)
1310
+ *
1311
+ * NOTE (Dec 2024): NotebookLM UI has changed significantly.
1312
+ * Audio generation now works via chat requests or may require specific UI interaction.
1313
+ * This method attempts both approaches.
1314
+ */
1315
+ async generateAudioOverview(input) {
1316
+ log.info(`🎙️ Generating Audio Overview...`);
1317
+ try {
1318
+ // First, check Studio for existing audio or audio generation button
1319
+ await this.navigateToStudio();
1320
+ await this.page.waitForTimeout(1000);
1321
+ // Check if audio already exists
1322
+ const existingAudio = await this.page.$('audio, .audio-player, [class*="audio-overview"]');
1323
+ if (existingAudio) {
1324
+ log.info(` ℹ️ Audio Overview already exists`);
1325
+ return {
1326
+ success: true,
1327
+ contentType: 'audio_overview',
1328
+ status: 'ready',
1329
+ };
1330
+ }
1331
+ // Try to find audio generation button in Studio
1332
+ const audioSelectors = [
1333
+ 'button:has-text("Audio")',
1334
+ 'button:has-text("Generate audio")',
1335
+ 'button:has-text("Générer")',
1336
+ 'button[aria-label*="audio" i]',
1337
+ '[class*="audio"] button',
1338
+ 'button:has(mat-icon:has-text("mic"))',
1339
+ 'button:has(mat-icon:has-text("podcast"))',
1340
+ ];
1341
+ for (const selector of audioSelectors) {
1342
+ try {
1343
+ const btn = this.page.locator(selector).first();
1344
+ if (await btn.isVisible({ timeout: 1000 })) {
1345
+ log.info(` ✅ Found audio button: ${selector}`);
1346
+ // Add custom instructions if provided
1347
+ if (input.customInstructions) {
1348
+ await this.addCustomInstructions(input.customInstructions);
1349
+ }
1350
+ await btn.click();
1351
+ log.info(` ✅ Started audio generation`);
1352
+ // Wait for generation
1353
+ return await this.waitForAudioGeneration();
1354
+ }
1355
+ }
1356
+ catch {
1357
+ continue;
1358
+ }
1359
+ }
1360
+ // Fallback: Try chat-based approach
1361
+ log.info(` ℹ️ No audio button found, trying chat-based approach...`);
1362
+ await this.navigateToDiscussion();
1363
+ let prompt = 'Create an audio overview (Deep Dive podcast) for this notebook. Generate a conversational podcast script that covers the main topics from all sources.';
1364
+ if (input.customInstructions) {
1365
+ prompt += `\n\nCustom instructions: ${input.customInstructions}`;
1366
+ }
1367
+ await this.sendChatMessage(prompt);
1368
+ const result = await this.waitForGeneratedContent('audio_overview', 600000);
1369
+ if (result.content && result.content.length > 100) {
1370
+ log.success(` ✅ Audio overview script generated via ${result.source}`);
1371
+ return {
1372
+ success: true,
1373
+ contentType: 'audio_overview',
1374
+ status: 'ready',
1375
+ textContent: result.content,
1376
+ };
1377
+ }
1378
+ throw new Error('Could not generate audio overview - button not found and chat approach failed');
1379
+ }
1380
+ catch (error) {
1381
+ const errorMsg = error instanceof Error ? error.message : String(error);
1382
+ return { success: false, contentType: 'audio_overview', error: errorMsg };
1383
+ }
1384
+ }
1385
+ // NOTE: generateBriefingDoc, generateStudyGuide, generateTimeline, generateFAQ,
1386
+ // generateTOC, and generateDocumentContent methods were removed because they
1387
+ // only sent chat prompts instead of clicking actual NotebookLM Studio buttons.
1388
+ // Only audio_overview uses real UI interaction.
1389
+ /**
1390
+ * Generate Presentation/Slides using the generic ContentGenerator
1391
+ *
1392
+ * This uses the generic content generation architecture that:
1393
+ * 1. Navigates to Studio panel
1394
+ * 2. Looks for presentation/slides button
1395
+ * 3. Falls back to chat-based generation if button not found
1396
+ *
1397
+ * @param input Content generation input with optional custom instructions
1398
+ * @returns Content generation result
1399
+ */
1400
+ async generatePresentation(input) {
1401
+ log.info(`📊 Generating Presentation/Slides...`);
1402
+ try {
1403
+ // Use the generic ContentGenerator for presentation generation
1404
+ const generator = new ContentGenerator(this.page);
1405
+ const result = await generator.generate({
1406
+ type: 'presentation',
1407
+ customInstructions: input.customInstructions,
1408
+ sources: input.sources,
1409
+ language: input.language,
1410
+ presentationStyle: input.presentationStyle,
1411
+ presentationLength: input.presentationLength,
1412
+ });
1413
+ if (result.success) {
1414
+ log.success(` ✅ Presentation generated successfully`);
1415
+ }
1416
+ else {
1417
+ log.error(` ❌ Presentation generation failed: ${result.error}`);
1418
+ }
1419
+ return result;
1420
+ }
1421
+ catch (error) {
1422
+ const errorMsg = error instanceof Error ? error.message : String(error);
1423
+ log.error(` ❌ Presentation generation failed: ${errorMsg}`);
1424
+ return {
1425
+ success: false,
1426
+ contentType: 'presentation',
1427
+ error: errorMsg,
1428
+ };
1429
+ }
1430
+ }
1431
+ /**
1432
+ * Generate Report/Briefing Document using the generic ContentGenerator
1433
+ *
1434
+ * Creates a comprehensive briefing document (2,000-3,000 words) that summarizes
1435
+ * key findings, insights, and recommendations from notebook sources.
1436
+ * Can be exported as PDF or DOCX format.
1437
+ *
1438
+ * This uses the generic content generation architecture that:
1439
+ * 1. Navigates to Studio panel
1440
+ * 2. Looks for briefing/report button
1441
+ * 3. Falls back to chat-based generation if button not found
1442
+ *
1443
+ * @param input Content generation input with optional custom instructions
1444
+ * @returns Content generation result with text content
1445
+ */
1446
+ async generateReport(input) {
1447
+ log.info(`📄 Generating Report/Briefing Document...`);
1448
+ try {
1449
+ // Use the generic ContentGenerator for report generation
1450
+ const generator = new ContentGenerator(this.page);
1451
+ const result = await generator.generate({
1452
+ type: 'report',
1453
+ customInstructions: input.customInstructions,
1454
+ sources: input.sources,
1455
+ language: input.language,
1456
+ reportFormat: input.reportFormat,
1457
+ });
1458
+ if (result.success) {
1459
+ log.success(` ✅ Report generated successfully`);
1460
+ }
1461
+ else {
1462
+ log.error(` ❌ Report generation failed: ${result.error}`);
1463
+ }
1464
+ return result;
1465
+ }
1466
+ catch (error) {
1467
+ const errorMsg = error instanceof Error ? error.message : String(error);
1468
+ log.error(` ❌ Report generation failed: ${errorMsg}`);
1469
+ return {
1470
+ success: false,
1471
+ contentType: 'report',
1472
+ error: errorMsg,
1473
+ };
1474
+ }
1475
+ }
1476
+ /**
1477
+ * Generate Infographic using the generic ContentGenerator
1478
+ *
1479
+ * Creates a visual infographic from the notebook sources.
1480
+ * Supports two formats:
1481
+ * - horizontal (16:9): Landscape format for presentations/displays
1482
+ * - vertical (9:16): Portrait format for social media/mobile
1483
+ *
1484
+ * This uses the generic content generation architecture that:
1485
+ * 1. Navigates to Studio panel
1486
+ * 2. Looks for infographic button
1487
+ * 3. Falls back to chat-based generation if button not found
1488
+ *
1489
+ * @param input Content generation input with optional custom instructions and format
1490
+ * @returns Content generation result
1491
+ */
1492
+ async generateInfographic(input) {
1493
+ log.info(`Generating Infographic...`);
1494
+ try {
1495
+ // Use the generic ContentGenerator for infographic generation
1496
+ const generator = new ContentGenerator(this.page);
1497
+ const result = await generator.generate({
1498
+ type: 'infographic',
1499
+ customInstructions: input.customInstructions,
1500
+ sources: input.sources,
1501
+ language: input.language,
1502
+ infographicFormat: input.infographicFormat,
1503
+ });
1504
+ if (result.success) {
1505
+ log.success(` Infographic generated successfully`);
1506
+ }
1507
+ else {
1508
+ log.error(` Infographic generation failed: ${result.error}`);
1509
+ }
1510
+ return result;
1511
+ }
1512
+ catch (error) {
1513
+ const errorMsg = error instanceof Error ? error.message : String(error);
1514
+ log.error(` Infographic generation failed: ${errorMsg}`);
1515
+ return {
1516
+ success: false,
1517
+ contentType: 'infographic',
1518
+ error: errorMsg,
1519
+ };
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Generate a Data Table using the generic ContentGenerator
1524
+ *
1525
+ * Creates a structured table that organizes key information from notebook sources.
1526
+ * The generated table can be exported as CSV or Excel format.
1527
+ *
1528
+ * @param input Content generation input with optional custom instructions
1529
+ * @returns Content generation result with table data
1530
+ */
1531
+ async generateDataTable(input) {
1532
+ log.info(`Generating Data Table...`);
1533
+ try {
1534
+ // Use the generic ContentGenerator for data table generation
1535
+ const generator = new ContentGenerator(this.page);
1536
+ const result = await generator.generate({
1537
+ type: 'data_table',
1538
+ customInstructions: input.customInstructions,
1539
+ sources: input.sources,
1540
+ language: input.language,
1541
+ // Note: data_table has no format options - exports to Google Sheets
1542
+ });
1543
+ if (result.success) {
1544
+ log.success(` ✅ Data Table generated successfully`);
1545
+ }
1546
+ else {
1547
+ log.error(` ❌ Data Table generation failed: ${result.error}`);
1548
+ }
1549
+ return result;
1550
+ }
1551
+ catch (error) {
1552
+ const errorMsg = error instanceof Error ? error.message : String(error);
1553
+ log.error(` ❌ Data Table generation failed: ${errorMsg}`);
1554
+ return {
1555
+ success: false,
1556
+ contentType: 'data_table',
1557
+ error: errorMsg,
1558
+ };
1559
+ }
1560
+ }
1561
+ /**
1562
+ * Generate a Video using the generic ContentGenerator
1563
+ *
1564
+ * Creates a video summary that visually explains the main topics from notebook sources.
1565
+ * Video generation may take several minutes depending on content complexity.
1566
+ *
1567
+ * Supported formats:
1568
+ * - brief: Short video summary (2-3 minutes)
1569
+ * - explainer: Detailed explanation video (5-10 minutes)
1570
+ *
1571
+ * This uses the generic content generation architecture that:
1572
+ * 1. Navigates to Studio panel
1573
+ * 2. Looks for video generation button
1574
+ * 3. Falls back to chat-based generation if button not found
1575
+ *
1576
+ * @param input Content generation input with optional custom instructions and format
1577
+ * @returns Content generation result with video data
1578
+ */
1579
+ async generateVideo(input) {
1580
+ log.info(`🎬 Generating Video...`);
1581
+ try {
1582
+ // Use the generic ContentGenerator for video generation
1583
+ const generator = new ContentGenerator(this.page);
1584
+ const result = await generator.generate({
1585
+ type: 'video',
1586
+ customInstructions: input.customInstructions,
1587
+ sources: input.sources,
1588
+ language: input.language,
1589
+ videoFormat: input.videoFormat,
1590
+ });
1591
+ if (result.success) {
1592
+ log.success(` ✅ Video generated successfully`);
1593
+ }
1594
+ else {
1595
+ log.error(` ❌ Video generation failed: ${result.error}`);
1596
+ }
1597
+ return result;
1598
+ }
1599
+ catch (error) {
1600
+ const errorMsg = error instanceof Error ? error.message : String(error);
1601
+ log.error(` ❌ Video generation failed: ${errorMsg}`);
1602
+ return {
1603
+ success: false,
1604
+ contentType: 'video',
1605
+ error: errorMsg,
1606
+ };
1607
+ }
1608
+ }
1609
+ /**
1610
+ * Navigate to Discussion panel (chat)
1611
+ */
1612
+ async navigateToDiscussion() {
1613
+ const discussionSelectors = [
1614
+ 'div.mdc-tab:has-text("Discussion")',
1615
+ '.mat-mdc-tab:has-text("Discussion")',
1616
+ '[role="tab"]:has-text("Discussion")',
1617
+ 'div.mdc-tab >> text=Discussion',
1618
+ ];
1619
+ for (const selector of discussionSelectors) {
1620
+ try {
1621
+ const el = this.page.locator(selector).first();
1622
+ if (await el.isVisible({ timeout: 2000 })) {
1623
+ // Check if already selected
1624
+ const isActive = (await el.getAttribute('aria-selected')) === 'true' ||
1625
+ (await el.getAttribute('class'))?.includes('mdc-tab--active');
1626
+ if (!isActive) {
1627
+ await el.click();
1628
+ await randomDelay(500, 800);
1629
+ log.info(` ✅ Clicked Discussion tab`);
1630
+ }
1631
+ else {
1632
+ log.info(` ✅ Discussion tab already active`);
1633
+ }
1634
+ return;
1635
+ }
1636
+ }
1637
+ catch {
1638
+ continue;
1639
+ }
1640
+ }
1641
+ // Quick check if there's any chat content visible (might already be on Discussion)
1642
+ try {
1643
+ const chatContent = this.page
1644
+ .locator('[class*="chat"], [class*="discussion"], [class*="message"]')
1645
+ .first();
1646
+ if (await chatContent.isVisible({ timeout: 1000 })) {
1647
+ log.info(` ℹ️ Discussion content appears accessible`);
1648
+ return;
1649
+ }
1650
+ }
1651
+ catch {
1652
+ // Continue to error
1653
+ }
1654
+ // Fail-fast: throw error if Discussion tab not found
1655
+ throw new Error('Discussion tab not found - notebook may not have a chat history');
1656
+ }
1657
+ /**
1658
+ * Navigate to Studio panel
1659
+ */
1660
+ async navigateToStudio() {
1661
+ // Updated selectors based on current NotebookLM UI (Dec 2024)
1662
+ // The tabs are: Sources | Discussion | Studio
1663
+ // Tab class: mdc-tab mat-mdc-tab mat-focus-indicator
1664
+ const studioSelectors = [
1665
+ // Material Design tabs (bilingual FR/EN via i18n)
1666
+ ...i18nSelectors('div.mdc-tab:has-text("{text}")', 'tabs', 'studio'),
1667
+ ...i18nSelectors('.mat-mdc-tab:has-text("{text}")', 'tabs', 'studio'),
1668
+ ...i18nSelectors('[role="tab"]:has-text("{text}")', 'tabs', 'studio'),
1669
+ ...i18nSelectors('div.mdc-tab >> text={text}', 'tabs', 'studio'),
1670
+ '.notebook-guide', // Legacy fallback
1671
+ ];
1672
+ for (const selector of studioSelectors) {
1673
+ try {
1674
+ const el = this.page.locator(selector).first();
1675
+ if (await el.isVisible({ timeout: 2000 })) {
1676
+ // Check if already selected
1677
+ const isActive = (await el.getAttribute('aria-selected')) === 'true' ||
1678
+ (await el.getAttribute('class'))?.includes('mdc-tab--active');
1679
+ if (!isActive) {
1680
+ await el.click();
1681
+ await randomDelay(800, 1200);
1682
+ log.info(` ✅ Clicked Studio tab`);
1683
+ }
1684
+ else {
1685
+ log.info(` ✅ Studio tab already active`);
1686
+ }
1687
+ return;
1688
+ }
1689
+ }
1690
+ catch {
1691
+ continue;
1692
+ }
1693
+ }
1694
+ // Try clicking by finding the tab list and clicking the third tab
1695
+ try {
1696
+ const tabList = this.page.locator('.mat-mdc-tab-list .mdc-tab').nth(2); // Studio is 3rd tab (0-indexed)
1697
+ if (await tabList.isVisible({ timeout: 1000 })) {
1698
+ await tabList.click();
1699
+ await randomDelay(800, 1200);
1700
+ log.info(` ✅ Studio tab accessed via tab list`);
1701
+ return;
1702
+ }
1703
+ }
1704
+ catch {
1705
+ // Continue to fallback
1706
+ }
1707
+ log.warning(` ⚠️ Could not find Studio tab, content generation may fail`);
1708
+ }
1709
+ /**
1710
+ * Add custom instructions for content generation
1711
+ */
1712
+ async addCustomInstructions(instructions) {
1713
+ const instructionSelectors = [
1714
+ 'textarea[placeholder*="instruction"]',
1715
+ 'textarea[placeholder*="focus"]',
1716
+ 'textarea[placeholder*="custom"]',
1717
+ '.custom-instructions textarea',
1718
+ ];
1719
+ for (const selector of instructionSelectors) {
1720
+ try {
1721
+ const textarea = await this.page.$(selector);
1722
+ if (textarea && (await textarea.isVisible())) {
1723
+ await textarea.fill(instructions);
1724
+ log.info(` ✅ Custom instructions added`);
1725
+ return;
1726
+ }
1727
+ }
1728
+ catch {
1729
+ continue;
1730
+ }
1731
+ }
1732
+ }
1733
+ /**
1734
+ * Wait for audio generation to complete
1735
+ */
1736
+ async waitForAudioGeneration() {
1737
+ log.info(` ⏳ Waiting for audio generation (this may take several minutes)...`);
1738
+ const timeout = 600000; // 10 minutes
1739
+ const startTime = Date.now();
1740
+ while (Date.now() - startTime < timeout) {
1741
+ // Check for errors
1742
+ const errorEl = await this.page.$('.error-message, [role="alert"]:has-text("error")');
1743
+ if (errorEl) {
1744
+ const errorText = await errorEl.textContent();
1745
+ return {
1746
+ success: false,
1747
+ contentType: 'audio_overview',
1748
+ error: errorText || 'Audio generation failed',
1749
+ status: 'failed',
1750
+ };
1751
+ }
1752
+ // Check for audio player (generation complete)
1753
+ const audioPlayer = await this.page.$('audio, .audio-player, [data-component="audio-player"]');
1754
+ if (audioPlayer) {
1755
+ log.success(` ✅ Audio Overview generated!`);
1756
+ return { success: true, contentType: 'audio_overview', status: 'ready' };
1757
+ }
1758
+ // Check progress
1759
+ const progressEl = await this.page.$('[role="progressbar"], .progress-bar');
1760
+ if (progressEl) {
1761
+ const progress = await progressEl.getAttribute('aria-valuenow');
1762
+ if (progress) {
1763
+ log.info(` ⏳ Generation progress: ${progress}%`);
1764
+ }
1765
+ }
1766
+ await this.page.waitForTimeout(5000);
1767
+ }
1768
+ return {
1769
+ success: false,
1770
+ contentType: 'audio_overview',
1771
+ error: 'Timeout waiting for audio generation',
1772
+ status: 'failed',
1773
+ };
1774
+ }
1775
+ // ============================================================================
1776
+ // Content Listing & Download
1777
+ // ============================================================================
1778
+ /**
1779
+ * Get overview of notebook content (sources and generated content)
1780
+ */
1781
+ async getContentOverview() {
1782
+ log.info(`📋 Getting notebook content overview...`);
1783
+ const sources = await this.listSources();
1784
+ const generatedContent = await this.listGeneratedContent();
1785
+ const hasAudioOverview = generatedContent.some((c) => c.type === 'audio_overview');
1786
+ return {
1787
+ sources,
1788
+ generatedContent,
1789
+ sourceCount: sources.length,
1790
+ hasAudioOverview,
1791
+ };
1792
+ }
1793
+ /**
1794
+ * List all sources in the notebook
1795
+ */
1796
+ async listSources() {
1797
+ const sources = [];
1798
+ try {
1799
+ // First ensure Sources panel is active
1800
+ await this.ensureSourcesPanel();
1801
+ await randomDelay(500, 800);
1802
+ // Try to find source names by looking at the actual text content visible on page
1803
+ // NotebookLM shows source names in spans/divs - look for text that looks like file names
1804
+ const seenNames = new Set();
1805
+ // Method 1: Look for elements with PDF-like text using Playwright locators
1806
+ try {
1807
+ // Find all elements containing .pdf text
1808
+ const pdfElements = await this.page.locator('text=/\\.pdf/i').all();
1809
+ if (pdfElements.length > 0) {
1810
+ log.info(` 📚 Found ${pdfElements.length} PDF elements`);
1811
+ for (const el of pdfElements) {
1812
+ try {
1813
+ const text = await el.textContent();
1814
+ if (text && text.length > 10 && !seenNames.has(text.trim())) {
1815
+ seenNames.add(text.trim());
1816
+ sources.push({
1817
+ id: `source-${sources.length}`,
1818
+ name: text.trim(),
1819
+ type: 'document',
1820
+ status: 'ready',
1821
+ });
1822
+ }
1823
+ }
1824
+ catch {
1825
+ continue;
1826
+ }
1827
+ }
1828
+ }
1829
+ // Also look for elements with [Author] format (brackets)
1830
+ if (sources.length === 0) {
1831
+ const bracketElements = await this.page.locator('text=/\\[.+\\]/').all();
1832
+ if (bracketElements.length > 0) {
1833
+ log.info(` 📚 Found ${bracketElements.length} bracketed elements`);
1834
+ for (const el of bracketElements) {
1835
+ try {
1836
+ const text = await el.textContent();
1837
+ if (text && text.length > 10 && !seenNames.has(text.trim())) {
1838
+ // Skip UI elements
1839
+ if (/^(Sources|Discussion|Studio|Sélectionner)/i.test(text.trim()))
1840
+ continue;
1841
+ seenNames.add(text.trim());
1842
+ sources.push({
1843
+ id: `source-${sources.length}`,
1844
+ name: text.trim(),
1845
+ type: 'document',
1846
+ status: 'ready',
1847
+ });
1848
+ }
1849
+ }
1850
+ catch {
1851
+ continue;
1852
+ }
1853
+ }
1854
+ }
1855
+ }
1856
+ }
1857
+ catch (error) {
1858
+ log.warning(` ⚠️ Text scan failed: ${error}`);
1859
+ }
1860
+ // Method 2: If no sources found, try looking at specific source list selectors
1861
+ if (sources.length === 0) {
1862
+ log.info(` 🔍 Trying alternative source selectors...`);
1863
+ // Look for any element that contains source text
1864
+ const sourceTextSelectors = [
1865
+ '.source-item-name',
1866
+ '.source-name',
1867
+ '[class*="source-item"] span:not(mat-icon)',
1868
+ '[class*="source"] span.mdc-list-item__primary-text',
1869
+ 'mat-list-item span',
1870
+ ];
1871
+ for (const selector of sourceTextSelectors) {
1872
+ try {
1873
+ const elements = await this.page.$$(selector);
1874
+ if (elements.length > 0) {
1875
+ log.info(` 📄 Found ${elements.length} elements with ${selector}`);
1876
+ for (const el of elements) {
1877
+ const text = (await el.textContent())?.trim();
1878
+ if (text && text.length > 5 && !seenNames.has(text)) {
1879
+ // Skip icon text
1880
+ if (text.match(/^(drive_pdf|markdown|more_vert|check)/i))
1881
+ continue;
1882
+ seenNames.add(text);
1883
+ sources.push({
1884
+ id: `source-${sources.length}`,
1885
+ name: text,
1886
+ type: 'document',
1887
+ status: 'ready',
1888
+ });
1889
+ }
1890
+ }
1891
+ if (sources.length > 0)
1892
+ break;
1893
+ }
1894
+ }
1895
+ catch {
1896
+ continue;
1897
+ }
1898
+ }
1899
+ }
1900
+ }
1901
+ catch (error) {
1902
+ log.warning(` ⚠️ Could not list sources: ${error}`);
1903
+ }
1904
+ return sources;
1905
+ }
1906
+ /**
1907
+ * Delete a source from the current notebook
1908
+ *
1909
+ * @param input - Either sourceId or sourceName to identify the source to delete
1910
+ * @returns Result indicating success or failure
1911
+ */
1912
+ async deleteSource(input) {
1913
+ const { sourceId, sourceName } = input;
1914
+ if (!sourceId && !sourceName) {
1915
+ return { success: false, error: 'Either sourceId or sourceName is required' };
1916
+ }
1917
+ log.info(`🗑️ Deleting source: ${sourceId || sourceName}`);
1918
+ try {
1919
+ // First, ensure we're on the Sources panel
1920
+ await this.ensureSourcesPanel();
1921
+ await randomDelay(500, 800);
1922
+ // Find the source element
1923
+ const sourceElement = await this.findSourceElement(sourceId, sourceName);
1924
+ if (!sourceElement) {
1925
+ return {
1926
+ success: false,
1927
+ error: `Source not found: ${sourceId || sourceName}`,
1928
+ };
1929
+ }
1930
+ // Get the source name for logging before deletion
1931
+ let deletedSourceName = sourceName;
1932
+ if (!deletedSourceName) {
1933
+ try {
1934
+ deletedSourceName = await sourceElement.$eval('.source-name, .title, [class*="name"], [class*="title"]', (e) => e.textContent?.trim() || 'Unknown');
1935
+ }
1936
+ catch {
1937
+ deletedSourceName = sourceId || 'Unknown';
1938
+ }
1939
+ }
1940
+ // Click on the source to select it
1941
+ await sourceElement.click();
1942
+ await randomDelay(300, 500);
1943
+ // Open the source menu (3-dot menu or right-click)
1944
+ const menuOpened = await this.openSourceMenu(sourceElement);
1945
+ if (!menuOpened) {
1946
+ // Try right-click as fallback
1947
+ log.info(` 🔍 Trying right-click on source...`);
1948
+ await sourceElement.click({ button: 'right' });
1949
+ await randomDelay(300, 500);
1950
+ }
1951
+ // Click delete option
1952
+ const deleteClicked = await this.clickDeleteOption();
1953
+ if (!deleteClicked) {
1954
+ return {
1955
+ success: false,
1956
+ error: 'Could not find delete option in menu',
1957
+ };
1958
+ }
1959
+ // Confirm deletion if prompted
1960
+ await this.confirmDeletion();
1961
+ // Wait for source to be removed
1962
+ await randomDelay(1000, 2000);
1963
+ // Verify deletion by checking if source is still present
1964
+ const stillExists = await this.findSourceElement(sourceId, sourceName);
1965
+ if (stillExists) {
1966
+ return {
1967
+ success: false,
1968
+ error: 'Source deletion may have failed - source still appears in list',
1969
+ };
1970
+ }
1971
+ log.success(` ✅ Source deleted: ${deletedSourceName}`);
1972
+ return {
1973
+ success: true,
1974
+ sourceId: sourceId,
1975
+ sourceName: deletedSourceName,
1976
+ };
1977
+ }
1978
+ catch (error) {
1979
+ const errorMsg = error instanceof Error ? error.message : String(error);
1980
+ log.error(`❌ Failed to delete source: ${errorMsg}`);
1981
+ return { success: false, error: errorMsg };
1982
+ }
1983
+ }
1984
+ /**
1985
+ * Find a source element by ID or name
1986
+ */
1987
+ async findSourceElement(sourceId, sourceName
1988
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1989
+ ) {
1990
+ log.info(` 🔍 Finding source: id="${sourceId}", name="${sourceName}"`);
1991
+ // METHOD 1: Direct text search (most reliable for NotebookLM)
1992
+ if (sourceName) {
1993
+ const directSelectors = [
1994
+ // Try to find element containing the source name text
1995
+ `:has-text("${sourceName}")`,
1996
+ `text="${sourceName}"`,
1997
+ `text=/.*${sourceName}.*/i`,
1998
+ ];
1999
+ for (const selector of directSelectors) {
2000
+ try {
2001
+ const el = this.page.locator(selector).first();
2002
+ if (await el.isVisible({ timeout: 1000 })) {
2003
+ log.info(` ✅ Found source via direct text: ${selector}`);
2004
+ return await el.elementHandle();
2005
+ }
2006
+ }
2007
+ catch {
2008
+ continue;
2009
+ }
2010
+ }
2011
+ }
2012
+ // METHOD 2: Look within sources panel structure
2013
+ const sourceItemSelectors = [
2014
+ // NotebookLM current UI structure (checkboxes with labels)
2015
+ 'mat-checkbox',
2016
+ '[class*="checkbox"]',
2017
+ // Standard list items
2018
+ '.source-item',
2019
+ '[data-item="source"]',
2020
+ '.sources-list-item',
2021
+ '[class*="source-list"] > div',
2022
+ '[class*="source-list"] > li',
2023
+ 'mat-list-item',
2024
+ '.mat-list-item',
2025
+ '[role="listitem"]',
2026
+ ];
2027
+ for (const selector of sourceItemSelectors) {
2028
+ try {
2029
+ const elements = await this.page.$$(selector);
2030
+ log.info(` 🔍 Checking ${elements.length} elements with selector: ${selector}`);
2031
+ for (const el of elements) {
2032
+ // Check by data-id attribute
2033
+ if (sourceId) {
2034
+ const dataId = await el.getAttribute('data-id');
2035
+ if (dataId === sourceId) {
2036
+ log.info(` ✅ Found source by data-id`);
2037
+ return el;
2038
+ }
2039
+ }
2040
+ // Check by name/title text content
2041
+ if (sourceName) {
2042
+ const textContent = await el.textContent();
2043
+ if (textContent && textContent.toLowerCase().includes(sourceName.toLowerCase())) {
2044
+ log.info(` ✅ Found source by text content: "${textContent.slice(0, 50)}..."`);
2045
+ return el;
2046
+ }
2047
+ // Also check specific name/title elements
2048
+ try {
2049
+ const nameText = await el.$eval('.source-name, .title, [class*="name"], [class*="title"], label, span', (e) => e.textContent?.trim() || '');
2050
+ if (nameText.toLowerCase().includes(sourceName.toLowerCase())) {
2051
+ log.info(` ✅ Found source by inner text: "${nameText}"`);
2052
+ return el;
2053
+ }
2054
+ }
2055
+ catch {
2056
+ // Element doesn't have a name/title child
2057
+ }
2058
+ }
2059
+ }
2060
+ }
2061
+ catch {
2062
+ continue;
2063
+ }
2064
+ }
2065
+ // Take debug screenshot
2066
+ try {
2067
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-find-source-failed.png');
2068
+ await this.page.screenshot({ path: screenshotPath });
2069
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
2070
+ }
2071
+ catch {
2072
+ /* ignore */
2073
+ }
2074
+ log.warning(` ⚠️ Source not found: ${sourceId || sourceName}`);
2075
+ return null;
2076
+ }
2077
+ /**
2078
+ * Open the source menu (3-dot menu)
2079
+ */
2080
+ async openSourceMenu(
2081
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2082
+ sourceElement) {
2083
+ const menuButtonSelectors = [
2084
+ // Material Design 3-dot menu button
2085
+ 'button:has(mat-icon:has-text("more_vert"))',
2086
+ 'button:has(mat-icon:has-text("more_horiz"))',
2087
+ 'button[aria-label*="menu" i]',
2088
+ 'button[aria-label*="options" i]',
2089
+ 'button[aria-label*="actions" i]',
2090
+ 'button[aria-label*="More" i]',
2091
+ 'button[aria-label*="Plus" i]',
2092
+ '.mat-mdc-icon-button:has(mat-icon)',
2093
+ '[class*="menu-button"]',
2094
+ '[class*="more-button"]',
2095
+ '[data-action="menu"]',
2096
+ // Generic icon buttons that might be the menu
2097
+ 'button.mat-icon-button',
2098
+ 'button.mdc-icon-button',
2099
+ ];
2100
+ // First, try to find the menu button within the source element
2101
+ for (const selector of menuButtonSelectors) {
2102
+ try {
2103
+ const menuBtn = await sourceElement.$(selector);
2104
+ if (menuBtn) {
2105
+ const isVisible = await menuBtn.isVisible();
2106
+ if (isVisible) {
2107
+ log.info(` ✅ Found menu button: ${selector}`);
2108
+ await menuBtn.click();
2109
+ await randomDelay(300, 500);
2110
+ return true;
2111
+ }
2112
+ }
2113
+ }
2114
+ catch {
2115
+ continue;
2116
+ }
2117
+ }
2118
+ // Hover over the source to reveal hidden menu button
2119
+ log.info(` 🔍 Hovering to reveal menu button...`);
2120
+ await sourceElement.hover();
2121
+ await randomDelay(500, 800);
2122
+ // Try again after hover
2123
+ for (const selector of menuButtonSelectors) {
2124
+ try {
2125
+ const menuBtn = await sourceElement.$(selector);
2126
+ if (menuBtn) {
2127
+ const isVisible = await menuBtn.isVisible();
2128
+ if (isVisible) {
2129
+ log.info(` ✅ Found menu button after hover: ${selector}`);
2130
+ await menuBtn.click();
2131
+ await randomDelay(300, 500);
2132
+ return true;
2133
+ }
2134
+ }
2135
+ }
2136
+ catch {
2137
+ continue;
2138
+ }
2139
+ }
2140
+ return false;
2141
+ }
2142
+ /**
2143
+ * Click the delete option in the menu
2144
+ */
2145
+ async clickDeleteOption() {
2146
+ const deleteSelectors = [
2147
+ // Menu item selectors (bilingual FR/EN via i18n)
2148
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'delete'),
2149
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'remove'),
2150
+ ...i18nSelectors('[role="menuitem"]:has-text("{text}")', 'buttons', 'delete'),
2151
+ ...i18nSelectors('[role="menuitem"]:has-text("{text}")', 'buttons', 'remove'),
2152
+ ...i18nSelectors('mat-menu-item:has-text("{text}")', 'buttons', 'delete'),
2153
+ ...i18nSelectors('.mat-menu-item:has-text("{text}")', 'buttons', 'delete'),
2154
+ // With icons
2155
+ 'button:has(mat-icon:has-text("delete"))',
2156
+ '[role="menuitem"]:has(mat-icon:has-text("delete"))',
2157
+ 'mat-menu-item:has(mat-icon:has-text("delete"))',
2158
+ // Aria labels
2159
+ 'button[aria-label*="Delete" i]',
2160
+ 'button[aria-label*="Remove" i]',
2161
+ 'button[aria-label*="Supprimer" i]',
2162
+ // Generic patterns
2163
+ '[data-action="delete"]',
2164
+ '[class*="delete"]',
2165
+ ];
2166
+ // Wait for menu to appear
2167
+ await randomDelay(300, 500);
2168
+ for (const selector of deleteSelectors) {
2169
+ try {
2170
+ const deleteBtn = this.page.locator(selector).first();
2171
+ if (await deleteBtn.isVisible({ timeout: 500 })) {
2172
+ log.info(` ✅ Found delete option: ${selector}`);
2173
+ await deleteBtn.click();
2174
+ await randomDelay(300, 500);
2175
+ return true;
2176
+ }
2177
+ }
2178
+ catch {
2179
+ continue;
2180
+ }
2181
+ }
2182
+ // Debug: list menu items
2183
+ log.warning(` ⚠️ Delete option not found, listing menu items...`);
2184
+ try {
2185
+ const menuItems = await this.page.$$('[role="menuitem"], .mat-menu-item, mat-menu-item');
2186
+ for (let i = 0; i < Math.min(menuItems.length, 5); i++) {
2187
+ const text = await menuItems[i].textContent();
2188
+ log.info(` 🔍 Menu item[${i}]: "${text?.trim()}"`);
2189
+ }
2190
+ }
2191
+ catch {
2192
+ // ignore
2193
+ }
2194
+ return false;
2195
+ }
2196
+ /**
2197
+ * Confirm deletion if a confirmation dialog appears
2198
+ */
2199
+ async confirmDeletion() {
2200
+ const confirmSelectors = [
2201
+ // Confirmation buttons (bilingual via i18n)
2202
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'confirm'),
2203
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'yes'),
2204
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'delete'),
2205
+ 'button:has-text("OK")',
2206
+ // Dialog confirm buttons
2207
+ '[role="dialog"] button.mat-primary',
2208
+ '[role="dialog"] button[color="primary"]',
2209
+ '[role="dialog"] button.mdc-button--raised',
2210
+ '.mat-dialog-actions button:not(:has-text("Cancel")):not(:has-text("Annuler"))',
2211
+ '.mdc-dialog__actions button:not(:has-text("Cancel")):not(:has-text("Annuler"))',
2212
+ // Aria patterns
2213
+ 'button[aria-label*="Confirm" i]',
2214
+ 'button[aria-label*="Delete" i]',
2215
+ ];
2216
+ // Wait a moment for dialog to appear
2217
+ await randomDelay(500, 800);
2218
+ // Check if a confirmation dialog is visible
2219
+ const dialogSelectors = ['[role="dialog"]', '.mat-dialog-container', '.mdc-dialog'];
2220
+ let dialogVisible = false;
2221
+ for (const dialogSelector of dialogSelectors) {
2222
+ try {
2223
+ const dialog = this.page.locator(dialogSelector).first();
2224
+ if (await dialog.isVisible({ timeout: 500 })) {
2225
+ dialogVisible = true;
2226
+ log.info(` 📋 Confirmation dialog detected`);
2227
+ break;
2228
+ }
2229
+ }
2230
+ catch {
2231
+ continue;
2232
+ }
2233
+ }
2234
+ if (!dialogVisible) {
2235
+ log.info(` ℹ️ No confirmation dialog detected`);
2236
+ return;
2237
+ }
2238
+ // Click confirm button
2239
+ for (const selector of confirmSelectors) {
2240
+ try {
2241
+ const confirmBtn = this.page.locator(selector).first();
2242
+ if (await confirmBtn.isVisible({ timeout: 500 })) {
2243
+ log.info(` ✅ Clicking confirm: ${selector}`);
2244
+ await confirmBtn.click();
2245
+ await randomDelay(300, 500);
2246
+ return;
2247
+ }
2248
+ }
2249
+ catch {
2250
+ continue;
2251
+ }
2252
+ }
2253
+ log.warning(` ⚠️ No confirm button found, pressing Enter as fallback`);
2254
+ await this.page.keyboard.press('Enter');
2255
+ }
2256
+ /**
2257
+ * List all generated content
2258
+ */
2259
+ async listGeneratedContent() {
2260
+ const content = [];
2261
+ try {
2262
+ // Check for audio overview
2263
+ const audioPlayer = await this.page.$('audio, .audio-player');
2264
+ if (audioPlayer) {
2265
+ content.push({
2266
+ id: 'audio-overview',
2267
+ type: 'audio_overview',
2268
+ name: 'Audio Overview',
2269
+ status: 'ready',
2270
+ createdAt: new Date().toISOString(),
2271
+ });
2272
+ }
2273
+ // Note: We only list audio_overview content now since other content types
2274
+ // (briefing_doc, study_guide, etc.) were removed as they were fake implementations.
2275
+ // Any notes in the Studio panel would have been created by the user directly in NotebookLM.
2276
+ }
2277
+ catch (error) {
2278
+ log.warning(` ⚠️ Could not list generated content: ${error}`);
2279
+ }
2280
+ return content;
2281
+ }
2282
+ /**
2283
+ * Download generated content (audio, video, infographic)
2284
+ *
2285
+ * For media content types that produce downloadable files:
2286
+ * - audio_overview: WAV audio file
2287
+ * - video: MP4 video file
2288
+ * - infographic: PNG image file
2289
+ *
2290
+ * Note: Text-based content (report, presentation, data_table) is returned
2291
+ * directly in the generation response, not as downloadable files.
2292
+ *
2293
+ * @param contentType Type of content to download
2294
+ * @param outputPath Optional path to save the file
2295
+ * @returns Download result with file path
2296
+ */
2297
+ async downloadContent(contentType, outputPath) {
2298
+ log.info(`📥 Downloading ${contentType}...`);
2299
+ // Handle Google export types (presentation -> Google Slides, data_table -> Google Sheets)
2300
+ if (contentType === 'presentation') {
2301
+ return await this.exportPresentationToGoogleSlides();
2302
+ }
2303
+ if (contentType === 'data_table') {
2304
+ return await this.exportDataTableToGoogleSheets();
2305
+ }
2306
+ // Report is truly text-based with no export option
2307
+ if (contentType === 'report') {
2308
+ return {
2309
+ success: false,
2310
+ error: `Content type 'report' is text-based and returned in the generation response. No file download available.`,
2311
+ };
2312
+ }
2313
+ try {
2314
+ // Navigate to the appropriate content panel
2315
+ const panelConfig = this.getContentPanelConfig(contentType);
2316
+ await this.navigateToContentPanel(panelConfig);
2317
+ // Find and click download button
2318
+ const downloadBtn = await this.findDownloadButton();
2319
+ if (!downloadBtn) {
2320
+ // For audio, try to get source URL directly
2321
+ if (contentType === 'audio_overview') {
2322
+ const audioSrc = await this.getAudioSourceUrl();
2323
+ if (audioSrc) {
2324
+ return { success: true, filePath: audioSrc, mimeType: 'audio/wav' };
2325
+ }
2326
+ }
2327
+ throw new Error('Download button not found');
2328
+ }
2329
+ // Set up download handling and click
2330
+ const downloadPromise = this.page.waitForEvent('download', { timeout: 60000 });
2331
+ await downloadBtn.click();
2332
+ const download = await downloadPromise;
2333
+ // Save the file
2334
+ const suggestedName = download.suggestedFilename();
2335
+ const savePath = outputPath || path.join(CONFIG.dataDir, suggestedName);
2336
+ await download.saveAs(savePath);
2337
+ // Determine MIME type
2338
+ const mimeTypes = {
2339
+ audio_overview: 'audio/wav',
2340
+ video: 'video/mp4',
2341
+ infographic: 'image/png',
2342
+ };
2343
+ log.success(` ✅ ${contentType} downloaded: ${savePath}`);
2344
+ return {
2345
+ success: true,
2346
+ filePath: savePath,
2347
+ mimeType: mimeTypes[contentType] || 'application/octet-stream',
2348
+ };
2349
+ }
2350
+ catch (error) {
2351
+ const errorMsg = error instanceof Error ? error.message : String(error);
2352
+ return { success: false, error: `Download failed: ${errorMsg}` };
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Get panel configuration for a content type
2357
+ */
2358
+ getContentPanelConfig(contentType) {
2359
+ const configs = {
2360
+ audio_overview: {
2361
+ tabSelectors: [
2362
+ '[role="tab"]:has-text("Audio Overview")',
2363
+ '[role="tab"]:has-text("Audio")',
2364
+ 'button:has-text("Audio Overview")',
2365
+ '[aria-label*="Audio"]',
2366
+ ],
2367
+ cardSelectors: [
2368
+ '.audio-overview-card',
2369
+ '[data-type="audio"]',
2370
+ 'button:has-text("Deep Dive")',
2371
+ ],
2372
+ },
2373
+ video: {
2374
+ tabSelectors: [
2375
+ '[role="tab"]:has-text("Video")',
2376
+ 'button:has-text("Video")',
2377
+ '[aria-label*="Video"]',
2378
+ ],
2379
+ cardSelectors: ['.video-card', '[data-type="video"]', 'video'],
2380
+ },
2381
+ infographic: {
2382
+ tabSelectors: [
2383
+ '[role="tab"]:has-text("Infographic")',
2384
+ 'button:has-text("Infographic")',
2385
+ '[aria-label*="Infographic"]',
2386
+ ],
2387
+ cardSelectors: [
2388
+ '.infographic-card',
2389
+ '[data-type="infographic"]',
2390
+ 'img[class*="infographic"]',
2391
+ ],
2392
+ },
2393
+ presentation: {
2394
+ tabSelectors: [
2395
+ '[role="tab"]:has-text("Presentation")',
2396
+ '[role="tab"]:has-text("Slides")',
2397
+ '[role="tab"]:has-text("Diaporama")',
2398
+ 'button:has-text("Presentation")',
2399
+ 'button:has-text("Slides")',
2400
+ '[aria-label*="Presentation"]',
2401
+ '[aria-label*="Slides"]',
2402
+ ],
2403
+ cardSelectors: [
2404
+ '.presentation-card',
2405
+ '.slides-card',
2406
+ '[data-type="presentation"]',
2407
+ '[data-type="slides"]',
2408
+ ],
2409
+ },
2410
+ data_table: {
2411
+ tabSelectors: [
2412
+ '[role="tab"]:has-text("Data Table")',
2413
+ '[role="tab"]:has-text("Table")',
2414
+ '[role="tab"]:has-text("Tableau")',
2415
+ 'button:has-text("Data Table")',
2416
+ 'button:has-text("Table")',
2417
+ '[aria-label*="Table"]',
2418
+ '[aria-label*="Data"]',
2419
+ ],
2420
+ cardSelectors: [
2421
+ '.data-table-card',
2422
+ '.table-card',
2423
+ '[data-type="data_table"]',
2424
+ '[data-type="table"]',
2425
+ ],
2426
+ },
2427
+ };
2428
+ return configs[contentType] || { tabSelectors: [], cardSelectors: [] };
2429
+ }
2430
+ /**
2431
+ * Navigate to content panel
2432
+ */
2433
+ async navigateToContentPanel(config) {
2434
+ // Try to click tab
2435
+ for (const selector of config.tabSelectors) {
2436
+ try {
2437
+ const tab = this.page.locator(selector).first();
2438
+ if (await tab.isVisible({ timeout: 500 })) {
2439
+ await tab.click();
2440
+ await randomDelay(500, 1000);
2441
+ break;
2442
+ }
2443
+ }
2444
+ catch {
2445
+ continue;
2446
+ }
2447
+ }
2448
+ // Try to click card
2449
+ for (const selector of config.cardSelectors) {
2450
+ try {
2451
+ const card = this.page.locator(selector).first();
2452
+ if (await card.isVisible({ timeout: 500 })) {
2453
+ await card.click();
2454
+ await randomDelay(500, 1000);
2455
+ break;
2456
+ }
2457
+ }
2458
+ catch {
2459
+ continue;
2460
+ }
2461
+ }
2462
+ }
2463
+ /**
2464
+ * Find download button on the page
2465
+ */
2466
+ async findDownloadButton() {
2467
+ const downloadSelectors = [
2468
+ 'button:has(mat-icon:has-text("download"))',
2469
+ 'button:has(mat-icon:has-text("file_download"))',
2470
+ 'button:has(mat-icon:has-text("get_app"))',
2471
+ 'button[aria-label*="Download"]',
2472
+ 'button[aria-label*="Télécharger"]',
2473
+ 'button[aria-label*="download"]',
2474
+ // Text-based patterns (bilingual via i18n)
2475
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'download'),
2476
+ 'a[download]',
2477
+ '.download-button',
2478
+ '[data-action="download"]',
2479
+ ];
2480
+ for (const selector of downloadSelectors) {
2481
+ try {
2482
+ const btn = this.page.locator(selector).first();
2483
+ if (await btn.isVisible({ timeout: 500 })) {
2484
+ log.info(` ✅ Found download button: ${selector}`);
2485
+ return btn;
2486
+ }
2487
+ }
2488
+ catch {
2489
+ continue;
2490
+ }
2491
+ }
2492
+ return null;
2493
+ }
2494
+ /**
2495
+ * Get audio source URL directly from audio element
2496
+ */
2497
+ async getAudioSourceUrl() {
2498
+ try {
2499
+ const audioEl = await this.page.$('audio');
2500
+ if (audioEl) {
2501
+ const src = await audioEl.getAttribute('src');
2502
+ if (src) {
2503
+ log.info(` ✅ Audio source URL found: ${src}`);
2504
+ return src;
2505
+ }
2506
+ }
2507
+ }
2508
+ catch {
2509
+ /* ignore */
2510
+ }
2511
+ return null;
2512
+ }
2513
+ /**
2514
+ * Download audio content
2515
+ * @deprecated Use downloadContent('audio_overview', outputPath) instead
2516
+ */
2517
+ async downloadAudio(outputPath) {
2518
+ log.info(`📥 Downloading audio...`);
2519
+ try {
2520
+ // First, navigate to the Audio Overview panel/tab
2521
+ log.info(` 📑 Looking for Audio Overview panel...`);
2522
+ const audioTabSelectors = [
2523
+ '[role="tab"]:has-text("Audio Overview")',
2524
+ '[role="tab"]:has-text("Audio")',
2525
+ 'button:has-text("Audio Overview")',
2526
+ 'button:has-text("Audio")',
2527
+ '[aria-label*="Audio"]',
2528
+ ];
2529
+ for (const selector of audioTabSelectors) {
2530
+ try {
2531
+ const tab = this.page.locator(selector).first();
2532
+ if (await tab.isVisible({ timeout: 500 })) {
2533
+ log.info(` ✅ Found Audio tab: ${selector}`);
2534
+ await tab.click();
2535
+ await randomDelay(500, 1000);
2536
+ break;
2537
+ }
2538
+ }
2539
+ catch {
2540
+ continue;
2541
+ }
2542
+ }
2543
+ // Look for Audio Overview card/section and click it if needed
2544
+ const audioCardSelectors = [
2545
+ '.audio-overview-card',
2546
+ '[data-type="audio"]',
2547
+ 'button:has-text("Deep Dive")',
2548
+ 'button:has-text("Conversation")',
2549
+ ];
2550
+ for (const selector of audioCardSelectors) {
2551
+ try {
2552
+ const card = this.page.locator(selector).first();
2553
+ if (await card.isVisible({ timeout: 500 })) {
2554
+ log.info(` ✅ Found Audio card: ${selector}`);
2555
+ await card.click();
2556
+ await randomDelay(500, 1000);
2557
+ break;
2558
+ }
2559
+ }
2560
+ catch {
2561
+ continue;
2562
+ }
2563
+ }
2564
+ // First try to open a menu (NotebookLM often has download in a three-dot menu)
2565
+ const menuTriggerSelectors = [
2566
+ 'button:has(mat-icon:has-text("more_vert"))',
2567
+ 'button:has(mat-icon:has-text("more_horiz"))',
2568
+ 'button[aria-label*="More"]',
2569
+ 'button[aria-label*="Options"]',
2570
+ 'button[aria-label*="Menu"]',
2571
+ 'button[aria-label*="plus"]',
2572
+ '.mat-mdc-menu-trigger',
2573
+ '[aria-haspopup="menu"]',
2574
+ ];
2575
+ for (const menuSelector of menuTriggerSelectors) {
2576
+ try {
2577
+ const menuBtn = this.page.locator(menuSelector).first();
2578
+ if (await menuBtn.isVisible({ timeout: 300 })) {
2579
+ log.info(` 🔍 Opening menu: ${menuSelector}`);
2580
+ await menuBtn.click();
2581
+ await randomDelay(300, 500);
2582
+ break;
2583
+ }
2584
+ }
2585
+ catch {
2586
+ continue;
2587
+ }
2588
+ }
2589
+ // Find download button (either direct or in menu) - bilingual via i18n
2590
+ const downloadSelectors = [
2591
+ // Menu item patterns (if menu was opened)
2592
+ ...i18nSelectors('[role="menuitem"]:has-text("{text}")', 'buttons', 'download'),
2593
+ ...i18nSelectors('mat-menu-item:has-text("{text}")', 'buttons', 'download'),
2594
+ '.mat-mdc-menu-item:has-text("Download")',
2595
+ // Material Design icon buttons
2596
+ 'button:has(mat-icon:has-text("download"))',
2597
+ 'button:has(mat-icon:has-text("file_download"))',
2598
+ 'button:has(mat-icon:has-text("get_app"))',
2599
+ // Aria labels
2600
+ 'button[aria-label*="Download"]',
2601
+ 'button[aria-label*="Télécharger"]',
2602
+ 'button[aria-label*="download"]',
2603
+ // Text patterns (bilingual via i18n)
2604
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'download'),
2605
+ // Icon buttons near audio
2606
+ '.audio-controls button:has(mat-icon)',
2607
+ '.audio-player button:has(mat-icon)',
2608
+ // Generic download patterns
2609
+ 'a[download]',
2610
+ '.download-button',
2611
+ '[data-action="download"]',
2612
+ ];
2613
+ let downloadBtn = null;
2614
+ for (const selector of downloadSelectors) {
2615
+ try {
2616
+ const btn = this.page.locator(selector).first();
2617
+ if (await btn.isVisible({ timeout: 500 })) {
2618
+ downloadBtn = btn;
2619
+ log.info(` ✅ Found download button: ${selector}`);
2620
+ break;
2621
+ }
2622
+ }
2623
+ catch {
2624
+ continue;
2625
+ }
2626
+ }
2627
+ if (!downloadBtn) {
2628
+ // Try to get audio source directly from audio element
2629
+ log.info(` 🔍 No download button, looking for audio element...`);
2630
+ const audioEl = await this.page.$('audio');
2631
+ if (audioEl) {
2632
+ const src = await audioEl.getAttribute('src');
2633
+ if (src) {
2634
+ log.info(` ✅ Audio source URL found: ${src}`);
2635
+ return {
2636
+ success: true,
2637
+ filePath: src,
2638
+ mimeType: 'audio/wav',
2639
+ };
2640
+ }
2641
+ }
2642
+ // Debug: list all buttons in the panel
2643
+ log.warning(` ⚠️ Download button not found, listing panel buttons...`);
2644
+ try {
2645
+ const buttons = await this.page.locator('button').all();
2646
+ for (let i = 0; i < Math.min(buttons.length, 10); i++) {
2647
+ const text = await buttons[i].textContent();
2648
+ const aria = await buttons[i].getAttribute('aria-label');
2649
+ log.info(` 🔍 Button[${i}]: text="${text?.trim()}", aria="${aria}"`);
2650
+ }
2651
+ }
2652
+ catch {
2653
+ /* ignore */
2654
+ }
2655
+ throw new Error('Download button not found');
2656
+ }
2657
+ // Set up download handling
2658
+ const downloadPromise = this.page.waitForEvent('download', { timeout: 30000 });
2659
+ await downloadBtn.click();
2660
+ const download = await downloadPromise;
2661
+ const suggestedName = download.suggestedFilename();
2662
+ const savePath = outputPath || path.join(CONFIG.dataDir, suggestedName);
2663
+ await download.saveAs(savePath);
2664
+ log.success(` ✅ Audio downloaded: ${savePath}`);
2665
+ return {
2666
+ success: true,
2667
+ filePath: savePath,
2668
+ mimeType: 'audio/wav',
2669
+ };
2670
+ }
2671
+ catch (error) {
2672
+ const errorMsg = error instanceof Error ? error.message : String(error);
2673
+ return { success: false, error: `Download failed: ${errorMsg}` };
2674
+ }
2675
+ }
2676
+ /**
2677
+ * Export presentation to Google Slides
2678
+ * Finds and clicks the "Open in Slides" button to get the Google Slides URL
2679
+ */
2680
+ async exportPresentationToGoogleSlides() {
2681
+ log.info(` 📤 Exporting presentation to Google Slides...`);
2682
+ try {
2683
+ // Navigate to presentation panel
2684
+ const panelConfig = this.getContentPanelConfig('presentation');
2685
+ await this.navigateToContentPanel(panelConfig);
2686
+ // Look for "Open in Slides" or similar export button
2687
+ const exportSelectors = [
2688
+ 'button:has-text("Open in Slides")',
2689
+ 'button:has-text("Ouvrir dans Slides")',
2690
+ 'button:has-text("Export to Slides")',
2691
+ 'button:has-text("Google Slides")',
2692
+ 'a[href*="docs.google.com/presentation"]',
2693
+ 'button[aria-label*="Slides"]',
2694
+ 'button[aria-label*="slides"]',
2695
+ 'button:has(mat-icon:has-text("slideshow"))',
2696
+ // Also look for download as PDF option
2697
+ 'button:has-text("Download PDF")',
2698
+ 'button:has-text("Télécharger PDF")',
2699
+ ];
2700
+ for (const selector of exportSelectors) {
2701
+ try {
2702
+ const btn = this.page.locator(selector).first();
2703
+ if (await btn.isVisible({ timeout: 1000 })) {
2704
+ log.info(` ✅ Found export button: ${selector}`);
2705
+ // Check if it's a direct link
2706
+ const href = await btn.getAttribute('href');
2707
+ if (href && href.includes('docs.google.com/presentation')) {
2708
+ log.success(` ✅ Google Slides URL found: ${href}`);
2709
+ return {
2710
+ success: true,
2711
+ googleSlidesUrl: href,
2712
+ mimeType: 'application/vnd.google-apps.presentation',
2713
+ };
2714
+ }
2715
+ // Click the button and wait for navigation or new tab
2716
+ const [newPage] = await Promise.all([
2717
+ this.page
2718
+ .context()
2719
+ .waitForEvent('page', { timeout: 10000 })
2720
+ .catch(() => null),
2721
+ btn.click(),
2722
+ ]);
2723
+ if (newPage) {
2724
+ const newUrl = newPage.url();
2725
+ await newPage.close();
2726
+ if (newUrl.includes('docs.google.com/presentation')) {
2727
+ log.success(` ✅ Google Slides URL: ${newUrl}`);
2728
+ return {
2729
+ success: true,
2730
+ googleSlidesUrl: newUrl,
2731
+ mimeType: 'application/vnd.google-apps.presentation',
2732
+ };
2733
+ }
2734
+ }
2735
+ // Check current page URL
2736
+ await randomDelay(2000, 3000);
2737
+ const currentUrl = this.page.url();
2738
+ if (currentUrl.includes('docs.google.com/presentation')) {
2739
+ log.success(` ✅ Navigated to Google Slides: ${currentUrl}`);
2740
+ return {
2741
+ success: true,
2742
+ googleSlidesUrl: currentUrl,
2743
+ mimeType: 'application/vnd.google-apps.presentation',
2744
+ };
2745
+ }
2746
+ }
2747
+ }
2748
+ catch {
2749
+ continue;
2750
+ }
2751
+ }
2752
+ return {
2753
+ success: false,
2754
+ error: 'Could not find Google Slides export button. The presentation may not be ready or the export feature is not available.',
2755
+ };
2756
+ }
2757
+ catch (error) {
2758
+ const errorMsg = error instanceof Error ? error.message : String(error);
2759
+ return { success: false, error: `Export to Google Slides failed: ${errorMsg}` };
2760
+ }
2761
+ }
2762
+ /**
2763
+ * Export data table to Google Sheets
2764
+ * Finds and clicks the "Open in Sheets" button to get the Google Sheets URL
2765
+ */
2766
+ async exportDataTableToGoogleSheets() {
2767
+ log.info(` 📤 Exporting data table to Google Sheets...`);
2768
+ try {
2769
+ // Navigate to data table panel
2770
+ const panelConfig = this.getContentPanelConfig('data_table');
2771
+ await this.navigateToContentPanel(panelConfig);
2772
+ // Look for "Open in Sheets" or similar export button
2773
+ const exportSelectors = [
2774
+ 'button:has-text("Open in Sheets")',
2775
+ 'button:has-text("Ouvrir dans Sheets")',
2776
+ 'button:has-text("Export to Sheets")',
2777
+ 'button:has-text("Google Sheets")',
2778
+ 'a[href*="docs.google.com/spreadsheets"]',
2779
+ 'button[aria-label*="Sheets"]',
2780
+ 'button[aria-label*="sheets"]',
2781
+ 'button:has(mat-icon:has-text("table_chart"))',
2782
+ 'button:has(mat-icon:has-text("grid_on"))',
2783
+ ];
2784
+ for (const selector of exportSelectors) {
2785
+ try {
2786
+ const btn = this.page.locator(selector).first();
2787
+ if (await btn.isVisible({ timeout: 1000 })) {
2788
+ log.info(` ✅ Found export button: ${selector}`);
2789
+ // Check if it's a direct link
2790
+ const href = await btn.getAttribute('href');
2791
+ if (href && href.includes('docs.google.com/spreadsheets')) {
2792
+ log.success(` ✅ Google Sheets URL found: ${href}`);
2793
+ return {
2794
+ success: true,
2795
+ googleSheetsUrl: href,
2796
+ mimeType: 'application/vnd.google-apps.spreadsheet',
2797
+ };
2798
+ }
2799
+ // Click the button and wait for navigation or new tab
2800
+ const [newPage] = await Promise.all([
2801
+ this.page
2802
+ .context()
2803
+ .waitForEvent('page', { timeout: 10000 })
2804
+ .catch(() => null),
2805
+ btn.click(),
2806
+ ]);
2807
+ if (newPage) {
2808
+ const newUrl = newPage.url();
2809
+ await newPage.close();
2810
+ if (newUrl.includes('docs.google.com/spreadsheets')) {
2811
+ log.success(` ✅ Google Sheets URL: ${newUrl}`);
2812
+ return {
2813
+ success: true,
2814
+ googleSheetsUrl: newUrl,
2815
+ mimeType: 'application/vnd.google-apps.spreadsheet',
2816
+ };
2817
+ }
2818
+ }
2819
+ // Check current page URL
2820
+ await randomDelay(2000, 3000);
2821
+ const currentUrl = this.page.url();
2822
+ if (currentUrl.includes('docs.google.com/spreadsheets')) {
2823
+ log.success(` ✅ Navigated to Google Sheets: ${currentUrl}`);
2824
+ return {
2825
+ success: true,
2826
+ googleSheetsUrl: currentUrl,
2827
+ mimeType: 'application/vnd.google-apps.spreadsheet',
2828
+ };
2829
+ }
2830
+ }
2831
+ }
2832
+ catch {
2833
+ continue;
2834
+ }
2835
+ }
2836
+ return {
2837
+ success: false,
2838
+ error: 'Could not find Google Sheets export button. The data table may not be ready or the export feature is not available.',
2839
+ };
2840
+ }
2841
+ catch (error) {
2842
+ const errorMsg = error instanceof Error ? error.message : String(error);
2843
+ return { success: false, error: `Export to Google Sheets failed: ${errorMsg}` };
2844
+ }
2845
+ }
2846
+ // ============================================================================
2847
+ // Notes Management
2848
+ // ============================================================================
2849
+ /**
2850
+ * Create a note in the NotebookLM Studio panel
2851
+ *
2852
+ * Notes are user-created annotations that appear in the notebook's Studio panel.
2853
+ * They allow you to save research findings, summaries, or key insights.
2854
+ *
2855
+ * @param input Note input with title and content
2856
+ * @returns NoteResult with success status
2857
+ */
2858
+ async createNote(input) {
2859
+ log.info(`📝 Creating note: "${input.title}"`);
2860
+ try {
2861
+ // Step 1: Navigate to Studio panel where notes are managed
2862
+ await this.navigateToStudio();
2863
+ await randomDelay(500, 1000);
2864
+ // Step 2: Look for "Add note" or "+" button in the Studio panel (bilingual via i18n)
2865
+ const addNoteSelectors = [
2866
+ // Primary selectors for Add Note button (bilingual via i18n)
2867
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'addNote'),
2868
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'newNote'),
2869
+ // Icon button patterns
2870
+ 'button[aria-label*="Add note"]',
2871
+ 'button[aria-label*="add note" i]',
2872
+ 'button[aria-label*="Ajouter"]',
2873
+ 'button[aria-label*="New note"]',
2874
+ // Material Design patterns
2875
+ 'button:has(mat-icon:has-text("add"))',
2876
+ 'button:has(mat-icon:has-text("note_add"))',
2877
+ 'button:has(mat-icon:has-text("post_add"))',
2878
+ '.mat-mdc-icon-button[aria-label*="note" i]',
2879
+ '.mat-mdc-icon-button[aria-label*="add" i]',
2880
+ // Studio panel specific
2881
+ '[class*="studio"] button:has(mat-icon:has-text("add"))',
2882
+ '[class*="notes"] button:has(mat-icon:has-text("add"))',
2883
+ // Generic add patterns in notes section
2884
+ '.notes-section button',
2885
+ '.note-list button.add',
2886
+ '[data-testid*="add-note"]',
2887
+ '[data-action="add-note"]',
2888
+ ];
2889
+ let addButtonFound = false;
2890
+ for (const selector of addNoteSelectors) {
2891
+ try {
2892
+ const btn = this.page.locator(selector).first();
2893
+ if (await btn.isVisible({ timeout: 1000 })) {
2894
+ log.info(` ✅ Found Add note button: ${selector}`);
2895
+ await realisticClick(this.page, selector, true);
2896
+ addButtonFound = true;
2897
+ await randomDelay(500, 1000);
2898
+ break;
2899
+ }
2900
+ }
2901
+ catch {
2902
+ continue;
2903
+ }
2904
+ }
2905
+ if (!addButtonFound) {
2906
+ // Debug: log available buttons
2907
+ log.warning(` ⚠️ Add note button not found, checking available elements...`);
2908
+ await this.debugStudioElements();
2909
+ return {
2910
+ success: false,
2911
+ error: 'Could not find Add note button in Studio panel',
2912
+ status: 'failed',
2913
+ };
2914
+ }
2915
+ // Step 3: Wait for note editor dialog/panel to appear
2916
+ await randomDelay(500, 1000);
2917
+ // Step 4: Find and fill the title input
2918
+ const titleSelectors = [
2919
+ // Common title input patterns
2920
+ 'input[placeholder*="Title"]',
2921
+ 'input[placeholder*="title"]',
2922
+ 'input[placeholder*="Titre"]',
2923
+ 'input[placeholder*="titre"]',
2924
+ 'input[placeholder*="Note title"]',
2925
+ 'input[name="title"]',
2926
+ 'input[aria-label*="title" i]',
2927
+ // Material Design inputs
2928
+ '.mat-form-field input',
2929
+ 'mat-form-field input',
2930
+ // Dialog/modal specific
2931
+ '[role="dialog"] input[type="text"]:first-of-type',
2932
+ '.note-editor input:first-of-type',
2933
+ '.note-form input:first-of-type',
2934
+ // Generic text input in note context
2935
+ '[class*="note"] input[type="text"]',
2936
+ ];
2937
+ let titleInput = null;
2938
+ for (const selector of titleSelectors) {
2939
+ try {
2940
+ const input = this.page.locator(selector).first();
2941
+ if (await input.isVisible({ timeout: 1000 })) {
2942
+ titleInput = input;
2943
+ log.info(` ✅ Found title input: ${selector}`);
2944
+ break;
2945
+ }
2946
+ }
2947
+ catch {
2948
+ continue;
2949
+ }
2950
+ }
2951
+ if (titleInput) {
2952
+ await titleInput.fill(input.title);
2953
+ log.info(` ✅ Title entered: "${input.title}"`);
2954
+ await randomDelay(300, 500);
2955
+ }
2956
+ else {
2957
+ log.warning(` ⚠️ Title input not found, proceeding without title`);
2958
+ }
2959
+ // Step 5: Find and fill the content textarea
2960
+ const contentSelectors = [
2961
+ // Textarea patterns
2962
+ 'textarea[placeholder*="content"]',
2963
+ 'textarea[placeholder*="Content"]',
2964
+ 'textarea[placeholder*="note"]',
2965
+ 'textarea[placeholder*="Note"]',
2966
+ 'textarea[placeholder*="Contenu"]',
2967
+ 'textarea[placeholder*="Write"]',
2968
+ 'textarea[placeholder*="write"]',
2969
+ 'textarea[name="content"]',
2970
+ 'textarea[aria-label*="content" i]',
2971
+ 'textarea[aria-label*="note" i]',
2972
+ // Material Design
2973
+ '.mat-form-field textarea',
2974
+ 'mat-form-field textarea',
2975
+ // Dialog/modal specific
2976
+ '[role="dialog"] textarea',
2977
+ '.note-editor textarea',
2978
+ '.note-form textarea',
2979
+ // Rich text editor patterns
2980
+ '[contenteditable="true"]',
2981
+ '.ProseMirror',
2982
+ '.ql-editor',
2983
+ // Generic textarea in note context
2984
+ '[class*="note"] textarea',
2985
+ ];
2986
+ let contentInput = null;
2987
+ for (const selector of contentSelectors) {
2988
+ try {
2989
+ const input = this.page.locator(selector).first();
2990
+ if (await input.isVisible({ timeout: 1000 })) {
2991
+ contentInput = input;
2992
+ log.info(` ✅ Found content input: ${selector}`);
2993
+ break;
2994
+ }
2995
+ }
2996
+ catch {
2997
+ continue;
2998
+ }
2999
+ }
3000
+ if (contentInput) {
3001
+ // Check if it's a contenteditable element
3002
+ const isContentEditable = await contentInput.getAttribute('contenteditable');
3003
+ if (isContentEditable === 'true') {
3004
+ await contentInput.click();
3005
+ await this.page.keyboard.type(input.content);
3006
+ }
3007
+ else {
3008
+ await contentInput.fill(input.content);
3009
+ }
3010
+ log.info(` ✅ Content entered (${input.content.length} chars)`);
3011
+ await randomDelay(300, 500);
3012
+ }
3013
+ else {
3014
+ log.warning(` ⚠️ Content input not found`);
3015
+ return {
3016
+ success: false,
3017
+ error: 'Could not find content input field',
3018
+ status: 'failed',
3019
+ };
3020
+ }
3021
+ // Step 6: Save the note by clicking Save/Done button (bilingual via i18n)
3022
+ const saveSelectors = [
3023
+ // Primary save buttons (bilingual via i18n)
3024
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'save'),
3025
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'done'),
3026
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'create'),
3027
+ ...i18nSelectors('button:has-text("{text}")', 'buttons', 'add'),
3028
+ // Icon buttons
3029
+ 'button:has(mat-icon:has-text("check"))',
3030
+ 'button:has(mat-icon:has-text("save"))',
3031
+ 'button:has(mat-icon:has-text("done"))',
3032
+ // Submit button
3033
+ 'button[type="submit"]',
3034
+ // Material Design primary button
3035
+ 'button.mat-flat-button',
3036
+ 'button.mdc-button--raised',
3037
+ 'button[color="primary"]',
3038
+ // Dialog actions
3039
+ '[role="dialog"] button:not(:has-text("Cancel")):not(:has-text("Annuler"))',
3040
+ '.mat-dialog-actions button:not(:has-text("Cancel"))',
3041
+ '.mdc-dialog__actions button:not(:has-text("Cancel"))',
3042
+ ];
3043
+ let saveButtonFound = false;
3044
+ for (const selector of saveSelectors) {
3045
+ try {
3046
+ const btn = this.page.locator(selector).first();
3047
+ if (await btn.isVisible({ timeout: 500 })) {
3048
+ log.info(` ✅ Found save button: ${selector}`);
3049
+ // Check if button is enabled before clicking
3050
+ const isEnabled = await btn.isEnabled();
3051
+ log.info(` ℹ️ Button enabled: ${isEnabled}`);
3052
+ // Use force click with short timeout to avoid blocking
3053
+ await btn.click({ force: true, timeout: 5000 });
3054
+ log.info(` ✅ Clicked save button`);
3055
+ saveButtonFound = true;
3056
+ // Wait for dialog to close (max 5 seconds - faster feedback)
3057
+ try {
3058
+ await this.page.waitForSelector('[role="dialog"]', {
3059
+ state: 'hidden',
3060
+ timeout: 5000,
3061
+ });
3062
+ log.info(` ✅ Dialog closed after save`);
3063
+ }
3064
+ catch {
3065
+ // Dialog might not close - that's okay, we clicked
3066
+ log.info(` ⚠️ Dialog still visible after click`);
3067
+ }
3068
+ await randomDelay(300, 500);
3069
+ break;
3070
+ }
3071
+ }
3072
+ catch (e) {
3073
+ log.debug(` ℹ️ Selector ${selector} failed: ${e}`);
3074
+ continue;
3075
+ }
3076
+ }
3077
+ if (!saveButtonFound) {
3078
+ // Try pressing Enter as fallback
3079
+ log.info(` ⌨️ No save button found, pressing Enter as fallback`);
3080
+ await this.page.keyboard.press('Enter');
3081
+ // Wait for dialog to close
3082
+ try {
3083
+ await this.page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 10000 });
3084
+ }
3085
+ catch {
3086
+ // Continue anyway
3087
+ }
3088
+ await randomDelay(500, 1000);
3089
+ }
3090
+ // Step 7: Verify note was created by checking for its presence
3091
+ await randomDelay(1000, 2000);
3092
+ // Look for the note in the list
3093
+ const noteVerifySelectors = [
3094
+ `[class*="note"]:has-text("${input.title.substring(0, 20)}")`,
3095
+ `.note-item:has-text("${input.title.substring(0, 20)}")`,
3096
+ `[data-note-title="${input.title}"]`,
3097
+ ];
3098
+ for (const selector of noteVerifySelectors) {
3099
+ try {
3100
+ const note = this.page.locator(selector).first();
3101
+ if (await note.isVisible({ timeout: 2000 })) {
3102
+ log.success(` ✅ Note created successfully: "${input.title}"`);
3103
+ return {
3104
+ success: true,
3105
+ noteTitle: input.title,
3106
+ status: 'created',
3107
+ };
3108
+ }
3109
+ }
3110
+ catch {
3111
+ continue;
3112
+ }
3113
+ }
3114
+ // If we can't verify but no errors occurred, assume success
3115
+ log.success(` ✅ Note creation completed: "${input.title}"`);
3116
+ return {
3117
+ success: true,
3118
+ noteTitle: input.title,
3119
+ status: 'created',
3120
+ };
3121
+ }
3122
+ catch (error) {
3123
+ const errorMsg = error instanceof Error ? error.message : String(error);
3124
+ log.error(` ❌ Note creation failed: ${errorMsg}`);
3125
+ return {
3126
+ success: false,
3127
+ error: errorMsg,
3128
+ status: 'failed',
3129
+ };
3130
+ }
3131
+ }
3132
+ /**
3133
+ * Debug helper to log Studio panel elements
3134
+ */
3135
+ async debugStudioElements() {
3136
+ try {
3137
+ // Log all buttons in the Studio panel
3138
+ const studioButtons = await this.page
3139
+ .locator('[class*="studio"] button, [class*="Studio"] button')
3140
+ .all();
3141
+ log.info(` 🔍 DEBUG: Found ${studioButtons.length} buttons in Studio panel`);
3142
+ for (let i = 0; i < Math.min(studioButtons.length, 10); i++) {
3143
+ const btn = studioButtons[i];
3144
+ const ariaLabel = await btn.getAttribute('aria-label');
3145
+ const text = await btn.textContent();
3146
+ const classes = await btn.getAttribute('class');
3147
+ log.info(` 🔍 Button[${i}]: aria="${ariaLabel}", text="${text?.trim()}", class="${classes?.substring(0, 50)}"`);
3148
+ }
3149
+ // Also check for any visible buttons on the page
3150
+ const allButtons = await this.page.locator('button').all();
3151
+ log.info(` 🔍 Total buttons on page: ${allButtons.length}`);
3152
+ }
3153
+ catch (e) {
3154
+ log.warning(` ⚠️ Debug failed: ${e}`);
3155
+ }
3156
+ }
3157
+ // ============================================================================
3158
+ // Save Chat to Note
3159
+ // ============================================================================
3160
+ /**
3161
+ * Save the current chat/discussion to a note
3162
+ *
3163
+ * This method extracts all messages from the NotebookLM chat/discussion panel
3164
+ * and creates a note with the chat summary.
3165
+ *
3166
+ * @param input Optional input with custom title
3167
+ * @returns SaveChatToNoteResult with success status and message count
3168
+ */
3169
+ async saveChatToNote(input = {}) {
3170
+ const title = input.title || 'Chat Summary';
3171
+ log.info(`💬 Saving chat to note: "${title}"`);
3172
+ // Overall timeout for the entire operation (60 seconds)
3173
+ const OPERATION_TIMEOUT = 60000;
3174
+ const timeoutPromise = new Promise((_, reject) => {
3175
+ setTimeout(() => reject(new Error('Save chat to note timed out after 60 seconds')), OPERATION_TIMEOUT);
3176
+ });
3177
+ try {
3178
+ // Wrap the operation with a timeout
3179
+ return await Promise.race([this.performSaveChatToNote(title), timeoutPromise]);
3180
+ }
3181
+ catch (error) {
3182
+ const errorMsg = error instanceof Error ? error.message : String(error);
3183
+ log.error(` ❌ Failed to save chat to note: ${errorMsg}`);
3184
+ return {
3185
+ success: false,
3186
+ noteTitle: title,
3187
+ status: 'failed',
3188
+ error: errorMsg,
3189
+ };
3190
+ }
3191
+ }
3192
+ /**
3193
+ * Internal method to perform the save chat to note operation
3194
+ */
3195
+ async performSaveChatToNote(title) {
3196
+ try {
3197
+ // Step 1: Navigate to Discussion panel to access chat messages
3198
+ await this.navigateToDiscussion();
3199
+ await randomDelay(500, 1000);
3200
+ // Step 2: Extract chat messages (with shorter timeout for individual ops)
3201
+ const chatMessages = await this.extractChatMessages();
3202
+ if (chatMessages.length === 0) {
3203
+ return {
3204
+ success: false,
3205
+ noteTitle: title,
3206
+ status: 'failed',
3207
+ error: 'No chat messages found to save',
3208
+ };
3209
+ }
3210
+ log.info(` 📊 Extracted ${chatMessages.length} messages from chat`);
3211
+ // Step 3: Format messages into note content
3212
+ const noteContent = this.formatChatAsNote(chatMessages, title);
3213
+ // Step 4: Create the note using existing createNote method
3214
+ const noteResult = await this.createNote({
3215
+ title,
3216
+ content: noteContent,
3217
+ });
3218
+ if (noteResult.success) {
3219
+ log.success(` ✅ Chat saved to note: "${title}" (${chatMessages.length} messages)`);
3220
+ return {
3221
+ success: true,
3222
+ noteTitle: title,
3223
+ status: 'created',
3224
+ messageCount: chatMessages.length,
3225
+ };
3226
+ }
3227
+ else {
3228
+ return {
3229
+ success: false,
3230
+ noteTitle: title,
3231
+ status: 'failed',
3232
+ error: noteResult.error || 'Failed to create note',
3233
+ };
3234
+ }
3235
+ }
3236
+ catch (error) {
3237
+ const errorMsg = error instanceof Error ? error.message : String(error);
3238
+ log.error(` ❌ Failed to save chat to note: ${errorMsg}`);
3239
+ return {
3240
+ success: false,
3241
+ noteTitle: title,
3242
+ status: 'failed',
3243
+ error: errorMsg,
3244
+ };
3245
+ }
3246
+ }
3247
+ /**
3248
+ * Extract chat messages from the Discussion panel
3249
+ *
3250
+ * @returns Array of message objects with role (user/assistant) and content
3251
+ */
3252
+ async extractChatMessages() {
3253
+ const messages = [];
3254
+ try {
3255
+ // Selectors for user messages (questions)
3256
+ const userMessageSelectors = [
3257
+ // NotebookLM user message patterns
3258
+ '.user-message',
3259
+ '[data-role="user"]',
3260
+ '[class*="user-message"]',
3261
+ '[class*="user_message"]',
3262
+ '.chat-message.user',
3263
+ '.message.user',
3264
+ // Material Design chat patterns
3265
+ '.mat-chat-user-message',
3266
+ '.mdc-chat-user-message',
3267
+ // Generic patterns
3268
+ '[data-message-role="user"]',
3269
+ '[data-sender="user"]',
3270
+ ];
3271
+ // Selectors for AI messages (responses)
3272
+ const aiMessageSelectors = [
3273
+ // NotebookLM AI response patterns
3274
+ '.ai-message',
3275
+ '.assistant-message',
3276
+ '[data-role="assistant"]',
3277
+ '[data-role="model"]',
3278
+ '[class*="ai-message"]',
3279
+ '[class*="assistant-message"]',
3280
+ '[class*="model-message"]',
3281
+ '.chat-message.assistant',
3282
+ '.chat-message.ai',
3283
+ '.message.assistant',
3284
+ // Material Design patterns
3285
+ '.mat-chat-ai-message',
3286
+ '.mdc-chat-ai-message',
3287
+ // Generic patterns
3288
+ '[data-message-role="assistant"]',
3289
+ '[data-message-role="model"]',
3290
+ '[data-sender="assistant"]',
3291
+ '[data-sender="model"]',
3292
+ // Markdown response container (common in NotebookLM)
3293
+ '.response-container',
3294
+ '.markdown-body',
3295
+ '[class*="response"]',
3296
+ ];
3297
+ // Try to find all message containers in order
3298
+ const allMessageSelectors = [
3299
+ // Combined message container patterns
3300
+ '.chat-message',
3301
+ '.message-item',
3302
+ '[class*="message-container"]',
3303
+ '.messages-list > div',
3304
+ '.chat-scroll-container > div',
3305
+ '[role="listitem"]',
3306
+ // NotebookLM specific patterns
3307
+ '[class*="chat"] [class*="message"]',
3308
+ '[class*="discussion"] [class*="message"]',
3309
+ ];
3310
+ // First, try to find all messages in sequence
3311
+ for (const containerSelector of allMessageSelectors) {
3312
+ try {
3313
+ const messageContainers = await this.page.locator(containerSelector).all();
3314
+ if (messageContainers.length > 0) {
3315
+ log.info(` 🔍 Found ${messageContainers.length} message containers with: ${containerSelector}`);
3316
+ for (const container of messageContainers) {
3317
+ if (!(await container.isVisible()))
3318
+ continue;
3319
+ const text = await container.textContent();
3320
+ if (!text || text.trim().length === 0)
3321
+ continue;
3322
+ // Determine if user or AI message based on class/attributes
3323
+ const classes = (await container.getAttribute('class')) || '';
3324
+ const role = (await container.getAttribute('data-role')) ||
3325
+ (await container.getAttribute('data-sender')) ||
3326
+ '';
3327
+ const isUser = /user|human|question/i.test(classes) || /user|human/i.test(role);
3328
+ const isAI = /ai|assistant|model|response|answer/i.test(classes) ||
3329
+ /assistant|model/i.test(role);
3330
+ if (isUser) {
3331
+ messages.push({ role: 'user', content: text.trim() });
3332
+ }
3333
+ else if (isAI) {
3334
+ messages.push({ role: 'assistant', content: text.trim() });
3335
+ }
3336
+ }
3337
+ if (messages.length > 0) {
3338
+ break;
3339
+ }
3340
+ }
3341
+ }
3342
+ catch {
3343
+ continue;
3344
+ }
3345
+ }
3346
+ // Fallback: Try to extract user and AI messages separately
3347
+ if (messages.length === 0) {
3348
+ log.info(` 🔍 Trying fallback: separate user/AI message extraction...`);
3349
+ // Extract user messages
3350
+ for (const selector of userMessageSelectors) {
3351
+ try {
3352
+ const userMsgs = await this.page.locator(selector).all();
3353
+ for (const msg of userMsgs) {
3354
+ if (await msg.isVisible()) {
3355
+ const text = await msg.textContent();
3356
+ if (text && text.trim().length > 0) {
3357
+ messages.push({ role: 'user', content: text.trim() });
3358
+ }
3359
+ }
3360
+ }
3361
+ if (messages.length > 0)
3362
+ break;
3363
+ }
3364
+ catch {
3365
+ continue;
3366
+ }
3367
+ }
3368
+ // Extract AI messages
3369
+ for (const selector of aiMessageSelectors) {
3370
+ try {
3371
+ const aiMsgs = await this.page.locator(selector).all();
3372
+ for (const msg of aiMsgs) {
3373
+ if (await msg.isVisible()) {
3374
+ const text = await msg.textContent();
3375
+ if (text && text.trim().length > 0) {
3376
+ // Check if we already have this message (to avoid duplicates)
3377
+ const exists = messages.some((m) => m.content === text.trim());
3378
+ if (!exists) {
3379
+ messages.push({ role: 'assistant', content: text.trim() });
3380
+ }
3381
+ }
3382
+ }
3383
+ }
3384
+ if (messages.filter((m) => m.role === 'assistant').length > 0)
3385
+ break;
3386
+ }
3387
+ catch {
3388
+ continue;
3389
+ }
3390
+ }
3391
+ }
3392
+ // Last resort: Use snapshotAllResponses utility for AI responses
3393
+ if (messages.filter((m) => m.role === 'assistant').length === 0) {
3394
+ log.info(` 🔍 Using snapshotAllResponses for AI messages...`);
3395
+ const aiResponses = await snapshotAllResponses(this.page);
3396
+ for (const response of aiResponses) {
3397
+ if (response && response.trim().length > 0) {
3398
+ messages.push({ role: 'assistant', content: response.trim() });
3399
+ }
3400
+ }
3401
+ }
3402
+ log.info(` 📊 Total extracted: ${messages.filter((m) => m.role === 'user').length} user, ${messages.filter((m) => m.role === 'assistant').length} AI messages`);
3403
+ }
3404
+ catch (error) {
3405
+ log.warning(` ⚠️ Error extracting chat messages: ${error}`);
3406
+ }
3407
+ return messages;
3408
+ }
3409
+ /**
3410
+ * Format extracted chat messages as a note
3411
+ *
3412
+ * @param messages Array of chat messages
3413
+ * @param title Note title
3414
+ * @returns Formatted note content
3415
+ */
3416
+ formatChatAsNote(messages, title) {
3417
+ const lines = [];
3418
+ lines.push(`# ${title}`);
3419
+ lines.push('');
3420
+ lines.push(`*Saved from NotebookLM chat on ${new Date().toLocaleString()}*`);
3421
+ lines.push('');
3422
+ lines.push('---');
3423
+ lines.push('');
3424
+ for (const msg of messages) {
3425
+ if (msg.role === 'user') {
3426
+ lines.push(`**User:**`);
3427
+ lines.push(msg.content);
3428
+ lines.push('');
3429
+ }
3430
+ else {
3431
+ lines.push(`**NotebookLM:**`);
3432
+ lines.push(msg.content);
3433
+ lines.push('');
3434
+ }
3435
+ }
3436
+ lines.push('---');
3437
+ lines.push(`*${messages.length} messages total*`);
3438
+ return lines.join('\n');
3439
+ }
3440
+ // ============================================================================
3441
+ // Note to Source Conversion
3442
+ // ============================================================================
3443
+ /**
3444
+ * Convert a note to a source document in NotebookLM
3445
+ *
3446
+ * This feature allows users to convert an existing note into a source,
3447
+ * making the note content available for RAG queries. The method:
3448
+ * 1. Finds the note by title or ID in the Studio panel
3449
+ * 2. Attempts to use NotebookLM's native "Convert to source" feature if available
3450
+ * 3. Falls back to extracting note content and creating a text source if not
3451
+ *
3452
+ * @param input Note identification (title or ID)
3453
+ * @returns NoteToSourceResult with source information
3454
+ */
3455
+ async convertNoteToSource(input) {
3456
+ const { noteTitle, noteId } = input;
3457
+ log.info(`📄 Converting note to source: "${noteTitle || noteId}"`);
3458
+ try {
3459
+ // Step 1: Navigate to Studio panel where notes are located
3460
+ await this.navigateToStudio();
3461
+ await randomDelay(500, 1000);
3462
+ // Step 2: Find the note in the Studio panel
3463
+ const noteElement = await this.findNoteElement(noteTitle, noteId);
3464
+ if (!noteElement) {
3465
+ return {
3466
+ success: false,
3467
+ error: `Note not found: "${noteTitle || noteId}"`,
3468
+ };
3469
+ }
3470
+ log.info(` ✅ Found note: "${noteTitle || noteId}"`);
3471
+ // Step 3: Click on the note to select it
3472
+ await noteElement.click();
3473
+ await randomDelay(300, 500);
3474
+ // Step 4: Try to find and use native "Convert to source" or "Add to sources" option
3475
+ const nativeConversionSuccess = await this.tryNativeNoteToSourceConversion(noteElement);
3476
+ if (nativeConversionSuccess) {
3477
+ log.success(` ✅ Note converted to source using native feature`);
3478
+ return {
3479
+ success: true,
3480
+ sourceName: noteTitle || noteId,
3481
+ };
3482
+ }
3483
+ // Step 5: Fallback - Extract note content and create a text source
3484
+ log.info(` ℹ️ Native conversion not available, using fallback (extract + add as text source)`);
3485
+ const noteContent = await this.extractNoteContent(noteElement, noteTitle);
3486
+ if (!noteContent) {
3487
+ return {
3488
+ success: false,
3489
+ error: 'Could not extract note content for conversion',
3490
+ };
3491
+ }
3492
+ // Create a text source with the note content
3493
+ const sourceTitle = `[Note] ${noteTitle || 'Converted Note'}`;
3494
+ const sourceResult = await this.addSource({
3495
+ type: 'text',
3496
+ text: noteContent,
3497
+ title: sourceTitle,
3498
+ });
3499
+ if (sourceResult.success) {
3500
+ log.success(` ✅ Note converted to source: "${sourceTitle}"`);
3501
+ return {
3502
+ success: true,
3503
+ sourceId: sourceResult.sourceId,
3504
+ sourceName: sourceResult.sourceName || sourceTitle,
3505
+ };
3506
+ }
3507
+ else {
3508
+ return {
3509
+ success: false,
3510
+ error: sourceResult.error || 'Failed to create source from note content',
3511
+ };
3512
+ }
3513
+ }
3514
+ catch (error) {
3515
+ const errorMsg = error instanceof Error ? error.message : String(error);
3516
+ log.error(` ❌ Note to source conversion failed: ${errorMsg}`);
3517
+ return {
3518
+ success: false,
3519
+ error: errorMsg,
3520
+ };
3521
+ }
3522
+ }
3523
+ /**
3524
+ * Find a note element in the Studio panel by title or ID
3525
+ */
3526
+ async findNoteElement(noteTitle, noteId
3527
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3528
+ ) {
3529
+ // Selectors for note items in the Studio panel
3530
+ const noteItemSelectors = [
3531
+ '.note-item',
3532
+ '[data-item="note"]',
3533
+ '.notes-list-item',
3534
+ '[class*="note-list"] > div',
3535
+ '[class*="note-list"] > li',
3536
+ '[class*="Note"] > div',
3537
+ '[class*="notes"] > div',
3538
+ 'mat-list-item:has([class*="note"])',
3539
+ '.mat-list-item:has([class*="note"])',
3540
+ '[role="listitem"]:has([class*="note"])',
3541
+ // Studio panel specific
3542
+ '[class*="studio"] [class*="card"]',
3543
+ '[class*="Studio"] [class*="card"]',
3544
+ ];
3545
+ for (const selector of noteItemSelectors) {
3546
+ try {
3547
+ const elements = await this.page.$$(selector);
3548
+ for (const el of elements) {
3549
+ // Check by data-id attribute
3550
+ if (noteId) {
3551
+ const dataId = await el.getAttribute('data-id');
3552
+ if (dataId === noteId) {
3553
+ return el;
3554
+ }
3555
+ const dataNote = await el.getAttribute('data-note-id');
3556
+ if (dataNote === noteId) {
3557
+ return el;
3558
+ }
3559
+ }
3560
+ // Check by title text content
3561
+ if (noteTitle) {
3562
+ const textContent = await el.textContent();
3563
+ if (textContent && textContent.toLowerCase().includes(noteTitle.toLowerCase())) {
3564
+ return el;
3565
+ }
3566
+ // Also check specific title elements
3567
+ try {
3568
+ const titleText = await el.$eval('.note-title, .title, [class*="title"], [class*="name"], h3, h4', (e) => e.textContent?.trim() || '');
3569
+ if (titleText.toLowerCase().includes(noteTitle.toLowerCase())) {
3570
+ return el;
3571
+ }
3572
+ }
3573
+ catch {
3574
+ // Element doesn't have a title child
3575
+ }
3576
+ }
3577
+ }
3578
+ }
3579
+ catch {
3580
+ continue;
3581
+ }
3582
+ }
3583
+ // Fallback: look for any card or item with matching text
3584
+ try {
3585
+ const searchText = noteTitle || noteId || '';
3586
+ const fallbackSelectors = [
3587
+ `[class*="note"]:has-text("${searchText.substring(0, 30)}")`,
3588
+ `[class*="card"]:has-text("${searchText.substring(0, 30)}")`,
3589
+ `:has-text("${searchText.substring(0, 30)}")`,
3590
+ ];
3591
+ for (const selector of fallbackSelectors) {
3592
+ try {
3593
+ const el = await this.page.$(selector);
3594
+ if (el) {
3595
+ return el;
3596
+ }
3597
+ }
3598
+ catch {
3599
+ continue;
3600
+ }
3601
+ }
3602
+ }
3603
+ catch {
3604
+ // Ignore fallback errors
3605
+ }
3606
+ return null;
3607
+ }
3608
+ /**
3609
+ * Try to use NotebookLM's native "Convert to source" or "Add to sources" feature
3610
+ */
3611
+ async tryNativeNoteToSourceConversion(
3612
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3613
+ noteElement) {
3614
+ // First, try to find a menu button on the note
3615
+ const menuOpened = await this.openNoteMenu(noteElement);
3616
+ if (menuOpened) {
3617
+ // Look for "Convert to source", "Add to sources", or similar options
3618
+ const convertSelectors = [
3619
+ // English patterns
3620
+ 'button:has-text("Convert to source")',
3621
+ 'button:has-text("Add to sources")',
3622
+ 'button:has-text("Make source")',
3623
+ 'button:has-text("Save as source")',
3624
+ '[role="menuitem"]:has-text("Convert to source")',
3625
+ '[role="menuitem"]:has-text("Add to sources")',
3626
+ '[role="menuitem"]:has-text("source")',
3627
+ 'mat-menu-item:has-text("source")',
3628
+ // French patterns
3629
+ 'button:has-text("Convertir en source")',
3630
+ 'button:has-text("Ajouter aux sources")',
3631
+ '[role="menuitem"]:has-text("source")',
3632
+ // Icon patterns
3633
+ 'button:has(mat-icon:has-text("add_to_drive"))',
3634
+ 'button:has(mat-icon:has-text("file_copy"))',
3635
+ '[role="menuitem"]:has(mat-icon:has-text("add"))',
3636
+ ];
3637
+ for (const selector of convertSelectors) {
3638
+ try {
3639
+ const btn = this.page.locator(selector).first();
3640
+ if (await btn.isVisible({ timeout: 500 })) {
3641
+ log.info(` ✅ Found native convert option: ${selector}`);
3642
+ await btn.click();
3643
+ await randomDelay(1000, 2000);
3644
+ // Wait for potential confirmation or processing
3645
+ await this.waitForSourceProcessing('Note');
3646
+ return true;
3647
+ }
3648
+ }
3649
+ catch {
3650
+ continue;
3651
+ }
3652
+ }
3653
+ // Close menu if convert option wasn't found
3654
+ await this.page.keyboard.press('Escape');
3655
+ await randomDelay(200, 400);
3656
+ }
3657
+ // Also check for a direct "Convert" or "Add to sources" button on the note itself
3658
+ const directButtonSelectors = [
3659
+ 'button:has-text("Convert")',
3660
+ 'button:has-text("Add to sources")',
3661
+ 'button[aria-label*="source" i]',
3662
+ 'button[aria-label*="convert" i]',
3663
+ ];
3664
+ for (const selector of directButtonSelectors) {
3665
+ try {
3666
+ const btn = await noteElement.$(selector);
3667
+ if (btn && (await btn.isVisible())) {
3668
+ log.info(` ✅ Found direct convert button on note: ${selector}`);
3669
+ await btn.click();
3670
+ await randomDelay(1000, 2000);
3671
+ await this.waitForSourceProcessing('Note');
3672
+ return true;
3673
+ }
3674
+ }
3675
+ catch {
3676
+ continue;
3677
+ }
3678
+ }
3679
+ return false;
3680
+ }
3681
+ /**
3682
+ * Open the menu for a note element
3683
+ */
3684
+ async openNoteMenu(
3685
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3686
+ noteElement) {
3687
+ const menuButtonSelectors = [
3688
+ // Material Design 3-dot menu button
3689
+ 'button:has(mat-icon:has-text("more_vert"))',
3690
+ 'button:has(mat-icon:has-text("more_horiz"))',
3691
+ 'button[aria-label*="menu" i]',
3692
+ 'button[aria-label*="options" i]',
3693
+ 'button[aria-label*="actions" i]',
3694
+ 'button[aria-label*="More" i]',
3695
+ 'button[aria-label*="Plus" i]',
3696
+ '.mat-mdc-icon-button:has(mat-icon)',
3697
+ '[class*="menu-button"]',
3698
+ '[class*="more-button"]',
3699
+ '[data-action="menu"]',
3700
+ // Generic icon buttons
3701
+ 'button.mat-icon-button',
3702
+ 'button.mdc-icon-button',
3703
+ ];
3704
+ // First, try to find the menu button within the note element
3705
+ for (const selector of menuButtonSelectors) {
3706
+ try {
3707
+ const menuBtn = await noteElement.$(selector);
3708
+ if (menuBtn) {
3709
+ const isVisible = await menuBtn.isVisible();
3710
+ if (isVisible) {
3711
+ log.info(` ✅ Found note menu button: ${selector}`);
3712
+ await menuBtn.click();
3713
+ await randomDelay(300, 500);
3714
+ return true;
3715
+ }
3716
+ }
3717
+ }
3718
+ catch {
3719
+ continue;
3720
+ }
3721
+ }
3722
+ // Hover over the note to reveal hidden menu button
3723
+ log.info(` 🔍 Hovering to reveal note menu button...`);
3724
+ await noteElement.hover();
3725
+ await randomDelay(500, 800);
3726
+ // Try again after hover
3727
+ for (const selector of menuButtonSelectors) {
3728
+ try {
3729
+ const menuBtn = await noteElement.$(selector);
3730
+ if (menuBtn) {
3731
+ const isVisible = await menuBtn.isVisible();
3732
+ if (isVisible) {
3733
+ log.info(` ✅ Found note menu button after hover: ${selector}`);
3734
+ await menuBtn.click();
3735
+ await randomDelay(300, 500);
3736
+ return true;
3737
+ }
3738
+ }
3739
+ }
3740
+ catch {
3741
+ continue;
3742
+ }
3743
+ }
3744
+ // Try right-click as last resort
3745
+ try {
3746
+ await noteElement.click({ button: 'right' });
3747
+ await randomDelay(300, 500);
3748
+ // Check if a context menu appeared
3749
+ const contextMenu = await this.page.$('[role="menu"], .mat-menu-panel, .mdc-menu');
3750
+ if (contextMenu) {
3751
+ log.info(` ✅ Opened context menu via right-click`);
3752
+ return true;
3753
+ }
3754
+ }
3755
+ catch {
3756
+ // Ignore right-click errors
3757
+ }
3758
+ return false;
3759
+ }
3760
+ /**
3761
+ * Extract the content of a note for fallback conversion
3762
+ */
3763
+ async extractNoteContent(
3764
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3765
+ noteElement, noteTitle) {
3766
+ try {
3767
+ // Method 1: Click the note to open it and extract content
3768
+ await noteElement.click();
3769
+ await randomDelay(500, 1000);
3770
+ // Look for expanded note content
3771
+ const contentSelectors = [
3772
+ // Note body/content areas
3773
+ '.note-content',
3774
+ '.note-body',
3775
+ '[class*="note-content"]',
3776
+ '[class*="noteContent"]',
3777
+ '[class*="note-body"]',
3778
+ // Expanded card content
3779
+ '[class*="expanded"] [class*="content"]',
3780
+ '[class*="detail"] [class*="content"]',
3781
+ // Text areas in note view
3782
+ '.note-text',
3783
+ '[class*="text-content"]',
3784
+ // ProseMirror or other rich text editors
3785
+ '.ProseMirror',
3786
+ '[contenteditable="true"]',
3787
+ // Generic content areas
3788
+ 'article',
3789
+ '.content',
3790
+ '[role="article"]',
3791
+ ];
3792
+ for (const selector of contentSelectors) {
3793
+ try {
3794
+ const contentEl = await this.page.$(selector);
3795
+ if (contentEl && (await contentEl.isVisible())) {
3796
+ const content = await contentEl.textContent();
3797
+ if (content && content.trim().length > 10) {
3798
+ log.info(` ✅ Extracted note content from: ${selector}`);
3799
+ // Format the content with the note title
3800
+ const formattedContent = noteTitle
3801
+ ? `# ${noteTitle}\n\n${content.trim()}`
3802
+ : content.trim();
3803
+ return formattedContent;
3804
+ }
3805
+ }
3806
+ }
3807
+ catch {
3808
+ continue;
3809
+ }
3810
+ }
3811
+ // Method 2: Try to extract from the note element itself
3812
+ const elementContent = await noteElement.textContent();
3813
+ if (elementContent && elementContent.trim().length > 20) {
3814
+ log.info(` ✅ Extracted note content from element`);
3815
+ const formattedContent = noteTitle
3816
+ ? `# ${noteTitle}\n\n${elementContent.trim()}`
3817
+ : elementContent.trim();
3818
+ return formattedContent;
3819
+ }
3820
+ // Method 3: Look for inner HTML content
3821
+ try {
3822
+ const innerContent = await noteElement.$eval('div, p, span', (el) => el.textContent?.trim() || '');
3823
+ if (innerContent && innerContent.length > 20) {
3824
+ log.info(` ✅ Extracted note content from inner elements`);
3825
+ const formattedContent = noteTitle ? `# ${noteTitle}\n\n${innerContent}` : innerContent;
3826
+ return formattedContent;
3827
+ }
3828
+ }
3829
+ catch {
3830
+ // Ignore extraction errors
3831
+ }
3832
+ log.warning(` ⚠️ Could not extract note content`);
3833
+ return null;
3834
+ }
3835
+ catch (error) {
3836
+ log.warning(` ⚠️ Error extracting note content: ${error}`);
3837
+ return null;
3838
+ }
3839
+ }
3840
+ }
3841
+ //# sourceMappingURL=content-manager.js.map