From f86a92c1b169a4078ae48df512f088ff43fb66c6 Mon Sep 17 00:00:00 2001 From: Bartosz Petrynski Date: Thu, 2 Oct 2025 18:39:30 +0800 Subject: [PATCH] feat: Add Firefox support and import caption capture improvements - Add Firefox Manifest V2 support with separate manifest-firefox.json - Implement Firefox-compatible download mechanism using blob and meetings.html - Add browser detection (isFirefox) to handle Chrome vs Firefox differences - Import caption capture improvements from upstream (v3.2.3): - Simplified DOM selectors (removed canUseAriaBasedTranscriptSelector) - Improved person selection logic with edge case handling - Better meeting start timestamp tracking - Structured error handling with ErrorObject pattern - Meeting software tracking (meetingSoftware field) - Enhanced version validation with meetsMinVersion() - Updated analytics endpoints with meetingSoftware parameter - Add cross-browser build script (build-cross.sh) for Chrome and Firefox - Update .gitignore with modern patterns for build artifacts - Update README with Firefox installation instructions - Bump version to 3.2.3 --- .gitignore | 17 +- README.md | 27 +++- extension-unpacked.zip | Bin 26802 -> 0 bytes extension/background.js | 277 +++++++++++++++++++++----------- extension/content.js | 254 +++++++++++++++-------------- extension/manifest-firefox.json | 40 +++++ extension/manifest.json | 11 +- extension/meetings.js | 27 ++++ scripts/build-cross.sh | 81 ++++++++++ types/index.js | 47 +++++- 10 files changed, 555 insertions(+), 226 deletions(-) delete mode 100644 extension-unpacked.zip create mode 100644 extension/manifest-firefox.json create mode 100755 scripts/build-cross.sh diff --git a/.gitignore b/.gitignore index 79faf8f..2ce9e32 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,16 @@ -extension.zip \ No newline at end of file +# OS +.DS_Store + +# Build outputs +/dist/ +*.zip + +# Local scripts cache or temp +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +.idea/ +.vscode/ diff --git a/README.md b/README.md index dd4f5b7..7a25da4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # TranscripTonic -Simple Google Meet transcripts. Private and open source. +Simple Google Meet transcripts. Private and open source. Works on Chrome and Firefox. ![marquee-large](/assets/marquee-large.png) -Extension status: 🟢 OPERATIONAL (v3.1.7) +Extension status: 🟢 OPERATIONAL (v3.2.3)

@@ -21,10 +21,17 @@ View video on [YouTube](https://www.youtube.com/watch?v=ARL6HbkakX4) # Installation + +## Chrome +## Firefox +Firefox users can install the extension using the unpacked installation method described below, or wait for the official Firefox Add-ons store release. + +**Note:** Firefox requires Manifest V2 format, so a separate `manifest-firefox.json` file is provided for Firefox compatibility. +

@@ -69,7 +76,7 @@ When this happens, it might be possible to recover the transcript, but recovery
# Privacy policy -TranscripTonic Chrome extension does not collect any information from users in any manner, except anonymous errors and transcript download timestamp. All processing/transcript storage happens within the user's Chrome browser and does not leave the device, unless you configure a webhook and choose to post data to your webhook URL. +TranscripTonic browser extension does not collect any information from users in any manner, except anonymous errors and transcript download timestamp. All processing/transcript storage happens within the user's browser and does not leave the device, unless you configure a webhook and choose to post data to your webhook URL.

@@ -81,12 +88,24 @@ The transcript may not always be accurate and is only intended to aid in improvi
# Installing unpacked extension +This method works for both Chrome and Firefox browsers. + 1. Download the unpacked extension zip file from GitHub using this [link](https://raw.githubusercontent.com/vivek-nexus/transcriptonic/refs/heads/main/extension-unpacked.zip) + +## For Chrome: 2. Open `chrome://extensions` in a new Chrome tab 3. Enable "Developer mode" from top right corner 4. Drag and drop the unpacked extension zip file to complete the installation process 5. If drag and drop of zip file does not work, unzip the file. Click on "Load unpacked" in chrome extensions page and select the `extension-unpacked` folder to complete the installation process. -6. Remove unpacked extension when no longer needed. Your meeting data of unpacked extension and extension installed from Chrome Store, are stored separately. + +## For Firefox: +2. Open `about:debugging` in a new Firefox tab +3. Click "This Firefox" in the left sidebar +4. Click "Load Temporary Add-on" +5. Unzip the downloaded file and navigate to the `extension-unpacked` folder +6. **Important:** Select specifically the `manifest-firefox.json` file (NOT `manifest.json`) + +**Note:** Remove unpacked extension when no longer needed. Your meeting data of unpacked extension and extension installed from official stores are stored separately.

diff --git a/extension-unpacked.zip b/extension-unpacked.zip deleted file mode 100644 index 75348f67d5e18199816aa3b220121d1aa4419c67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26802 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 Bsg(c# diff --git a/extension/background.js b/extension/background.js index 9f327dd..7d69ff0 100644 --- a/extension/background.js +++ b/extension/background.js @@ -28,25 +28,24 @@ chrome.runtime.onMessage.addListener(function (messageUnTyped, sender, sendRespo } if (message.type === "meeting_ended") { - // Prevents double downloading of transcript from tab closed event listener. Also prevents available update from being applied, during meeting post processing. - chrome.storage.local.set({ meetingTabId: "processing" }, function () { - console.log("Meeting tab id set to processing meeting") + processLastMeeting() + .then(() => { + /** @type {ExtensionResponse} */ + const response = { success: true } + sendResponse(response) + }) + .catch((error) => { + // Fails with error codes: 009, 010, 011, 012, 013, 014 + const parsedError = /** @type {ErrorObject} */ (error) - processLastMeeting() - .then(() => { - /** @type {ExtensionResponse} */ - const response = { success: true } - sendResponse(response) - }) - .catch((error) => { - /** @type {ExtensionResponse} */ - const response = { success: false, message: error } - sendResponse(response) - }) - .finally(() => { - clearTabIdAndApplyUpdate() - }) - }) + /** @type {ExtensionResponse} */ + const response = { success: false, message: parsedError } + sendResponse(response) + }) + .finally(() => { + // Invalidate tab id since transcript is downloaded, prevents double downloading of transcript from tab closed event listener + clearTabIdAndApplyUpdate() + }) } if (message.type === "download_transcript_at_index") { @@ -59,14 +58,17 @@ chrome.runtime.onMessage.addListener(function (messageUnTyped, sender, sendRespo sendResponse(response) }) .catch((error) => { + // Fails with error codes: 009, 010 + const parsedError = /** @type {ErrorObject} */ (error) + /** @type {ExtensionResponse} */ - const response = { success: false, message: error } + const response = { success: false, message: parsedError } sendResponse(response) }) } else { /** @type {ExtensionResponse} */ - const response = { success: false, message: "Invalid index" } + const response = { success: false, message: { errorCode: "015", errorMessage: "Invalid index" } } sendResponse(response) } } @@ -81,15 +83,18 @@ chrome.runtime.onMessage.addListener(function (messageUnTyped, sender, sendRespo sendResponse(response) }) .catch(error => { - console.error("Webhook retry failed:", error) + // Fails with error codes: 009, 010, 011, 012 + const parsedError = /** @type {ErrorObject} */ (error) + + console.error("Webhook retry failed:", parsedError) /** @type {ExtensionResponse} */ - const response = { success: false, message: error } + const response = { success: false, message: parsedError } sendResponse(response) }) } else { /** @type {ExtensionResponse} */ - const response = { success: false, message: "Invalid index" } + const response = { success: false, message: { errorCode: "015", errorMessage: "Invalid index" } } sendResponse(response) } } @@ -101,8 +106,11 @@ chrome.runtime.onMessage.addListener(function (messageUnTyped, sender, sendRespo sendResponse(response) }) .catch((error) => { + // Fails with error codes: 009, 010, 011, 012, 013, 014 + const parsedError = /** @type {ErrorObject} */ (error) + /** @type {ExtensionResponse} */ - const response = { success: false, message: error } + const response = { success: false, message: parsedError } sendResponse(response) }) } @@ -117,13 +125,9 @@ chrome.tabs.onRemoved.addListener(function (tabId) { if (tabId === resultLocal.meetingTabId) { console.log("Successfully intercepted tab close") - // Prevent misfires of onRemoved until next meeting. Also prevents available update from being applied, during meeting post processing. - chrome.storage.local.set({ meetingTabId: "processing" }, function () { - console.log("Meeting tab id set to processing meeting") - - processLastMeeting().finally(() => { - clearTabIdAndApplyUpdate() - }) + processLastMeeting().finally(() => { + // Clearing meetingTabId to prevent misfires of onRemoved until next meeting actually starts + clearTabIdAndApplyUpdate() }) } }) @@ -136,12 +140,12 @@ chrome.runtime.onUpdateAvailable.addListener(() => { const result = /** @type {ResultLocal} */ (resultUntyped) if (result.meetingTabId) { - // There is an active meeting(values: tabId or processing), defer the update + // There is an active meeting, defer the update chrome.storage.local.set({ isDeferredUpdatedAvailable: true }, function () { console.log("Deferred update flag set") }) } else { - // No active meeting, apply the update immediately. Meeting tab id is nullified only post meeting operations are done, so no race conditions. + // No active meeting, apply the update immediately. Meeting tab id is invalidated only post meeting operations are done, so no race conditions. console.log("No active meeting, applying update immediately") chrome.runtime.reload() } @@ -150,6 +154,7 @@ chrome.runtime.onUpdateAvailable.addListener(() => { // Download transcripts, post webhook if URL is enabled and available // Fails if transcript is empty or webhook request fails or if no meetings in storage +/** @throws error codes: 009, 010, 011, 012, 013, 014 */ function processLastMeeting() { return new Promise((resolve, reject) => { pickupLastMeetingFromStorage() @@ -187,22 +192,30 @@ function processLastMeeting() { resolve("Meeting processing and download/webhook posting complete") }) .catch(error => { - console.error("Operation failed:", error) - reject(error) + // Fails with error codes: 009, 010, 011, 012 + const parsedError = /** @type {ErrorObject} */ (error) + console.error("Operation failed:", parsedError.errorMessage) + reject({ errorCode: parsedError.errorCode, errorMessage: parsedError.errorMessage }) }) }) }) }) .catch((error) => { - reject(error) + // Fails with error codes: 013, 014 + const parsedError = /** @type {ErrorObject} */ (error) + reject({ errorCode: parsedError.errorCode, errorMessage: parsedError.errorMessage }) }) }) } +/** + * @throws error codes: 013, 014 + */ // Process transcript and chat messages of the meeting that just ended from storage, format them into strings, and save as a new entry in meetings (keeping last 10) function pickupLastMeetingFromStorage() { return new Promise((resolve, reject) => { chrome.storage.local.get([ + "meetingSoftware", "meetingTitle", "meetingStartTimestamp", "transcript", @@ -215,6 +228,7 @@ function pickupLastMeetingFromStorage() { // Create new transcript entry /** @type {Meeting} */ const newMeetingEntry = { + meetingSoftware: result.meetingSoftware ? result.meetingSoftware : "", meetingTitle: result.meetingTitle, meetingStartTimestamp: result.meetingStartTimestamp, meetingEndTimestamp: new Date().toISOString(), @@ -242,11 +256,11 @@ function pickupLastMeetingFromStorage() { }) } else { - reject("Empty transcript and empty chatMessages") + reject({ errorCode: "014", errorMessage: "Empty transcript and empty chatMessages" }) } } else { - reject("No meetings found. May be attend one?") + reject({ errorCode: "013", errorMessage: "No meetings found. May be attend one?" }) } }) }) @@ -257,6 +271,7 @@ function pickupLastMeetingFromStorage() { /** * @param {number} index * @param {boolean} isWebhookEnabled + * @throws error codes: 009, 010 */ function downloadTranscript(index, isWebhookEnabled) { return new Promise((resolve, reject) => { @@ -269,7 +284,7 @@ function downloadTranscript(index, isWebhookEnabled) { // Sanitise meeting title to prevent invalid file name errors // https://stackoverflow.com/a/78675894 const invalidFilenameRegex = /[:?"*<>|~/\\\u{1}-\u{1f}\u{7f}\u{80}-\u{9f}\p{Cf}\p{Cn}]|^[.\u{0}\p{Zl}\p{Zp}\p{Zs}]|[.\u{0}\p{Zl}\p{Zp}\p{Zs}]$|^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?=\.|$)/gui - let sanitisedMeetingTitle = "Google Meet call" + let sanitisedMeetingTitle = "Meeting" if (meeting.meetingTitle) { sanitisedMeetingTitle = meeting.meetingTitle.replaceAll(invalidFilenameRegex, "_") } @@ -281,7 +296,9 @@ function downloadTranscript(index, isWebhookEnabled) { const timestamp = new Date(meeting.meetingStartTimestamp) const formattedTimestamp = timestamp.toLocaleString("default", timeFormat).replace(/[\/:]/g, "-") - const fileName = `TranscripTonic/Transcript-${sanitisedMeetingTitle} at ${formattedTimestamp}.txt` + const prefix = meeting.meetingSoftware ? `${meeting.meetingSoftware} transcript` : "Transcript" + + const fileName = `TranscripTonic/${prefix}-${sanitisedMeetingTitle} at ${formattedTimestamp} on.txt` // Format transcript and chatMessages content @@ -294,59 +311,66 @@ function downloadTranscript(index, isWebhookEnabled) { content += "Transcript saved using TranscripTonic Chrome extension (https://chromewebstore.google.com/detail/ciepnfnceimjehngolkijpnbappkkiag)" content += "\n---------------" - const blob = new Blob([content], { type: "text/plain" }) - - // Read the blob as a data URL - const reader = new FileReader() + if (isFirefox()) { + // Firefox: use message passing to meetings.html for download + sendDownloadToMeetingsPage(fileName, content, resolve, reject); + } else { + // Chrome: use downloads API + const blob = new Blob([content], { type: "text/plain" }) - // Read the blob - reader.readAsDataURL(blob) + // Read the blob as a data URL + const reader = new FileReader() - // Download as text file, once blob is read - reader.onload = function (event) { - if (event.target?.result) { - const dataUrl = event.target.result + // Read the blob + reader.readAsDataURL(blob) - // Create a download with Chrome Download API - chrome.downloads.download({ - // @ts-ignore - url: dataUrl, - filename: fileName, - conflictAction: "uniquify" - }).then(() => { - console.log("Transcript downloaded") - resolve("Transcript downloaded successfully") + // Download as text file, once blob is read + reader.onload = function (event) { + if (event.target?.result) { + const dataUrl = event.target.result - // Increment anonymous transcript generated count to a Google sheet - fetch(`https://script.google.com/macros/s/AKfycbw4wRFjJcIoC5uDfscITSjNtUj83JVrBXKn44u9Cs0BoKNgyvt0A5hmG-xsJnlhfVu--g/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}`, { - mode: "no-cors" - }) - }).catch((err) => { - console.error(err) + // Create a download with Chrome Download API chrome.downloads.download({ // @ts-ignore url: dataUrl, - filename: "TranscripTonic/Transcript.txt", + filename: fileName, conflictAction: "uniquify" + }).then(() => { + console.log("Transcript downloaded") + resolve("Transcript downloaded successfully") + + // Increment anonymous transcript generated count to a Google sheet + fetch(`https://script.google.com/macros/s/AKfycbxgUPDKDfreh2JIs8pIC-9AyQJxq1lx9Q1qI2SVBjJRvXQrYCPD2jjnBVQmds2mYeD5nA/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}&meetingSoftware=${meeting.meetingSoftware}`, { + mode: "no-cors" + }) + }).catch((err) => { + console.error(err) + chrome.downloads.download({ + // @ts-ignore + url: dataUrl, + filename: "TranscripTonic/Transcript.txt", + conflictAction: "uniquify" + }) + console.log("Invalid file name. Transcript downloaded to TranscripTonic directory with simple file name.") + resolve("Transcript downloaded successfully with default file name") + + // Logs anonymous errors to a Google sheet for swift debugging + fetch(`https://script.google.com/macros/s/AKfycbwN-bVkVv3YX4qvrEVwG9oSup0eEd3R22kgKahsQ3bCTzlXfRuaiO7sUVzH9ONfhL4wbA/exec?version=${chrome.runtime.getManifest().version}&code=009&error=${encodeURIComponent(err)}&meetingSoftware=${meeting.meetingSoftware}`, { mode: "no-cors" }) + + // Increment anonymous transcript generated count to a Google sheet + fetch(`https://script.google.com/macros/s/AKfycbxgUPDKDfreh2JIs8pIC-9AyQJxq1lx9Q1qI2SVBjJRvXQrYCPD2jjnBVQmds2mYeD5nA/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}&meetingSoftware=${meeting.meetingSoftware}`, { + mode: "no-cors" + }) }) - console.log("Invalid file name. Transcript downloaded to TranscripTonic directory with simple file name.") - resolve("Transcript downloaded successfully with default file name") - - // Logs anonymous errors to a Google sheet for swift debugging - fetch(`https://script.google.com/macros/s/AKfycbw4wRFjJcIoC5uDfscITSjNtUj83JVrBXKn44u9Cs0BoKNgyvt0A5hmG-xsJnlhfVu--g/exec?version=${chrome.runtime.getManifest().version}&code=009&error=${encodeURIComponent(err)}`, { mode: "no-cors" }) - // Increment anonymous transcript generated count to a Google sheet - fetch(`https://script.google.com/macros/s/AKfycbzUk-q3N8_BWjwE90g9HXs5im1pYFriydKi1m9FoxEmMrWhK8afrHSmYnwYcw6AkH14eg/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}`, { - mode: "no-cors" - }) - }) - } - else { - reject(new Error("Failed to read blob")) + } + else { + reject({ errorCode: "009", errorMessage: "Failed to read blob" }) + } } } } else { - reject(new Error("Meeting at specified index not found")) + reject({ errorCode: "010", errorMessage: "Meeting at specified index not found" }) } }) }) @@ -354,6 +378,7 @@ function downloadTranscript(index, isWebhookEnabled) { /** * @param {number} index + * @throws error code: 010, 011, 012 */ function postTranscriptToWebhook(index) { return new Promise((resolve, reject) => { @@ -372,6 +397,7 @@ function postTranscriptToWebhook(index) { if (resultSync.webhookBodyType === "advanced") { webhookData = { webhookBodyType: "advanced", + meetingSoftware: meeting.meetingSoftware ? meeting.meetingSoftware : "", meetingTitle: meeting.meetingTitle || meeting.title || "", meetingStartTimestamp: new Date(meeting.meetingStartTimestamp).toISOString(), meetingEndTimestamp: new Date(meeting.meetingEndTimestamp).toISOString(), @@ -382,6 +408,7 @@ function postTranscriptToWebhook(index) { else { webhookData = { webhookBodyType: "simple", + meetingSoftware: meeting.meetingSoftware ? meeting.meetingSoftware : "", meetingTitle: meeting.meetingTitle || meeting.title || "", meetingStartTimestamp: new Date(meeting.meetingStartTimestamp).toLocaleString("default", timeFormat).toUpperCase(), meetingEndTimestamp: new Date(meeting.meetingEndTimestamp).toLocaleString("default", timeFormat).toUpperCase(), @@ -409,36 +436,33 @@ function postTranscriptToWebhook(index) { resolve("Webhook posted successfully") }) }).catch(error => { - console.error(error) // Update failure status. // @ts-ignore - Pointless type error about resultLocal.meetings being undefined, which is already checked above. resultLocal.meetings[index].webhookPostStatus = "failed" chrome.storage.local.set({ meetings: resultLocal.meetings }, function () { - // Create notification and open webhooks page + // Notify user of webhook failure chrome.notifications.create({ type: "basic", iconUrl: "icon.png", title: "Could not post webhook!", - message: "Click to view status and retry. Check console for more details." + message: `Webhook failed: ${error && error.message ? error.message : error}` }, function (notificationId) { - // Handle notification click chrome.notifications.onClicked.addListener(function (clickedNotificationId) { if (clickedNotificationId === notificationId) { chrome.tabs.create({ url: "meetings.html" }) } }) }) - - reject(error) + reject({ errorCode: "011", errorMessage: error }) }) }) } else { - reject(new Error("Meeting at specified index not found")) + reject({ errorCode: "010", errorMessage: "Meeting at specified index not found" }) } } else { - reject(new Error("No webhook URL configured")) + reject({ errorCode: "012", errorMessage: "No webhook URL configured" }) } }) }) @@ -481,7 +505,6 @@ function getChatMessagesString(chatMessages) { } function clearTabIdAndApplyUpdate() { - // Nullify to indicate end of meeting processing chrome.storage.local.set({ meetingTabId: null }, function () { console.log("Meeting tab id cleared for next meeting") @@ -499,6 +522,7 @@ function clearTabIdAndApplyUpdate() { }) } +/** @throws error codes: 009, 010, 011, 012, 013, 014 */ function recoverLastMeeting() { return new Promise((resolve, reject) => { chrome.storage.local.get(["meetings", "meetingStartTimestamp"], function (resultLocalUntyped) { @@ -516,8 +540,9 @@ function recoverLastMeeting() { processLastMeeting().then(() => { resolve("Recovered last meeting to the best possible extent") }).catch((error) => { - // Fails if transcript is empty or webhook request fails or user never attended any meetings - reject(error) + // Fails with error codes: 009, 010, 011, 013, 014 + const parsedError = /** @type {ErrorObject} */ (error) + reject({ errorCode: parsedError.errorCode, errorMessage: parsedError.errorMessage }) }) } else { @@ -525,8 +550,76 @@ function recoverLastMeeting() { } } else { - reject("No meetings found. May be attend one?") + reject({ errorCode: "013", errorMessage: "No meetings found. May be attend one?" }) } }) }) +} + +function isFirefox() { + // @ts-ignore - browser is a Firefox-specific global + return typeof browser !== 'undefined' && /firefox/i.test(navigator.userAgent); +} + +/** + * Firefox-compatible download handler that sends blob to meetings.html + * @param {string} fileName + * @param {string} content + * @param {Function} resolve + * @param {Function} reject + */ +function sendDownloadToMeetingsPage(fileName, content, resolve, reject) { + // Find or open meetings.html, then send the download message + chrome.tabs.query({}, function (tabs) { + let meetingsTab = tabs.find(tab => tab.url && tab.url.includes('meetings.html')); + if (meetingsTab && meetingsTab.id) { + chrome.tabs.update(meetingsTab.id, { active: true }, function () { + if (meetingsTab.id) { + chrome.tabs.sendMessage( + meetingsTab.id, + { + type: "download_transcript_blob", + fileName: fileName, + blobContent: content + }, + function (response) { + if (response && response.success) { + resolve("Transcript downloaded successfully (Firefox)"); + } else { + reject(new Error("Failed to trigger download in Firefox (meetings.html)")); + } + } + ); + } + }); + } else { + // Open meetings.html in a new tab + chrome.tabs.create({ url: chrome.runtime.getURL('meetings.html'), active: true }, function (newTab) { + // Wait for the tab to load, then send the message + const listener = function (tabId, changeInfo) { + if (tabId === newTab.id && changeInfo.status === 'complete') { + chrome.tabs.onUpdated.removeListener(listener); + if (newTab.id) { + chrome.tabs.sendMessage( + newTab.id, + { + type: "download_transcript_blob", + fileName: fileName, + blobContent: content + }, + function (response) { + if (response && response.success) { + resolve("Transcript downloaded successfully (Firefox)"); + } else { + reject(new Error("Failed to trigger download in Firefox (new meetings.html)")); + } + } + ); + } + } + }; + chrome.tabs.onUpdated.addListener(listener); + }); + } + }); } \ No newline at end of file diff --git a/extension/content.js b/extension/content.js index 3e98ea9..98ceeac 100644 --- a/extension/content.js +++ b/extension/content.js @@ -28,6 +28,9 @@ let personNameBuffer = "", transcriptTextBuffer = "", timestampBuffer = "" /** @type {ChatMessage[]} */ let chatMessages = [] +/** @type {MeetingSoftware} */ +const meetingSoftware = "Google Meet" + // Capture meeting start timestamp, stored in ISO format let meetingStartTimestamp = new Date().toISOString() let meetingTitle = document.title @@ -45,8 +48,6 @@ let hasMeetingEnded = false /** @type {ExtensionStatusJSON} */ let extensionStatusJSON -let canUseAriaBasedTranscriptSelector = true - @@ -56,15 +57,18 @@ let canUseAriaBasedTranscriptSelector = true Promise.race([ recoverLastMeeting(), new Promise((_, reject) => - setTimeout(() => reject(new Error('Recovery timed out')), 2000) + setTimeout(() => reject({ errorCode: "016", errorMessage: "Recovery timed out" }), 2000) ) ]). catch((error) => { - console.error(error) + const parsedError = /** @type {ErrorObject} */ (error) + if ((parsedError.errorCode !== "013") && (parsedError.errorCode !== "014")) { + console.error(parsedError.errorMessage) + } }). finally(() => { // Save current meeting data to chrome storage once recovery is complete or is aborted - overWriteChromeStorage(["meetingStartTimestamp", "meetingTitle", "transcript", "chatMessages"], false) + overWriteChromeStorage(["meetingSoftware", "meetingStartTimestamp", "meetingTitle", "transcript", "chatMessages"], false) }) @@ -72,42 +76,38 @@ Promise.race([ //*********** MAIN FUNCTIONS **********// checkExtensionStatus().finally(() => { - // Read the status JSON - chrome.storage.local.get(["extensionStatusJSON"], function (resultLocalUntyped) { - const resultLocal = /** @type {ResultLocal} */ (resultLocalUntyped) - extensionStatusJSON = resultLocal.extensionStatusJSON - console.log("Extension status " + extensionStatusJSON.status) - - // Enable extension functions only if status is 200 - if (extensionStatusJSON.status === 200) { - // NON CRITICAL DOM DEPENDENCY. Attempt to get username before meeting starts. Abort interval if valid username is found or if meeting starts and default to "You". - waitForElement(".awLEm").then(() => { - // Poll the element until the textContent loads from network or until meeting starts - const captureUserNameInterval = setInterval(() => { - if (!hasMeetingStarted) { - const capturedUserName = document.querySelector(".awLEm")?.textContent - if (capturedUserName) { - userName = capturedUserName - clearInterval(captureUserNameInterval) - } - } - else { + console.log("Extension status " + extensionStatusJSON.status) + + // Enable extension functions only if status is 200 + if (extensionStatusJSON.status === 200) { + // NON CRITICAL DOM DEPENDENCY. Attempt to get username before meeting starts. Abort interval if valid username is found or if meeting starts and default to "You". + waitForElement(".awLEm").then(() => { + // Poll the element until the textContent loads from network or until meeting starts + const captureUserNameInterval = setInterval(() => { + if (!hasMeetingStarted) { + const capturedUserName = document.querySelector(".awLEm")?.textContent + if (capturedUserName) { + userName = capturedUserName clearInterval(captureUserNameInterval) } - }, 100) - }) + } + else { + clearInterval(captureUserNameInterval) + } + }, 100) + }) - // 1. Meet UI prior to July/Aug 2024 - // meetingRoutines(1) + // 1. Meet UI prior to July/Aug 2024 + // meetingRoutines(1) + + // 2. Meet UI post July/Aug 2024 + meetingRoutines(2) + } + else { + // Show downtime message as extension status is 400 + showNotification(extensionStatusJSON) + } - // 2. Meet UI post July/Aug 2024 - meetingRoutines(2) - } - else { - // Show downtime message as extension status is 400 - showNotification(extensionStatusJSON) - } - }) }) @@ -149,10 +149,14 @@ function meetingRoutines(uiType) { } chrome.runtime.sendMessage(message, function () { }) hasMeetingStarted = true + // Update meeting startTimestamp + meetingStartTimestamp = new Date().toISOString() + overWriteChromeStorage(["meetingStartTimestamp"], false) //*********** MEETING START ROUTINES **********// - updateMeetingTitle() + // Pick up meeting name after a delay, since Google meet updates meeting name after a delay + setTimeout(() => updateMeetingTitle(), 5000) /** @type {MutationObserver} */ let transcriptObserver @@ -160,7 +164,7 @@ function meetingRoutines(uiType) { let chatMessagesObserver // **** REGISTER TRANSCRIPT LISTENER **** // - // Wait for chat icon to be visible. When user is waiting in meeting lobbing for someone to let them in, the call end icon is visible, but the captions icon is still not visible. + // Wait for captions icon to be visible. When user is waiting in meeting lobbing for someone to let them in, the call end icon is visible, but the captions icon is still not visible. waitForElement(captionsIconData.selector, captionsIconData.text).then(() => { // CRITICAL DOM DEPENDENCY const captionsButton = selectElements(captionsIconData.selector, captionsIconData.text)[0] @@ -168,29 +172,20 @@ function meetingRoutines(uiType) { // Click captions icon for non manual operation modes. Async operation. chrome.storage.sync.get(["operationMode"], function (resultSyncUntyped) { const resultSync = /** @type {ResultSync} */ (resultSyncUntyped) - if (resultSync.operationMode === "manual") { + if (resultSync.operationMode === "manual") console.log("Manual mode selected, leaving transcript off") - } - else { + else captionsButton.click() - } }) - // Allow DOM to be updated and then register chatMessage mutation observer + // Allow DOM to be updated and then register transcript mutation observer waitForElement(`div[role="region"][tabindex="0"]`).then(() => { // CRITICAL DOM DEPENDENCY. Grab the transcript element. This element is present, irrespective of captions ON/OFF, so this executes independent of operation mode. - let transcriptTargetNode = document.querySelector(`div[role="region"][tabindex="0"]`) - // For old captions UI - // if (!transcriptTargetNode) { - // transcriptTargetNode = document.querySelector(".a4cQT") - // canUseAriaBasedTranscriptSelector = false - // } + const transcriptTargetNode = document.querySelector(`div[role="region"][tabindex="0"]`) if (transcriptTargetNode) { // Attempt to dim down the transcript - canUseAriaBasedTranscriptSelector - ? transcriptTargetNode.setAttribute("style", "opacity:0.2") - : transcriptTargetNode.children[1].setAttribute("style", "opacity:0.2") + transcriptTargetNode.setAttribute("style", "opacity:0.2") // Create transcript observer instance linked to the callback function. Registered irrespective of operation mode, so that any visible transcript can be picked up during the meeting, independent of the operation mode. transcriptObserver = new MutationObserver(transcriptMutationCallback) @@ -201,20 +196,24 @@ function meetingRoutines(uiType) { else { throw new Error("Transcript element not found in DOM") } + }).catch((err) => { + console.error(err) + isTranscriptDomErrorCaptured = true + showNotification(extensionStatusJSON_bug) + + logError("001", err) }) - .catch((err) => { - console.error(err) - isTranscriptDomErrorCaptured = true - showNotification(extensionStatusJSON_bug) + }).catch((err) => { + console.error(err) + isTranscriptDomErrorCaptured = true + showNotification(extensionStatusJSON_bug) - logError("001", err) - }) + logError("001", err) }) // **** REGISTER CHAT MESSAGES LISTENER **** // - // Wait for chat icon to be visible. When user is waiting in meeting lobbing for someone to let them in, the call end icon is visible, but the chat icon is still not visible. - waitForElement(".google-symbols", "chat").then(() => { + try { const chatMessagesButton = selectElements(".google-symbols", "chat")[0] // Force open chat messages to make the required DOM to appear. Otherwise, the required chatMessages DOM element is not available. chatMessagesButton.click() @@ -242,14 +241,13 @@ function meetingRoutines(uiType) { logError("002", err) } }) - }) - .catch((err) => { - console.error(err) - isChatMessagesDomErrorCaptured = true - showNotification(extensionStatusJSON_bug) + } catch (err) { + console.error(err) + isChatMessagesDomErrorCaptured = true + showNotification(extensionStatusJSON_bug) - logError("003", err) - }) + logError("003", err) + } // Show confirmation message from extensionStatusJSON, once observation has started, based on operation mode if (!isTranscriptDomErrorCaptured && !isChatMessagesDomErrorCaptured) { @@ -306,17 +304,16 @@ function transcriptMutationCallback(mutationsList) { mutationsList.forEach(() => { try { // CRITICAL DOM DEPENDENCY. Get all people in the transcript - const people = canUseAriaBasedTranscriptSelector - ? document.querySelector(`div[role="region"][tabindex="0"]`)?.children - : document.querySelector(".a4cQT")?.childNodes[1]?.firstChild?.childNodes + const people = document.querySelector(`div[role="region"][tabindex="0"]`)?.children if (people) { /// In aria based selector case, the last people element is "Jump to bottom" button. So, pick up only if more than 1 element is available. - if (canUseAriaBasedTranscriptSelector ? (people.length > 1) : (people.length > 0)) { + if (people.length > 1) { // Get the last person - const person = canUseAriaBasedTranscriptSelector - ? people[people.length - 2] - : people[people.length - 1] + let person = people[people.length - 2] + if (person.childNodes.length < 2) { + person = people[people.length - 3] + } // CRITICAL DOM DEPENDENCY const currentPersonName = person.childNodes[0].textContent // CRITICAL DOM DEPENDENCY @@ -343,24 +340,16 @@ function transcriptMutationCallback(mutationsList) { } // Same person speaking more else { - if (canUseAriaBasedTranscriptSelector) { - // When the same person speaks for more than 30 min (approx), Meet drops very long transcript for current person and starts over, which is detected by current transcript string being significantly smaller than the previous one - if ((currentTranscriptText.length - transcriptTextBuffer.length) < -250) { - // Push the long transcript - pushBufferToTranscript() - - // Store transcript block timestamp for next transcript block of same person - timestampBuffer = new Date().toISOString() - } - } - else { - // If a person is speaking for a long time, Google Meet does not keep the entire text in the spans. Starting parts are automatically removed in an unpredictable way as the length increases and TranscripTonic will miss them. So we force remove a lengthy transcript node in a controlled way. Google Meet will add a fresh person node when we remove it and continue transcription. TranscripTonic picks it up as a new person and nothing is missed. - if (currentTranscriptText.length > 250) { - person.remove() - } + // When the same person speaks for more than 30 min (approx), Meet drops very long transcript for current person and starts over, which is detected by current transcript string being significantly smaller than the previous one + if ((currentTranscriptText.length - transcriptTextBuffer.length) < -250) { + // Push the long transcript + pushBufferToTranscript() + + // Store transcript block timestamp for next transcript block of same person + timestampBuffer = new Date().toISOString() } - // Update buffers for next mutation. This has to be done irrespective of any condition. + // Update buffers for next mutation transcriptTextBuffer = currentTranscriptText } } @@ -382,7 +371,7 @@ function transcriptMutationCallback(mutationsList) { // Logs to indicate that the extension is working if (transcriptTextBuffer.length > 125) { - console.log(transcriptTextBuffer.slice(0, 50) + " ... " + transcriptTextBuffer.slice(-50)) + console.log(transcriptTextBuffer.slice(0, 50) + " ... " + transcriptTextBuffer.slice(-50)) } else { console.log(transcriptTextBuffer) @@ -483,14 +472,14 @@ function pushUniqueChatBlock(chatBlock) { // Saves specified variables to chrome storage. Optionally, can send message to background script to download, post saving. /** - * @param {Array<"transcript" | "meetingTitle" | "meetingStartTimestamp" | "chatMessages">} keys + * @param {Array<"meetingSoftware" | "meetingTitle" | "meetingStartTimestamp" | "transcript" | "chatMessages">} keys * @param {boolean} sendDownloadMessage */ function overWriteChromeStorage(keys, sendDownloadMessage) { const objectToSave = {} // Hard coded list of keys that are accepted - if (keys.includes("transcript")) { - objectToSave.transcript = transcript + if (keys.includes("meetingSoftware")) { + objectToSave.meetingSoftware = meetingSoftware } if (keys.includes("meetingTitle")) { objectToSave.meetingTitle = meetingTitle @@ -498,6 +487,9 @@ function overWriteChromeStorage(keys, sendDownloadMessage) { if (keys.includes("meetingStartTimestamp")) { objectToSave.meetingStartTimestamp = meetingStartTimestamp } + if (keys.includes("transcript")) { + objectToSave.transcript = transcript + } if (keys.includes("chatMessages")) { objectToSave.chatMessages = chatMessages } @@ -512,8 +504,8 @@ function overWriteChromeStorage(keys, sendDownloadMessage) { } chrome.runtime.sendMessage(message, (responseUntyped) => { const response = /** @type {ExtensionResponse} */ (responseUntyped) - if (!response.success) { - console.error(response.message) + if ((!response.success) && (typeof response.message === 'object') && (response.message?.errorCode === "010")) { + console.error(response.message.errorMessage) } }) } @@ -551,19 +543,14 @@ function pulseStatus() { // Grabs updated meeting title, if available function updateMeetingTitle() { try { - waitForElement(".u6vdEc").then(() => { - // Pick up meeting name after a delay, since Google meet updates meeting name after a delay - setTimeout(() => { - // NON CRITICAL DOM DEPENDENCY - const meetingTitleElement = document.querySelector(".u6vdEc") - if (meetingTitleElement?.textContent) { - meetingTitle = meetingTitleElement.textContent - overWriteChromeStorage(["meetingTitle"], false) - } else { - throw new Error("Meeting title element not found in DOM") - } - }, 5000) - }) + // NON CRITICAL DOM DEPENDENCY + const meetingTitleElement = document.querySelector(".u6vdEc") + if (meetingTitleElement?.textContent) { + meetingTitle = meetingTitleElement.textContent + overWriteChromeStorage(["meetingTitle"], false) + } else { + throw new Error("Meeting title element not found in DOM") + } } catch (err) { console.error(err) @@ -571,7 +558,6 @@ function updateMeetingTitle() { logError("007", err) } } - } // Returns all elements of the specified selector type and specified textContent. Return array contains the actual element as well as all the parents. @@ -647,7 +633,7 @@ function showNotification(extensionStatusJSON) { } // CSS for notification -const commonCSS = `background: rgb(255 255 255 / 10%); +const commonCSS = `background: rgb(255 255 255 / 100%); backdrop-filter: blur(16px); position: fixed; top: 5%; @@ -675,7 +661,23 @@ const commonCSS = `background: rgb(255 255 255 / 10%); * @param {any} err */ function logError(code, err) { - fetch(`https://script.google.com/macros/s/AKfycbxiyQSDmJuC2onXL7pKjXgELK1vA3aLGZL5_BLjzCp7fMoQ8opTzJBNfEHQX_QIzZ-j4Q/exec?version=${chrome.runtime.getManifest().version}&code=${code}&error=${encodeURIComponent(err)}`, { mode: "no-cors" }) + fetch(`https://script.google.com/macros/s/AKfycbwN-bVkVv3YX4qvrEVwG9oSup0eEd3R22kgKahsQ3bCTzlXfRuaiO7sUVzH9ONfhL4wbA/exec?version=${chrome.runtime.getManifest().version}&code=${code}&error=${encodeURIComponent(err)}&meetingSoftware=${meetingSoftware}`, { mode: "no-cors" }) +} + +/** + * @param {string} oldVer + * @param {string} newVer + */ +function meetsMinVersion(oldVer, newVer) { + const oldParts = oldVer.split('.') + const newParts = newVer.split('.') + for (var i = 0; i < newParts.length; i++) { + const a = ~~newParts[i] // parse int + const b = ~~oldParts[i] // parse int + if (a > b) return false + if (a < b) return true + } + return true } @@ -683,22 +685,30 @@ function logError(code, err) { function checkExtensionStatus() { return new Promise((resolve, reject) => { // Set default value as 200 - chrome.storage.local.set({ - extensionStatusJSON: { status: 200, message: "TranscripTonic is running
Do not turn off captions" }, - }) + extensionStatusJSON = { status: 200, message: "TranscripTonic is running
Do not turn off captions" } // https://stackoverflow.com/a/42518434 fetch( - "https://ejnana.github.io/transcripto-status/status-prod-unpacked.json", + "https://ejnana.github.io/transcripto-status/status-prod-meet.json", { cache: "no-store" } ) .then((response) => response.json()) .then((result) => { - // Write status to chrome local storage - chrome.storage.local.set({ extensionStatusJSON: result }, function () { - console.log("Extension status fetched and saved") - resolve("Extension status fetched and saved") - }) + const minVersion = result.minVersion + + // Disable extension if version is below the min version + if (!meetsMinVersion(chrome.runtime.getManifest().version, minVersion)) { + extensionStatusJSON.status = 400 + extensionStatusJSON.message = `TranscripTonic is not running
Please update to v${minVersion} by following these instructions` + } + else { + // Update status based on response + extensionStatusJSON.status = result.status + extensionStatusJSON.message = result.message + } + + console.log("Extension status fetched and saved") + resolve("Extension status fetched and saved") }) .catch((err) => { console.error(err) diff --git a/extension/manifest-firefox.json b/extension/manifest-firefox.json new file mode 100644 index 0000000..bcb2db6 --- /dev/null +++ b/extension/manifest-firefox.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 2, + "name": "TranscripTonic", + "version": "3.2.3", + "description": "Simple Google Meet transcripts. Private and open source.", + "browser_action": { + "default_icon": "icon.png", + "default_popup": "popup.html" + }, + "icons": { + "128": "icon.png" + }, + "content_scripts": [ + { + "js": [ + "content.js" + ], + "run_at": "document_end", + "matches": [ + "https://meet.google.com/*" + ], + "exclude_matches": [ + "https://meet.google.com/" + ] + } + ], + "permissions": [ + "storage", + "https://meet.google.com/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "web_accessible_resources": [ + "meetings.html" + ] +} diff --git a/extension/manifest.json b/extension/manifest.json index 63e3c4e..d264b98 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,8 @@ { + "_comment": "This is the Chrome/Manifest V3 version. Firefox users should use manifest-firefox.json (Manifest V2)", "manifest_version": 3, "name": "TranscripTonic", - "version": "3.1.7", + "version": "3.2.3", "description": "Simple Google Meet transcripts. Private and open source.", "action": { "default_icon": "icon.png", @@ -39,5 +40,11 @@ ], "background": { "service_worker": "background.js" - } + }, + "web_accessible_resources": [ + { + "resources": ["icon.png", "popup.html", "meetings.html"], + "matches": ["https://meet.google.com/*"] + } + ] } \ No newline at end of file diff --git a/extension/meetings.js b/extension/meetings.js index 660646e..0c45b3e 100644 --- a/extension/meetings.js +++ b/extension/meetings.js @@ -292,4 +292,31 @@ function getDuration(meetingStartTimestamp, meetingEndTimestamp) { return durationHours > 0 ? `${durationHours}h ${remainingMinutes}m` : `${durationMinutes}m` +} + +// Add Firefox download support +if (typeof browser !== 'undefined' && /firefox/i.test(navigator.userAgent)) { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'download_transcript_blob') { + try { + const blob = new Blob([message.blobContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = message.fileName; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + if (sendResponse) sendResponse({ success: true }); + } catch (e) { + if (sendResponse) sendResponse({ success: false }); + } + return true; + } + }); + // If meetings.html is opened by the background script, focus the window + window.focus(); } \ No newline at end of file diff --git a/scripts/build-cross.sh b/scripts/build-cross.sh new file mode 100755 index 0000000..4b8d63a --- /dev/null +++ b/scripts/build-cross.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Build Chrome (MV3) and Firefox (MV2) variants from unified root using separate manifests + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC_DIR="$ROOT_DIR/extension" +DIST_DIR="$ROOT_DIR/dist" +CHROME_OUT="$DIST_DIR/chrome" +FIREFOX_OUT="$DIST_DIR/amo" + +echo "[build] Root: $ROOT_DIR" + +rm -rf "$CHROME_OUT" "$FIREFOX_OUT" +mkdir -p "$CHROME_OUT/icons" "$FIREFOX_OUT/icons" "$DIST_DIR" + +read_version() { + local manifest_path="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "$manifest_path" <<'PY' +import json,sys +path=sys.argv[1] +with open(path,'r') as f: + data=json.load(f) +print(data.get('version','0.0.0')) +PY + else + grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest_path" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' + fi +} + +VERSION=$(read_version "$SRC_DIR/manifest.json") +echo "[build] Version: $VERSION" + +copy_common() { + local from="$1"; local to="$2" + local files=(background.js content.js meetings.html meetings.js popup.html popup.js icon.png) + for f in "${files[@]}"; do + if [[ -f "$from/$f" ]]; then cp "$from/$f" "$to/$f"; fi + done + if [[ -d "$from/icons" ]]; then + find "$from/icons" -maxdepth 1 -type f -name '*.svg' -exec cp {} "$to/icons/" \; + fi +} + +strip_ts_refs() { + local f="$1" + if [[ -f "$f" ]]; then + if [[ "$OSTYPE" == darwin* ]]; then + sed -i '' -E 's#^/// /dev/null 2>&1; then + echo "[lint] Running web-ext lint (Firefox)..." + web-ext lint --source-dir "$FIREFOX_OUT" || echo "[lint] web-ext reported warnings/errors (continuing)." +fi +FIREFOX_ZIP="$DIST_DIR/transcriptonic-firefox-v$VERSION.zip" +(cd "$FIREFOX_OUT" && zip -qr "$FIREFOX_ZIP" . -x '*.DS_Store') + +echo "[done] Chrome: $CHROME_ZIP" +echo "[done] Firefox: $FIREFOX_ZIP" + + diff --git a/types/index.js b/types/index.js index f1de2e1..d3031df 100644 --- a/types/index.js +++ b/types/index.js @@ -15,6 +15,7 @@ /** * @typedef {Object} WebhookBody * @property {"simple" | "advanced"} webhookBodyType simple or advanced + * @property {MeetingSoftware} meetingSoftware * @property {string} meetingTitle title of the meeting * @property {string} meetingStartTimestamp ISO timestamp of when the meeting started * @property {string} meetingEndTimestamp ISO timestamp of when the meeting ended @@ -29,6 +30,7 @@ * @typedef {Object} ResultLocal Local chrome storage * @property {ExtensionStatusJSON} extensionStatusJSON * @property {MeetingTabId} meetingTabId + * @property {MeetingSoftware} meetingSoftware * @property {MeetingTitle} meetingTitle * @property {MeetingStartTimestamp} meetingStartTimestamp * @property {Transcript} transcript @@ -44,6 +46,7 @@ */ /** * @typedef {Object} Meeting + * @property {MeetingSoftware} [meetingSoftware] * @property {string | undefined} [meetingTitle] title of the meeting * @property {string | undefined} [title] title of the meeting (this is older key for meetingTitle key, in v3.1.0) * @property {string} meetingStartTimestamp ISO timestamp of when the meeting started @@ -52,6 +55,10 @@ * @property {ChatMessage[] | []} chatMessages array containing chat messages from the meeting * @property {"new" | "failed" | "successful"} webhookPostStatus status of the webhook post request */ + +/** + * @typedef {"Google Meet" | "Zoom" | "Teams" | "" | undefined} MeetingSoftware Google Meet or Zoom or undefined. + */ /** * @typedef {number | "processing" | null} MeetingTabId tab id of the meeting tab, captured when meeting starts. A valid value or "processing" indicates that a meeting is in progress. Set to null once meeting ends and associated processing is complete. */ @@ -98,14 +105,44 @@ -/** +/** * @typedef {Object} ExtensionMessage Message sent by the calling script - * @property {"new_meeting_started" | "meeting_ended" | "download_transcript_at_index" | "retry_webhook_at_index" | "recover_last_meeting"} type type of message + * @property {"new_meeting_started" | "meeting_ended" | "download_transcript_at_index" | "retry_webhook_at_index" | "recover_last_meeting" | "register_content_scripts"} type type of message * @property {number} [index] index of the meeting to process */ -/** +/** * @typedef {Object} ExtensionResponse Response sent by the called script * @property {boolean} success whether the message was processed successfully as per the request - * @property {string} [message] message explaining success or failure - */ \ No newline at end of file + * @property {string | ErrorObject} [message] message explaining success or failure + */ + +/** + * @typedef {Object} ErrorObject Error Object + * @property {string} errorCode whether the message was processed successfully as per the request + * @property {string} errorMessage message explaining success or failure + */ + +// CONTENT SCRIPT ERRORS +// | Error Code | Error Message | +// | :--- | :--- | +// | **001** | "Transcript element not found in DOM" | +// | **002** | "Chat messages element not found in DOM" | +// | **003** | "Chat button element not found in DOM" | +// | **004** | "Call end button element not found in DOM" | +// | **005** | "Transcript mutation failed to process" | +// | **006** | "Chat messages mutation failed to process" | +// | **007** | "Meeting title element not found in DOM" | +// | **008** | "Failed to fetch extension status" | +// | **016** | "Recovery timed out" | + +// BACKGROUND SCRIPT ERRORS +// | Error Code | Error Message | +// | :--- | :--- | +// | **009** | "Failed to read blob" | +// | **010** | "Meeting at specified index not found" | +// | **011** | "Webhook request failed with HTTP status code [number] [statusText]" | +// | **012** | "No webhook URL configured" | +// | **013** | "No meetings found. May be attend one?" | +// | **014** | "Empty transcript and empty chatMessages" | +// | **015** | "Invalid index" |