From 7bc34c79edca04694b631c734a2f41eac570fa7a Mon Sep 17 00:00:00 2001 From: vahidaskari Date: Fri, 12 Jun 2026 00:31:32 +0330 Subject: [PATCH] Refactor Google Meet Transcripts Extension for Local Use - Removed all cloud-related functionalities, including login prompts and token handling. - Disabled Laxis cloud features, ensuring no data is sent to external servers. - Updated manifest to reflect the new local-only functionality. - Added a new Python server to handle transcripts locally, including WebSocket support. - Implemented storage management for transcripts, including deduplication and file writing. - Created a smoke test for the WebSocket server to simulate transcript updates. - Updated README with setup instructions and usage details for the new local server. --- caption-extension/.gitignore | 1 - caption-extension/background.js | 19 -- caption-extension/config.js | 5 - caption-extension/content-bk.js | 64 ---- caption-extension/content.js | 280 ------------------ caption-extension/logger.js | 47 --- caption-extension/manifest.json | 43 --- caption-extension/popup.css | 93 ------ caption-extension/popup.html | 21 -- caption-extension/popup.js | 118 -------- docs/action/001_action_doc_writing.md | 51 ++++ docs/action/002_action_planning.md | 77 +++++ docs/result/001_result_plan_doc_writing.md | 52 ++++ docs/result/002_result_planning.md | 116 ++++++++ .../analytics.js | 5 +- google-meet-transcripts-extension/bridge.js | 68 +++++ .../config/share.js | 2 +- .../feature/panel/main.js | 19 +- .../feature/record/meetingInfo.js | 2 +- .../feature/record/storage.js | 2 +- .../feature/record/transcript.js | 18 +- .../image/logo.png | Bin 29101 -> 11458 bytes .../image/logo128x128.png | Bin 9934 -> 6638 bytes .../image/logo16x16.png | Bin 667 -> 778 bytes .../image/logo48x48.png | Bin 3182 -> 2659 bytes google-meet-transcripts-extension/login.js | 140 ++------- .../manifest.json | 8 +- google-meet-transcripts-extension/runtime.js | 30 +- meet-transcripts/.gitignore | 4 + meet-transcripts/README.md | 117 ++++++++ meet-transcripts/_smoke_test.py | 37 +++ meet-transcripts/mcp_server.py | 155 ++++++++++ meet-transcripts/requirements.txt | 2 + meet-transcripts/storage.py | 234 +++++++++++++++ meet-transcripts/ws_server.py | 79 +++++ 35 files changed, 1069 insertions(+), 840 deletions(-) delete mode 100644 caption-extension/.gitignore delete mode 100644 caption-extension/background.js delete mode 100644 caption-extension/config.js delete mode 100644 caption-extension/content-bk.js delete mode 100644 caption-extension/content.js delete mode 100644 caption-extension/logger.js delete mode 100644 caption-extension/manifest.json delete mode 100644 caption-extension/popup.css delete mode 100644 caption-extension/popup.html delete mode 100644 caption-extension/popup.js create mode 100644 docs/action/001_action_doc_writing.md create mode 100644 docs/action/002_action_planning.md create mode 100644 docs/result/001_result_plan_doc_writing.md create mode 100644 docs/result/002_result_planning.md create mode 100644 google-meet-transcripts-extension/bridge.js create mode 100644 meet-transcripts/.gitignore create mode 100644 meet-transcripts/README.md create mode 100644 meet-transcripts/_smoke_test.py create mode 100644 meet-transcripts/mcp_server.py create mode 100644 meet-transcripts/requirements.txt create mode 100644 meet-transcripts/storage.py create mode 100644 meet-transcripts/ws_server.py diff --git a/caption-extension/.gitignore b/caption-extension/.gitignore deleted file mode 100644 index 45784b0..0000000 --- a/caption-extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.qodo diff --git a/caption-extension/background.js b/caption-extension/background.js deleted file mode 100644 index 5e8e272..0000000 --- a/caption-extension/background.js +++ /dev/null @@ -1,19 +0,0 @@ -"use strict" -import logger from './logger.js'; - -logger.debug("Hello, world from background!") - -function setBadgeText(enabled) { - const text = enabled ? "ON" : "OFF" - void chrome.action.setBadgeText({text: text}) -} - -function startUp() { - chrome.storage.sync.get("slider", (data) => { - setBadgeText(!!data.slider) - }) -} - -// Ensure the background script always runs. -chrome.runtime.onStartup.addListener(startUp) -chrome.runtime.onInstalled.addListener(startUp) diff --git a/caption-extension/config.js b/caption-extension/config.js deleted file mode 100644 index e2f2708..0000000 --- a/caption-extension/config.js +++ /dev/null @@ -1,5 +0,0 @@ -var config = { - loggerLevel: 'debug', //change it to 'debug' for all logs, 'info' or more to reduce the log output in the production - defaultCaptureInterval: 100, //change it to 1000 for 1 second interval -}; -export default config; diff --git a/caption-extension/content-bk.js b/caption-extension/content-bk.js deleted file mode 100644 index 8ffb8c8..0000000 --- a/caption-extension/content-bk.js +++ /dev/null @@ -1,64 +0,0 @@ -"use strict" - -const blurFilter = "blur(6px)" -let textToBlur = "" - -// Search this DOM node for text to blur and blur the parent element if found. -function processNode(node) { - if (node.childNodes.length > 0) { - Array.from(node.childNodes).forEach(processNode) - } - if (node.nodeType === Node.TEXT_NODE && - node.textContent !== null && node.textContent.trim().length > 0) { - const parent = node.parentElement - if (parent !== null && - (parent.tagName === 'SCRIPT' || parent.style.filter === blurFilter)) { - // Already blurred - return - } - if (node.textContent.includes(textToBlur)) { - blurElement(parent) - } - } -} - -function blurElement(elem) { - elem.style.filter = blurFilter - console.debug("blurred id:" + elem.id + " class:" + elem.className + - " tag:" + elem.tagName + " text:" + elem.textContent) -} - -// Create a MutationObserver to watch for changes to the DOM. -const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.addedNodes.length > 0) { - mutation.addedNodes.forEach(processNode) - } else { - processNode(mutation.target) - } - }) -}) - -// Enable the content script by default. -let enabled = true -const keys = ["slider", "item"] - -chrome.storage.sync.get(keys, (data) => { - if (data.enabled === false) { - enabled = false - } - if (data.item) { - textToBlur = data.item - } - // Only start observing the DOM if the extension is enabled and there is text to blur. - if (enabled && textToBlur.trim().length > 0) { - observer.observe(document, { - attributes: false, - characterData: true, - childList: true, - subtree: true, - }) - // Loop through all elements on the page for initial processing. - processNode(document) - } -}) diff --git a/caption-extension/content.js b/caption-extension/content.js deleted file mode 100644 index 3f2e88c..0000000 --- a/caption-extension/content.js +++ /dev/null @@ -1,280 +0,0 @@ -"use strict"; - -(async () => { - // This file cannot import config.js or logger.js directly because it runs in a different context (source:https://stackoverflow.com/a/53033388/4235602) - // So we need to load them dynamically. But our config.js and logger.js are in the same directory and can import each other - try { - //#region load logger - const loggerSrc = chrome.runtime.getURL("logger.js"); - const loggerModule = await import(loggerSrc); - const logger = loggerModule.default; - - logger.debug("Logger initialized successfully"); - logger.debug("Content script starting..."); - //#endregion - - //#region load config - const configSrc = chrome.runtime.getURL("config.js"); - const configModule = await import(configSrc); - const config = configModule.default; - - logger.debug("Config loaded:", config); - //#endregion - - class CaptionCapture { - constructor() { - this.isCapturing = false; - this.intervalId = null; - this.lastSpans = []; - this.captionsLog = []; - this.mergedCaption = []; - this.subtitleText = null; - this.checkSubtitleInterval = config.defaultCaptureInterval; - } - - // Function to detect and get the caption spans as an array - detectCaptionAsArray() { - try { - let spans = document.querySelectorAll('div.ygicle.VbkSUe'); - if (!spans || spans.length === 0) { - logger.debug("No caption spans found"); - return []; - } - return Array.from(spans).map(span => span.innerText.trim()) || []; - } catch (error) { - logger.error("Error detecting captions:", error); - return []; - } - } - - // Function to get absolute time in HH:MM:SS,mmm format (Real-Time) - getCurrentTimestamp() { - let now = new Date(); - let hours = String(now.getHours()).padStart(2, '0'); - let minutes = String(now.getMinutes()).padStart(2, '0'); - let seconds = String(now.getSeconds()).padStart(2, '0'); - let millis = String(now.getMilliseconds()).padStart(3, '0'); - - return `${hours}:${minutes}:${seconds},${millis}`; // Absolute time format - } - - // Function to save new words with timestamp - saveCaptionByWords(timestamp, newText) { - this.captionsLog.push({ time: timestamp, text: newText }); - logger.debug(`${timestamp} --> ${newText}`); // Print in subtitle format - } - - formatTimestamp(ms) { - const h = String(Math.floor(ms / 3600000)).padStart(2, "0"); - ms %= 3600000; - const m = String(Math.floor(ms / 60000)).padStart(2, "0"); - ms %= 60000; - const s = String(Math.floor(ms / 1000)).padStart(2, "0"); - const msStr = String(ms % 1000).padStart(3, "0"); - return `${h}:${m}:${s},${msStr}`; - } - - mergeCaptions(captions, gap = 1000) { - if (!captions || captions.length === 0) return []; - - const parseTime = (timestamp) => { - const [h, m, sMs] = timestamp.split(":"); - const [s, ms] = sMs.split(","); - return ( - parseInt(h) * 3600000 + - parseInt(m) * 60000 + - parseInt(s) * 1000 + - parseInt(ms) - ); - }; - - const merged = []; - let current = { - start: parseTime(captions[0].time), - end: parseTime(captions[0].time), - texts: [captions[0].text] - }; - - for (let i = 1; i < captions.length; i++) { - const entry = captions[i]; - const time = parseTime(entry.time); - const timeDiff = time - current.end; - - if (timeDiff > gap) { - merged.push({ - start: this.formatTimestamp(current.start), - end: this.formatTimestamp(current.end), - text: current.texts.join(" ") - }); - - current = { - start: time, - end: time, - texts: [entry.text] - }; - } else { - current.end = time; - current.texts.push(entry.text); - } - } - - // Push the last group - merged.push({ - start: this.formatTimestamp(current.start), - end: this.formatTimestamp(current.end), - text: current.texts.join(" ") - }); - - this.mergedCaption = merged; - - return merged; - } - - - printMergedSubtitle() { - const merged = this.mergeCaptions(this.getCapturedCaptions(), 1000); - if (merged.length === 0) { - logger.debug("No subtitles to print."); - return; - } - - const parseTime = (timestamp) => { - const [h, m, sMs] = timestamp.split(":"); - const [s, ms] = sMs.split(","); - return ( - parseInt(h) * 3600000 + - parseInt(m) * 60000 + - parseInt(s) * 1000 + - parseInt(ms) - ); - }; - - const baseTime = parseTime(merged[0].start); // time of first word - - const toRelative = (absTime) => { - const ms = parseTime(absTime) - baseTime; - return this.formatTimestamp(ms); - }; - - this.subtitleText = merged.map((item, index) => - `${index + 1}\n${toRelative(item.start)} --> ${toRelative(item.end)}\n${item.text}\n` - ).join("\n"); - - logger.debug(this.subtitleText); - - return this.subtitleText; - } - - generateSrtFilename() { - const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); - return `meet_caption_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.srt`; - } - - downloadSrtFile() { - if (!this.subtitleText) { - logger.warn("No subtitle content found. Please call printMergedSubtitle() first."); - return; - } - - const filename = this.generateSrtFilename(); - const blob = new Blob([this.subtitleText], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - - URL.revokeObjectURL(url); - } - - - - // Function to start capturing captions - startCapturing() { - if (this.isCapturing) { - logger.debug("Caption capturing is already running."); - return; - } - - this.isCapturing = true; - logger.debug("Caption capturing started."); - - this.intervalId = setInterval(() => { - let currentSpans = this.detectCaptionAsArray(); - - let lastOldSpan = this.lastSpans[this.lastSpans.length - 1] || ""; - let lastNewSpan = currentSpans[currentSpans.length - 1] || ""; - - let isLastCaptionChanged = lastNewSpan !== lastOldSpan; - - if (isLastCaptionChanged) { - this.lastSpans = currentSpans; // Update stored spans - let timestamp = this.getCurrentTimestamp(); - - // Get only the new part of the last span - let newText = lastNewSpan.replace(lastOldSpan, "").trim(); - - if (newText) { - logger.debug(newText) - const wordCount = newText.trim().split(/\s+/).length; - const BurstTextCount = 80; - if (wordCount <= (this.checkSubtitleInterval / 100) * BurstTextCount) { - this.saveCaptionByWords(timestamp, newText); - } else { - logger.debug(`Skipped burst text with ${wordCount} words at ${timestamp}`); - } - } - } - }, this.checkSubtitleInterval); // Runs every 100ms for better precision - } - - // Function to stop capturing captions - stopCapturing() { - if (!this.isCapturing) { - logger.debug("Caption capturing is not running."); - return; - } - - this.isCapturing = false; - clearInterval(this.intervalId); - logger.debug("Caption capturing stopped."); - } - - // Function to get the saved captions log - getCapturedCaptions() { - return this.captionsLog; - } - } - - // Initialize CaptionCapture and message listener - const captionCapture = new CaptionCapture(); - logger.debug("CaptionCapture initialized"); - - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - logger.debug("start of listener in content.js"); - - if (message.action === "startCapture") { - logger.debug("startCapture content.js"); - captionCapture.startCapturing(); - sendResponse({ status: "started" }); - } else if (message.action === "stopCapture") { - logger.debug("stopCapture content.js"); - captionCapture.stopCapturing(); - sendResponse({ status: "stopped" }); - } else if (message.action === "downloadSrt") { - alert("Downloading SRT file..."); - logger.debug("downloadSrt content.js"); - captionCapture.printMergedSubtitle(); - captionCapture.downloadSrtFile(); - sendResponse({ status: "downloadStarted" }); - } - }); - - logger.debug("Message listener set up"); - } catch (error) { - logger.error("Error loading modules:", error); - } -})(); - diff --git a/caption-extension/logger.js b/caption-extension/logger.js deleted file mode 100644 index 4406f59..0000000 --- a/caption-extension/logger.js +++ /dev/null @@ -1,47 +0,0 @@ -import config from './config.js'; - -class Logger { - static LEVELS = { - debug: 0, - info: 1, - warn: 2, - error: 3, - none: 4 - }; - - static currentLevel = Logger.LEVELS.debug; - - static setLevel(level) { - if (level in Logger.LEVELS) { - Logger.currentLevel = Logger.LEVELS[level]; - } - } - - static shouldLog(level, instanceLevel) { - return Logger.LEVELS[level] >= instanceLevel; - } - - constructor(level = 'debug', scope = '') { - this.level = Logger.LEVELS[level] ?? Logger.LEVELS.debug; - this.scope = scope.toUpperCase(); - - for (const levelName of Object.keys(Logger.LEVELS)) { - if (levelName === 'none') continue; - - this[levelName] = (...args) => { - if (Logger.shouldLog(levelName, this.level) && typeof console[levelName] === 'function') { - console[levelName]( - `[${levelName.toUpperCase()}]${this.scope ? ` [${this.scope}]` : ''}`, - ...args - ); - } - }; - } - } -} - -// Create a default global logger with no scope, using config value -const logger = new Logger(config.loggerLevel); - -export default logger; -export { Logger }; diff --git a/caption-extension/manifest.json b/caption-extension/manifest.json deleted file mode 100644 index c33b44f..0000000 --- a/caption-extension/manifest.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "manifest_version": 3, - "name": "Meet caption saver", - "version": "0.1.0", - "description": "Save .srt file of conversation", - "action": { - "default_popup": "popup.html" - }, - "permissions": [ - "storage" - ], - "host_permissions": [ - "http://meet.google.com/*", - "https://meet.google.com/*" - ], - "content_scripts": [ - { - "matches": [ - "http://meet.google.com/*", - "https://meet.google.com/*" - ], - "js": [ - "content.js" - ] - } - ], - "background": { - "service_worker": "background.js", - "type": "module" - }, - "web_accessible_resources": [ - { - "resources": [ - "logger.js", - "config.js" - ], - "matches": [ - "http://meet.google.com/*", - "https://meet.google.com/*" - ] - } - ] -} \ No newline at end of file diff --git a/caption-extension/popup.css b/caption-extension/popup.css deleted file mode 100644 index 061ca3b..0000000 --- a/caption-extension/popup.css +++ /dev/null @@ -1,93 +0,0 @@ -body { - min-width: 100px; - min-height: 60px; - display: flex; - justify-content: center; - align-items: center; -} - -.container { - display: flex; - flex-direction: column; - align-items: center; - margin-top: 3px; -} - -.downloadBtn { - margin: 6px 1px; - padding: 3px 3px; - font-size: 12px; - cursor: pointer; -} - -.error-message { - color: #ff4444; - font-size: 12px; - text-align: center; - margin-top: 8px; - padding: 0 10px; - max-width: 200px; - word-wrap: break-word; - display: none; -} - -/* The switch - the box around the slider */ -.switch { - position: relative; - width: 60px; - height: 34px; - display: flex; - justify-content: center; - align-items: center; - margin: 6px 1px; -} - -/* Hide default HTML checkbox */ -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -/* The slider */ -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; -} - -.slider::before { - position: absolute; - content: ""; - height: 26px; - width: 26px; - left: 4px; - bottom: 4px; - background-color: white; -} - -input:checked+.slider { - background-color: #2196F3; -} - -input:checked+.slider:before { - transform: translateX(26px); - /* Move the slider to the right when checked */ -} - -/* Rounded sliders */ -.slider.round { - border-radius: 34px; -} - -.slider.round::before { - border-radius: 50%; -} - -.secret { - margin: 5px; -} \ No newline at end of file diff --git a/caption-extension/popup.html b/caption-extension/popup.html deleted file mode 100644 index 80e2f0a..0000000 --- a/caption-extension/popup.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - My popup - - - - -
- - -
-
- - - - - \ No newline at end of file diff --git a/caption-extension/popup.js b/caption-extension/popup.js deleted file mode 100644 index 5d4f36c..0000000 --- a/caption-extension/popup.js +++ /dev/null @@ -1,118 +0,0 @@ -"use strict"; -import logger from './logger.js'; - -// #region Constants and Helper Functions -function setBadgeText(enabled) { - const text = enabled ? "ON" : "OFF" - void chrome.action.setBadgeText({text: text}) -} - -function isGoogleMeetPage(url) { - return url && (url.startsWith('http://meet.google.com/') || url.startsWith('https://meet.google.com/')); -} - -function showErrorMessage(message) { - const errorElement = document.getElementById("errorMessage"); - errorElement.textContent = message; - errorElement.style.display = message ? "block" : "none"; -} - -function handleContentScriptError(error) { - logger.debug("Error sending message:", chrome.runtime.lastError); - if (error.message.includes("Receiving end does not exist")) { - showErrorMessage("Please refresh the Google Meet page to use this extension"); - } else { - showErrorMessage("An error occurred. Please try again."); - } -} - -function resetSliderState() { - checkbox.checked = false; - setBadgeText(false); - chrome.storage.sync.set({ "slider": false }); -} -// #endregion Constants and Helper Functions - -// #region DOM Elements -const checkbox = document.getElementById("slider"); -const downloadButton = document.querySelector(".downloadBtn"); -// #endregion DOM Elements - -// #region Storage Operations -function initializeSliderState() { - chrome.storage.sync.get("slider", (data) => { - const isEnabled = !!data.slider; - checkbox.checked = isEnabled; - setBadgeText(isEnabled); - }); -} - -function saveSliderState(isChecked) { - chrome.storage.sync.set({ "slider": isChecked }, (error) => { - if (error) { - showErrorMessage("Failed to save settings. Please try again."); - return; - } - setBadgeText(isChecked); - }); -} -// #endregion Storage Operations - -// #region Message Handling -function sendMessageToContentScript(tabId, action) { - chrome.tabs.sendMessage(tabId, { action }, (response) => { - if (chrome.runtime.lastError) { - handleContentScriptError(chrome.runtime.lastError); - return; - } - showErrorMessage(""); // Clear any previous error messages - }); -} - -function handleTabAction(action) { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - if (tabs.length === 0) { - showErrorMessage("No active tab found"); - resetSliderState(); - return; - } - - const currentTab = tabs[0]; - if (!isGoogleMeetPage(currentTab.url)) { - showErrorMessage("Extension can only be used on Google Meet pages"); - resetSliderState(); - return; - } - - sendMessageToContentScript(currentTab.id, action); - }); -} -// #endregion Message Handling - -// #region Event Listeners -function setupSliderListener() { - checkbox.addEventListener("change", (event) => { - const isChecked = event.target.checked; - saveSliderState(isChecked); - handleTabAction(isChecked ? "startCapture" : "stopCapture"); - }); -} - -function setupDownloadButtonListener() { - downloadButton.addEventListener("click", () => { - handleTabAction("downloadSrt"); - }); -} -// #endregion Event Listeners - -// #region Initialization -function initializePopup() { - logger.debug("Initializing popup"); - initializeSliderState(); - setupSliderListener(); - setupDownloadButtonListener(); -} - -// Start the popup initialization -initializePopup(); -// #endregion Initialization diff --git a/docs/action/001_action_doc_writing.md b/docs/action/001_action_doc_writing.md new file mode 100644 index 0000000..31e6d72 --- /dev/null +++ b/docs/action/001_action_doc_writing.md @@ -0,0 +1,51 @@ +# Action — Doc Writing + +با قواعد اینجا باید اکشن‌ها رو بسازی که داک‌های من و خودت هست و بعد از اجرا اگر +جزییاتی داشت که من نیاز داشت بدونم توی ریزالت یک ریزالت با همین نام می‌سازی و +نتیجه‌ی کارت رو داک می‌کنی. +اکشن‌ها جنرال هستن و روی موقعیت‌های مشابه ممکنه همون داک رو مجدد بهت بدم. +اما ریزالت می‌تونه داخلش اطلاعات اون اجرات و درس‌آموخته‌هات باشه. + +--- + +## ساختار فایل‌ها + +``` +docs/ +├── action/ ← عمومی، قابل استفاده مجدد +└── result/ ← مخصوص هر اجرا +``` + +**فرمت نام فایل:** +``` +{sequential_numbering_xxx}_action_{short_title_of_doc}.md +{sequential_numbering_xxx}_result_{short_title_of_doc}.md +``` + +- شماره‌ی `NNN` بین جفت action/result **مشترک** است. +- عنوان کوتاه (`short_title`) در هر دو **یکسان** است. + +--- + +## action چیست؟ + +- **جنرال** — هر بار که موقعیت مشابه پیش آمد، همین فایل داده می‌شود. +- **بدون جزئیات اجرا** — هیچ نام فایل، تاریخ، یا تصمیم مخصوص یک run توش نباشد. +- می‌تواند شامل: هدف، چک‌لیست کارها، قواعد، یا context کلی باشد. +- اگر در حین اجرا متوجه شدی که action ناقص یا گمراه‌کننده بوده، **آن را بهبود بده**. + +## result چیست؟ + +- **مخصوص همان اجرا** — تاریخ، فایل‌های ساخته‌شده، تصمیم‌های گرفته‌شده. +- **درس‌آموخته** — چیزی که دفعه‌ی بعد باید متفاوت انجام شود. +- **وضعیت** — چه چیزی انجام شد، چه چیزی باقی ماند. +- **نیاز به تصمیم** — اگر چیزی هست که کاربر باید تصمیم بگیرد، اینجا بنویس. + +--- + +## قواعد کلی + +- result را **بعد از اجرا** بساز، نه قبل از آن. +- اگر result از قبل وجود داشت (اجرای مجدد همان action)، یا آپدیتش کن یا یک بخش + جدید با تاریخ جدید اضافه کن — فایل را overwrite نکن. +- اطلاعاتی که «کاربر نیاز دارد بداند» را در result بنویس، نه فقط خلاصه‌ی فنی. diff --git a/docs/action/002_action_planning.md b/docs/action/002_action_planning.md new file mode 100644 index 0000000..adef363 --- /dev/null +++ b/docs/action/002_action_planning.md @@ -0,0 +1,77 @@ +- [ ] از اسکریپت laxi هرچیزی که اضافه هست رو حذف کنیم. + - [ ] برای لوگو یک لوگو از خودمون میذاریم. + - [ ] مثلا لاگین یا اگر داده‌ای به جایی میفرسته نباید بفرسته اینا هم اضافی هستن +- [ ] هدف نهایی: بتونیم با استفاده از این پلاگین کپشن رو بگیریم + - [ ] راهکارها: + - [ ] پلاگین خودش یک mcp سرویس بده روی یک پورت + +--- + + +**درباره حذف کدهای laxis:** +فایل‌هایی که احتمالاً باید بررسی بشن: `analytics.js`، `login.js`، `runtime.js` (در `google-meet-transcripts-extension/`) — اینا بیشترین احتمال ارتباط با سرور laxis رو دارن. `config/` هم ممکنه endpoint داشته باشه. پیشنهاد: اول یه `grep` روی همه فایل‌ها برای `laxis` و `api.` و `fetch(` بزن تا دقیق بفهمیم کجاها data میفرسته. + +**درباره MCP روی پورت:** +این رویکرد خوبیه ولی یه محدودیت داره — Chrome extension نمیتونه مستقیم TCP server باز کنه. راه‌حل‌های ممکن: +1. **Native Messaging:** extension با یه اپ native (Python/Node) صحبت میکنه که اون اپ MCP server هست — پیچیده‌تر ولی استانداردتر +2. **WebSocket از extension:** extension یه WebSocket server روی `localhost` باز میکنه از طریق یه background service worker + `chrome.sockets` API — محدودیت‌های manifest v3 داره +3. **Polling روی clipboard یا فایل:** ساده‌ترین راه — extension هر X ثانیه caption رو در یه فایل یا clipboard میریزه، اپ دیگه‌ای میخونه + +کدوم راهکار رو در نظر داری؟ + +--- + +# پیاده‌سازی — معماری Bridge + +> راهکار انتخابی: **افزونه به‌عنوان WebSocket _client_** به یک سرور پایتونِ بیرون مرورگر وصل می‌شود. +> (نه راهکار ۲ که افزونه خودش server باشد — در MV3 سرویس‌ورکر نمی‌تواند پورت TCP باز کند.) + +## دیاگرام جریان داده + +```mermaid +flowchart TD + A["Google Meet
caption روی صفحه"] -->|"DOM observer"| B["content script
captionProcessing.js"] + B -->|"setSpeaker()"| C["storage.js
نقطه‌ی choke هر caption"] + C -->|"chrome.runtime.sendMessage
{type: TRANSCRIPT_UPDATE}"| D["service worker
bridge.js"] + D -->|"WebSocket
ws://127.0.0.1:8765"| E["سرور پایتون
bridge/server.py"] + E -->|"ذخیره"| F["transcripts/<sessionId>.txt"] + E -.->|"ACK / PONG"| D + E ==>|"گام بعدی"| G["MCP Server"] + G ==> H["Claude
transcript زنده را می‌خواند"] + + style E fill:#2F5496,color:#fff + style G fill:#888,color:#fff,stroke-dasharray: 5 5 + style H fill:#888,color:#fff,stroke-dasharray: 5 5 +``` + +## چرا client و نه server؟ + +- **CSP گوگل میت:** اگر WebSocket را از content script باز کنیم، `connect-src` صفحه بلاکش می‌کند. + سرویس‌ورکر تابع CSP صفحه نیست → اتصال از آن‌جا باز می‌شود. +- **MV3 لایف‌سایکل:** سرویس‌ورکر بعد ~۳۰ ثانیه می‌خوابد. دو مکانیزم نگهش می‌دارد: + (۱) هر پیام جدید از content script بیدارش می‌کند، (۲) heartbeat (PING) هر ۲۰ ثانیه + (از Chrome 116+ پیام WebSocket زیر ۳۰ ثانیه ورکر را زنده نگه می‌دارد). + +## وضعیت فعلی — ✅ گام ۱ انجام شد (MVP اتصال) + +| قطعه | فایل | کار | +|------|------|-----| +| سرور پایتون | `bridge/server.py` | WebSocket روی `127.0.0.1:8765`، ذخیره در `transcripts/` | +| WS client | `google-meet-transcripts-extension/bridge.js` | اتصال + reconnect + heartbeat | +| ایمپورت در ورکر | `login.js` | `importScripts("bridge.js")` | +| نقطه‌ی hook | `storage.js` → `setSpeaker` | ارسال `TRANSCRIPT_UPDATE` | +| دسترسی شبکه | `manifest.json` | `ws://127.0.0.1:8765/*` در host_permissions | + +تست end-to-end سمت پایتون با `_smoke_test.py` پاس شد (PONG + ACK + ذخیره‌ی فایل). + +## مراحل بعدی +- [ ] **پاک‌سازی کد laxis** (طبق بالای همین سند): حذف لاگین، analytics، و fetch به `domainUrl`. +- [ ] **تست میدانی با Chrome واقعی:** سرور را بالا بیاور، افزونه را Reload کن، در یک Meet + caption را روشن کن و خروجی را در ترمینال/فایل ببین. +- [ ] **افزودن MCP server** روی همین `server.py` (مثلاً با `mcp` SDK پایتون) تا Claude + بتواند transcript زنده را به‌عنوان resource/tool بخواند. +- [ ] **لوگوی اختصاصی** جایگزین لوگوی laxis. + +## محیط توسعه + +پایتون داخل **venv** اجرا می‌شود تا سیستم تمیز بماند (راهنما: `bridge/README.md`). \ No newline at end of file diff --git a/docs/result/001_result_plan_doc_writing.md b/docs/result/001_result_plan_doc_writing.md new file mode 100644 index 0000000..cba3dee --- /dev/null +++ b/docs/result/001_result_plan_doc_writing.md @@ -0,0 +1,52 @@ +# Result — 001 Plan Doc Writing + +> اکشن مرتبط: [`action/001_action_plan_doc_writing.md`](001_action_doc_writing.md) +> تاریخ اجرا: 2026-06-09 + +این ریزالتِ اجرای قاعده‌ی «نوشتن داک پلن» است. خودِ اکشن جنرال است؛ این‌جا فقط +جزئیات این اجرا و درس‌آموخته‌ها ثبت می‌شود تا دفعه‌ی بعد سریع‌تر و بی‌خطا انجام شود. + +## قاعده‌ای که فهمیدم و اعمال کردم + +سیستم دو لایه است: + +| لایه | مسیر | ماهیت | +|------|------|-------| +| Action | `docs/action/NNN_action_*.md` | عمومی، قابل‌استفاده‌ی مجدد، ورودیِ کار | +| Result | `docs/result/NNN_result_*.md` | مخصوص همان اجرا، شامل درس‌آموخته‌ها، خروجیِ کار | + +قواعد: +- نام result دقیقاً قرینه‌ی action است: `NNN_action_X` → `NNN_result_X`. +- شماره‌ی `NNN` بین جفتِ action/result مشترک است. +- چون action ممکن است دوباره برای موقعیت مشابه داده شود، **هیچ جزئیاتِ مخصوصِ یک اجرا نباید داخل action برود** — همه‌ی آن‌ها در result. +- result باید «چیزهایی که کاربر لازم است بداند» + «درس‌آموخته» را داشته باشد. + +## چه چیزی در این اجرا تولید شد + +سند پلن (`docs/Plan.md`) با یک سکشن **«پیاده‌سازی — معماری Bridge»** گسترش یافت: +- دیاگرام Mermaid از جریان داده (Meet → content script → service worker → پایتون → فایل → MCP/Claude) +- توضیح «چرا client نه server» (CSP گوگل میت + لایف‌سایکل MV3) +- جدول وضعیت گام ۱ + چک‌لیست مراحل بعدی + +همین محتوا در `docs/action/002_action_plan.md` به‌عنوان نسخه‌ی action نگه داشته شده. + +## درس‌آموخته‌ها (برای دفعه‌ی بعد) + +1. **Mermaid در Obsidian:** متنِ هر node را داخل `"..."` بگذار و برای شکستن خط از + `
` استفاده کن. کاراکترهای `<` و `>` داخل لیبل را به‌صورت `<` و `>` + بنویس وگرنه پارسر Mermaid خطا می‌دهد (مثلاً `` → `<sessionId>`). +2. **جهت‌دهی فارسی + کد:** عنوان‌ها و توضیح فارسی، ولی نام فایل/کد/پروتکل را داخل + backtick انگلیسی نگه دار تا RTL آن‌ها را به‌هم نریزد. +3. **لینک‌های نسبی:** result در `docs/result/` است و action در `docs/action/`؛ برای + ارجاع از داخل result به action از `../action/...` استفاده کن (که در Obsidian و + هم در GitHub درست باز می‌شود). +4. **`002_result_plan.md` خالی مانده:** قرینه‌اش (`002_action_plan`) پر است ولی + ریزالتش هنوز نوشته نشده — اگر آن اجرا کامل شد باید پر شود. +5. **همگام‌سازی action 002 با Plan.md:** الان `docs/Plan.md` و + `docs/action/002_action_plan.md` محتوای یکسانی دارند؛ اگر یکی تغییر کرد باید + تصمیم بگیریم کدام «منبع حقیقت» است تا واگرا نشوند. + +## باز است / نیاز به تصمیم + +- منبع حقیقتِ پلن کدام است: `docs/Plan.md` یا `docs/action/002_action_plan.md`؟ +- آیا result ها هم باید در Obsidian قابل‌ناوبری باشند (مثلاً با لینک دوطرفه به action)؟ diff --git a/docs/result/002_result_planning.md b/docs/result/002_result_planning.md new file mode 100644 index 0000000..91acce4 --- /dev/null +++ b/docs/result/002_result_planning.md @@ -0,0 +1,116 @@ +# Result — 002 Planning + +> اکشن مرتبط: [`action/002_action_planning.md`](../action/002_action_planning.md) +> تاریخ اجرا: 2026-06-09 + +--- + +## هدف این جلسه + +خواندن پروژه، فهمیدن ساختار، و پیاده‌سازی گام اول MVP: +اتصال افزونه‌ی Chrome به یک سرور پایتون بیرون مرورگر از طریق WebSocket. + +--- + +## تصمیم‌های کلیدی که گرفته شد + +### معماری — چرا extension به‌عنوان WS client؟ + +افزونه نمی‌تواند خودش سرور باشد (MV3 سرویس‌ورکر TCP port نمی‌تواند باز کند). +پس **افزونه client است و پایتون server** — افزونه به `ws://127.0.0.1:8765` وصل می‌شود. + +همچنین WS باید از service worker باز شود، نه content script؛ چون content script +تابع CSP صفحه‌ی گوگل میت است و `connect-src` اتصال را بلاک می‌کند. + +### نقطه‌ی hook در کد افزونه + +`setSpeaker()` در `storage.js` — هر پاراگراف caption نهایی از این تابع رد می‌شود. +یک `chrome.runtime.sendMessage` کوچک به آن اضافه شد که background را خبر می‌کند. + +### venv برای پایتون + +تصمیم گرفته شد پایتون داخل `.venv` اجرا شود تا سیستم کثیف نشود. + +--- + +## چه چیزی ساخته شد + +| فایل | نوع | کار | +|------|-----|-----| +| `bridge/server.py` | جدید | WebSocket server پایتون روی `127.0.0.1:8765` | +| `bridge/requirements.txt` | جدید | فقط `websockets>=12.0` | +| `bridge/.gitignore` | جدید | `.venv/` و `transcripts/` از git خارج | +| `bridge/README.md` | جدید | راهنمای اجرا با venv | +| `bridge/_smoke_test.py` | جدید | کلاینت تستی بدون Chrome | +| `bridge/.venv/` | جدید | محیط ایزوله پایتون | +| `google-meet-transcripts-extension/bridge.js` | جدید | WS client داخل service worker | +| `login.js` | ویرایش | اضافه شدن `importScripts("bridge.js")` | +| `storage.js` | ویرایش | `setSpeaker` حالا `TRANSCRIPT_UPDATE` می‌فرستد | +| `manifest.json` | ویرایش | `ws://127.0.0.1:8765/*` به `host_permissions` اضافه شد | + +--- + +## تست انجام شده + +سرور پایتون با `_smoke_test.py` end-to-end تست شد: +- PING → PONG ✅ +- سه `TRANSCRIPT_UPDATE` → سه ACK ✅ +- فایل `transcripts/meeting-abc123.txt` با متن فارسی درست ذخیره شد ✅ + +تست با Chrome واقعی هنوز انجام نشده (نیاز به Reload افزونه و یک جلسه‌ی Meet). + +--- + +## وضعیت چک‌لیست اصلی (از action) + +- [x] حذف کدهای laxis — **انجام شد** (اجرای ۲۰۲۶-۰۶-۱۰، پایین را ببین) +- [x] لوگوی اختصاصی — **انجام شد** (لوگوی جدید جایگزین شد) +- [x] گرفتن caption از افزونه — گام ۱ (اتصال) انجام شد +- [x] سرویس MCP روی پورت — **انجام شد** (روی همان `server.py`) +- [ ] تست میدانی با Chrome واقعی — باقی‌مانده (نیاز به مرورگر و یک جلسه‌ی Meet) + +--- + +## اجرای دوم — پاک‌سازی laxis + MCP + لوگو (۲۰۲۶-۰۶-۱۰) + +### پاک‌سازی laxis (هیچ داده‌ای دیگر بیرون نمی‌رود) + +- `analytics.js`: کاملاً خالی شد (از قبل کامنت‌شده و مرده بود؛ در manifest هم لود نمی‌شد). +- `login.js` (service worker): بازنویسی شد — حذف `tabs.onRemoved` (که transcript را آپلود می‌کرد)، + حذف `onMessageExternal` (دریافت توکن) و `getTopics`. فقط import ها + نگه‌داشتن وضعیت لوکال ماند. +- `runtime.js` (content script): حذف فراخوانی `addRemindLogin()` و کل listener دریافت توکن/fetch. +- `meetingInfo.js`: `checkToken` و `renewToken` با `return;` در ابتدا خنثی شدند؛ آپلود پایان جلسه + (`Export2App` + باز کردن `domainUrl/transcript`) حذف شد. +- `transcript.js`: `Export2App` بلافاصله reject می‌کند (آپلود ابری قطع)، `getTopics` خنثی شد، + `addRemindLogin` خالی شد، منوی دانلود از حالت پیش‌فرضِ «app/cloud» به دانلود محلی `txt` تغییر کرد. +- `panel/main.js`: لینک لوگو → `domainUrl/login` در هر سه جا حذف شد؛ متن‌های برند «Laxis…» حذف شدند. +- `manifest.json`: `update_url` فروشگاه حذف شد (که افزونه با ID لاکسیس آپدیت نشود)؛ name/description + به نسخه‌ی محلی و بدون cloud تغییر کرد. + +### MCP روی server.py + +سرور حالا در یک پراسس هم WebSocket (برای افزونه) و هم MCP stdio (برای Claude) را اجرا می‌کند و +حافظه‌ی `sessions` را share می‌کنند. ابزارها: `list_sessions`، `get_transcript`، +`get_latest_transcript` و resource با الگوی `transcript://{session_id}`. کانفیگ Claude Desktop در +`bridge/README.md`. + +### درس‌آموخته‌ها + +- **stdout برای MCP رزرو است:** همه‌ی `print` ها به stderr منتقل شدند، وگرنه استریم JSON-RPC خراب می‌شود. + برای تست دستیِ WebSocket باید از `python server.py --ws-only` استفاده کرد. +- **کد افزونه minified است:** برای فایل‌های فشرده (`meetingInfo.js`) به‌جای حذفِ بدنه‌ها، با `return;` + در ابتدای تابع خنثی‌سازی شد (کم‌ریسک‌تر). کدِ مرده باقی می‌ماند ولی هرگز اجرا نمی‌شود. +- **یک رشته‌ی غیرفعال باقی‌ست:** متنِ «Autosave to Laxis cloud…» در `displayGoogleMeetQuota` + (`transcript.js`) داخل کدِ مرده مانده — هرگز نمایش داده نمی‌شود چون فراخوان‌هایش خنثی‌اند. + +### تأیید (validation) + +- syntax همه‌ی فایل‌های JS با `node --check` پاس شد. +- منطق `server.py` با AST + یک smoke test واقعیِ WebSocket (PONG/ACK + ذخیره‌ی صحیح فارسی) تأیید شد. +- ابزار/resource های MCP با `mcp` SDK ساخته و اجرا شدند (`get_latest_transcript` متن را درست برگرداند). + +### باقی‌مانده برای تو + +تست میدانی: venv را بساز/فعال کن، `python server.py --ws-only` را اجرا کن، افزونه را در +`chrome://extensions` **Reload** کن، در یک Meet واقعی caption را روشن کن و خروجی را در +`bridge/transcripts/` ببین. بعد کانفیگ MCP را به Claude Desktop اضافه کن. diff --git a/google-meet-transcripts-extension/analytics.js b/google-meet-transcripts-extension/analytics.js index 3aefffc..d4063b5 100644 --- a/google-meet-transcripts-extension/analytics.js +++ b/google-meet-transcripts-extension/analytics.js @@ -1 +1,4 @@ -//!function(e,t,c,r,n,s){e.GoogleAnalyticsObject=r,e[r]=e[r]||function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].l=1*new Date,n=t.createElement(c),s=t.getElementsByTagName(c)[0],n.async=1,n.src=chrome.runtime.getURL("analytics/analyticsSource.js"),n.id="laxis-track",s.parentNode.insertBefore(n,s)}(window,document,"script","ga");let firstScript=document.getElementsByTagName("script")[1],s=document.createElement("script");s.src=chrome.runtime.getURL("analytics/load.js"),firstScript.parentNode.insertBefore(s,firstScript); \ No newline at end of file +// analytics.js — غیرفعال شد. +// در نسخه‌ی اصلی laxis این فایل Google Analytics (laxis-track) را تزریق می‌کرد +// و به analytics/analyticsSource.js و analytics/load.js وابسته بود (که دیگر وجود ندارند). +// برای حذف ردیابی و ارسال داده، محتوای آن کاملاً برداشته شد. این فایل در manifest هم لود نمی‌شود. diff --git a/google-meet-transcripts-extension/bridge.js b/google-meet-transcripts-extension/bridge.js new file mode 100644 index 0000000..5a56255 --- /dev/null +++ b/google-meet-transcripts-extension/bridge.js @@ -0,0 +1,68 @@ +// bridge.js — پل ارتباطی افزونه با سرور لوکال پایتون (MVP) +// این فایل داخل service worker اجرا می‌شود (importScripts از login.js). +// content script هر caption را با پیام {type:"TRANSCRIPT_UPDATE"} می‌فرستد، +// و این‌جا روی WebSocket به سرور پایتون (ws://127.0.0.1:8765) forward می‌شود. +// +// چرا از service worker و نه content script؟ چون content script تابعِ CSP سختگیر +// google meet است و connect-src اتصال ws را بلاک می‌کند؛ service worker این محدودیت را ندارد. + +const BRIDGE_URL = "ws://127.0.0.1:8765"; + +let ws = null; +const pending = []; // پیام‌هایی که موقع وصل‌نبودن سوکت صف می‌شوند (MVP: کوتاه) + +function connect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + ws = new WebSocket(BRIDGE_URL); + + ws.onopen = () => { + console.log("[bridge] connected to", BRIDGE_URL); + while (pending.length && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(pending.shift())); + } + }; + + ws.onmessage = (event) => { + // پاسخ سرور پایتون (ACK / PONG) — فعلاً فقط لاگ + console.log("[bridge] <-", event.data); + }; + + ws.onclose = () => { + console.log("[bridge] disconnected, retry in 3s"); + ws = null; + setTimeout(connect, 3000); + }; + + ws.onerror = () => { + try { ws && ws.close(); } catch (e) {} + }; +} + +function sendToBridge(payload) { + connect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(payload)); + } else { + pending.push(payload); + if (pending.length > 500) pending.splice(0, pending.length - 500); + } +} + +// heartbeat: از Chrome 116+ تبادل پیامِ کمتر از ۳۰ ثانیه، service worker را زنده نگه می‌دارد +setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "PING", ts: Date.now() })); + } +}, 20000); + +// دریافت caption از content script و forward به سرور پایتون +chrome.runtime.onMessage.addListener((message) => { + if (message && message.type === "TRANSCRIPT_UPDATE") { + sendToBridge(message); + } +}); + +connect(); diff --git a/google-meet-transcripts-extension/config/share.js b/google-meet-transcripts-extension/config/share.js index 7762c20..4e34294 100644 --- a/google-meet-transcripts-extension/config/share.js +++ b/google-meet-transcripts-extension/config/share.js @@ -1 +1 @@ -const domainUrl="https://app.laxis.tech",extensionId=chrome.runtime.id,loginUrl=domainUrl+"/login?source=chrome-extension&extensionId="+extensionId,upgradeUrl=domainUrl+"/settings/plan",signupUrl=domainUrl+"/signup?source=chrome-extension&extensionId="+extensionId,googleMeetUrl="https://meet.google.com/*"; \ No newline at end of file +const domainUrl="https://example.com",extensionId=chrome.runtime.id,loginUrl=domainUrl+"/login?source=chrome-extension&extensionId="+extensionId,upgradeUrl=domainUrl+"/settings/plan",signupUrl=domainUrl+"/signup?source=chrome-extension&extensionId="+extensionId,googleMeetUrl="https://meet.google.com/*"; \ No newline at end of file diff --git a/google-meet-transcripts-extension/feature/panel/main.js b/google-meet-transcripts-extension/feature/panel/main.js index 922cdb7..e35643b 100644 --- a/google-meet-transcripts-extension/feature/panel/main.js +++ b/google-meet-transcripts-extension/feature/panel/main.js @@ -12,8 +12,8 @@ const addTutorialPanel = () => { t.style.display = "flex", t.style.alignItems = "center", t.style.paddingLeft = "8px"; const i = document.createElement("img"); i.src = chrome.runtime.getURL("image/logo.png"), i.addEventListener("click", (function() { - window.open(`${domainUrl}/login`) - })), i.alt = "none", i.style.height = "30px", i.style.width = "90px", i.style.marginLeft = "-16px", i.style.cursor = "pointer", i.style.zIndex = "999", t.appendChild(i), e.appendChild(t); + void 0 /* لینک لاگین laxis حذف شد */ + })), i.alt = "none", i.style.height = "30px", i.style.width = "90px", i.style.marginLeft = "0px", i.style.cursor = "pointer", i.style.zIndex = "999", t.appendChild(i), e.appendChild(t); const o = document.createElement("div"); o.style.display = "flex", o.style.alignItems = "center"; const d = document.createElement("div"); @@ -42,7 +42,7 @@ const addTutorialPanel = () => { const g = createCaptionOnIcon(); y.appendChild(g), u.appendChild(y), s.appendChild(u); let h = document.createElement("div"); - h.style.textAlign = "center", h.innerHTML = "Laxis supports 69 languages in Google Meet", s.appendChild(h); + h.style.textAlign = "center", h.innerHTML = "Supports 69 languages in Google Meet", s.appendChild(h); let x = document.createElement("div"); x.style.paddingTop = "32px", x.innerHTML = "Download", s.appendChild(x); let C = document.createElement("div"); @@ -52,7 +52,7 @@ const addTutorialPanel = () => { let E = createDownloadIcon(); v.appendChild(E), C.appendChild(v), s.appendChild(C); let b = document.createElement("div"); - b.style.textAlign = "center", b.innerHTML = "Laxis provides multiple formats for downloading the transcripts.", s.appendChild(b), l.appendChild(s), n.appendChild(l), dragElement(n) + b.style.textAlign = "center", b.innerHTML = "Download transcripts in multiple formats.", s.appendChild(b), l.appendChild(s), n.appendChild(l), dragElement(n) } }; @@ -68,9 +68,7 @@ function addMiniPanel() { const n = document.createElement("div"); n.setAttribute("name", "laxis-rootHeader"), n.style.cursor = "move", n.classList.add("col-12"), n.style.height = "50px", n.style.width = "50px", n.style.marginBottom = "-10px", n.style.cursor = "move", n.style.zIndex = "999"; const l = document.createElement("img"); - l.src = chrome.runtime.getURL("image/logo.png"), l.addEventListener("click", (function() { - window.open(`${domainUrl}/login`) - })), l.style.cursor = "pointer", l.style.marginTop = "10px", l.style.height = "25px", l.style.width = "75px", l.style.marginLeft = "-16px", l.alt = "none", n.appendChild(l), t.appendChild(n); + l.src = chrome.runtime.getURL("image/logo128x128.png"), l.style.cursor = "pointer", l.style.marginTop = "5px", l.style.height = "40px", l.style.width = "40px", l.style.marginLeft = "5px", l.alt = "none", n.appendChild(l), t.appendChild(n); const i = document.createElement("div"); i.title = "Expand", i.id = "laxis-expandPanel", i.classList.add("miniButtonContainer"), i.addEventListener("click", addTutorialPanel); const o = createExpandIcon(); @@ -112,8 +110,8 @@ const addCaptionPanel = () => { p.style.display = "flex", p.style.alignItems = "center", p.style.paddingLeft = "8px"; const m = document.createElement("img"); m.src = chrome.runtime.getURL("image/logo.png"), m.alt = "none", m.addEventListener("click", (function() { - window.open(`${domainUrl}/login`) - })), m.style.height = "30px", m.style.marginLeft = "-16px", m.style.cursor = "pointer", m.style.zIndex = "999", p.appendChild(m), r.appendChild(p); + void 0 /* لینک لاگین laxis حذف شد */ + })), m.style.height = "30px", m.style.marginLeft = "0px", m.style.cursor = "pointer", m.style.zIndex = "999", p.appendChild(m), r.appendChild(p); const u = document.createElement("div"); u.style.display = "flex", u.style.alignItems = "center"; const y = document.createElement("div"); @@ -144,7 +142,6 @@ const addCaptionPanel = () => { const I = document.createElement("div"); I.style.width = "300px", I.style.backgroundColor = "#292c35", I.style.paddingTop = "8px"; const w = document.createElement("div"); - w.id = "login-prompt", w.style.display = "none", w.style.margin = "0px 8px", w.style.padding = "4px", w.style.textAlign = "center", w.addEventListener("click", signup), w.style.cursor = "pointer", w.innerHTML = "Please log in to enable autosave to Laxis Cloud.", w.style.borderRadius = "12px", w.style.border = "1px solid rgba(255, 255, 255, 0.5)", w.style.backgroundColor = "#454953", w.style.color = "#ffffff", w.style.fontSize = "10px"; const T = document.createElement("div"); T.id = "google-meet-quota", T.style.display = "none", T.style.margin = "0px 8px", T.style.padding = "4px", T.style.textAlign = "center", T.addEventListener("click", upgrade), T.style.cursor = "pointer", T.innerHTML = "Used Google Meet Quota: ", T.style.borderRadius = "12px", T.style.border = "1px solid rgba(255, 255, 255, 0.5)", T.style.backgroundColor = "#454953", T.style.color = "#ffffff", T.style.fontSize = "10px"; const M = document.createElement("div"); @@ -192,13 +189,11 @@ const addCaptionPanel = () => { n.className = "modal-header", n.innerHTML = "Download", t.appendChild(n); let l = document.createElement("div"), i = document.createElement("div"); - i.className = "modal-body", i.innerHTML = "File Type", l.appendChild(i); let o = document.createElement("div"); o.className = "modal-body", o.innerHTML = "
Highlights
", l.appendChild(o); let d = document.createElement("div"); d.className = "modal-body", d.innerHTML = "
Timestamps
", l.appendChild(d); let a = document.createElement("div"); - a.className = "modal-body", a.title = "Autosave to Laxis every 5 minutes", a.style.cursor = "help", a.innerHTML = "
Autosave to Laxis Cloud
", l.appendChild(a), t.appendChild(l); let s = document.createElement("div"); s.className = "modal-footer"; let c = document.createElement("button"); diff --git a/google-meet-transcripts-extension/feature/record/meetingInfo.js b/google-meet-transcripts-extension/feature/record/meetingInfo.js index 659657d..49a9fc9 100644 --- a/google-meet-transcripts-extension/feature/record/meetingInfo.js +++ b/google-meet-transcripts-extension/feature/record/meetingInfo.js @@ -1 +1 @@ -const getMeetingName=()=>{let e=xpath(XPATH_TITLE_V20220324)?.innerText;e||(e=xpath(XPATH_TITLE)?.innerText),e===SEARCH_TEXT_NO_MEETING_NAME&&(e=xpath(XPATH_TITLE_TOOLTIP)?.innerText);const t=document.location.pathname.match(/\/(.+)/)[1];if(e&&e!==SEARCH_TEXT_NO_MEETING_NAME&&e!==t)return e},checkMeetingStatus=()=>{const e=xpath("//body[@id='yDmH0d']/c-wiz/div/div[2]/div/div/div[3]/div/div/div/div/button/div[2]"),t=getElementWithXPathFallback(document,'//div[contains(@jscontroller,"VQ0pCb")]','//*[contains(text(), "You left the meeting")]');xpath(),e?meetingStatus=0:findButtonContainer()?meetingStatus=1:t&&(meetingStatus=2,clearInterval(checkCaptionStatusInterval),clearInterval(autoSaveInterval),Export2App(!0,!0).then((e=>{increment(`hangout_${currentTranscriptId}`),window.open(`${domainUrl}/transcript/${e}`,"_blank")})).catch((e=>{console.error(e);const t=get(ERROR_SAVING)||[];set(ERROR_SAVING,[...t,e])})))},renewToken=e=>{chrome.storage.local.get(["refreshToken"],(function(t){let n=JSON.stringify({idToken:e,refreshToken:t.refreshToken}),o={Authorization:`Bearer ${e}`,"Content-Type":"application/json"};window.fetch(`${domainUrl}/api/v1/users/token`,{method:"POST",headers:o,body:n}).then((t=>{200===t.status?t.json().then((e=>{const t=new Date;chrome.storage.local.set({last_refresh_date:t},(function(){console.log(t)})),chrome.storage.local.set({token:e.id_token,refreshToken:e.refreshToken},(function(){console.log("Token refreshed")}))})):401===t.status?(debug("Unauthorized"),t.json().then((t=>{saveLog(`renewToken 401 fail ${e} ${t.message}`)})),reDisplayPrompt(),reDisplayRemindLogin()):(t.json().then((t=>{saveLog(`renewToken request fail ${e} ${t.message}`),debug(t.message)})),reDisplayPrompt(),reDisplayRemindLogin())})).catch((t=>{saveLog(`renewToken request exception ${e} ${t}`),debug(t),reDisplayPrompt(),reDisplayRemindLogin()}))}))},checkToken=()=>{chrome.storage.local.get(["token"],(function(e){if(e.token){tryTo((()=>{1e3*JSON.parse(atob(e.token.split(".")[1])).exp-Date.now()<6048e5&&(debug("Refresh JWT token"),renewToken(e.token))})(),"check whether token expired");const t={method:"GET",headers:{Authorization:`Bearer ${e.token}`}},n=()=>window.fetch(`${domainUrl}/api/v1/users/info`,t),o=()=>window.fetch(`${domainUrl}/api/v2/templates?quick-note=true`,t),s=e=>window.fetch(`${domainUrl}/api/v2/templates/${e}/topics`,t);Promise.all([n(),o()]).then((n=>{if(200===n[0].status&&200===n[1].status){loginStatus=1;const e=document.getElementById("login-prompt");e&&(e.style.display="none");const o=document.getElementById("laxis-remindLogin");o&&(o.style.display="none"),n[0].json().then((e=>{const n=e;appUser=`${n.firstName} ${n.lastName}`,window.fetch(`${domainUrl}/api/v1/teams/isAdmin`,t).then((e=>{e.json().then((e=>{e.isAdmin||e.hasTeam?window.fetch(`${domainUrl}/api/v1/teams/info`,t).then((e=>{e.json().then((e=>{console.log(e),displayGoogleMeetQuota(e.usedSecondsQuota/60,-1)}))})):"free"===n.plan?displayGoogleMeetQuota(n.usedSecondsQuota/60,n.secondsQuota/60):displayGoogleMeetQuota(n.usedSecondsQuota/60,-1)}))}))})),n[1].json().then((e=>{let t=bookmarkList;s(e.items[0].id).then((e=>e.json().then((e=>{e.items.forEach((e=>{let n=bookmarkList.findIndex((t=>t.code===e.color));if(-1!==n){if(t[n].name=e.name,document.getElementById(`laxis-highlight-${e.color}-title`)){document.getElementById(`laxis-highlight-${e.color}-title`).innerHTML=e.name}let o=document.getElementById(`laxis-highlight-${e.color}`),s=document.getElementById(`laxis-highlight-${e.color}-mini`);o&&(o.title=`Highlight as ${e.name}`),s&&(s.title=`Highlight as ${e.name}`)}}))})))),bookmarkList=t}))}else 401===n[0].status||401===n[1].status?(401===n[0].status?n[0].json().then((t=>{saveLog(`checkToken 401 fail ${e.token} ${t.message}`)})):n[1].json().then((t=>{saveLog(`checkToken 401 fail ${e.token} ${t.message}`)})),reDisplayPrompt(),reDisplayRemindLogin()):n[0].json().then((e=>{window.alert(e.message)}))})).catch((e=>{window.alert(e)}))}else saveLog(`checkToken token empty ${e.token}`),reDisplayPrompt(),reDisplayRemindLogin()}))};let hasCaptionButtons=!1;const checkCaptionStatus=()=>{if(checkMeetingStatus(),1===meetingStatus){const e=getElementWithXPathFallback(document,XPATH_TURN_ON_CAPTIONS_BUTTON,XPATH_TURN_ON_CAPTIONS_BUTTON_V20210602),t=getElementWithXPathFallback(document,XPATH_TURN_OFF_CAPTIONS_BUTTON,XPATH_TURN_OFF_CAPTIONS_BUTTON_V20210602);if(null==e&&null==t)return void tryTo(startTranscribing(),"start");hasCaptionButtons=!0;let n=!1;if(e&&!t&&(n=!1),!e&&t&&(n=!0),firstStart||n!==weTurnedCaptionsOn){firstStart=!1,weTurnedCaptionsOn=n;const e=document.getElementById("feature"),t=document.getElementById("laxis-caption-toggle");e&&t&&(n?(isCaptionTurnedOn=!0,notificationsOn(),tryTo(startTranscribing(),"start")):tryTo(stopTranscribing(),"stop"))}}},setCurrentTranscriptDetails=()=>{const e=new Date,t=`${e.getFullYear()}-${pad(e.getMonth()+1)}-${pad(e.getDate())}`,n=`${document.location.pathname.match(/\/(.+)/)[1]}-${t}`,o=get(KEY_TRANSCRIPT_IDS)||[],s=!o.includes(n);if(currentTranscriptId=n,chrome.runtime.sendMessage({type:"meetingId",meetingId:n}),s){o.unshift(currentTranscriptId),set(KEY_TRANSCRIPT_IDS,o),currentSessionIndex=increment(`hangout_${currentTranscriptId}`);const e=getMeetingName();e&&set(`${makeTranscriptKey(currentTranscriptId)}_name`,e)}else{currentSessionIndex=get(makeTranscriptKey(currentTranscriptId))||0;const e=get(makeTranscriptKey(currentTranscriptId,currentSessionIndex))||0,t=get(makeTranscriptKey(currentTranscriptId,currentSessionIndex,e));if(t){const e=new Date(t.endedAt),n=parseInt(get(CURRENT_INTERVAL))||10;(new Date).getTime()-e.getTime()>6e4*n?currentSessionIndex=increment(`hangout_${currentTranscriptId}`):checkTranscriptionId()}else checkTranscriptionId()}debug({currentTranscriptId:currentTranscriptId,currentSessionIndex:currentSessionIndex}),loadLocalStorage=1};function saveLog(e){const t={message:e,time:(new Date).toISOString()};chrome.storage.local.get("logs",(function(e){var n=structuredClone(e.logs);void 0===n?n=[t]:n.push(t),chrome.storage.local.set({logs:n})}))} \ No newline at end of file +const getMeetingName=()=>{let e=xpath(XPATH_TITLE_V20220324)?.innerText;e||(e=xpath(XPATH_TITLE)?.innerText),e===SEARCH_TEXT_NO_MEETING_NAME&&(e=xpath(XPATH_TITLE_TOOLTIP)?.innerText);const t=document.location.pathname.match(/\/(.+)/)[1];if(e&&e!==SEARCH_TEXT_NO_MEETING_NAME&&e!==t)return e},checkMeetingStatus=()=>{const e=xpath("//body[@id='yDmH0d']/c-wiz/div/div[2]/div/div/div[3]/div/div/div/div/button/div[2]"),t=getElementWithXPathFallback(document,'//div[contains(@jscontroller,"VQ0pCb")]','//*[contains(text(), "You left the meeting")]');xpath(),e?meetingStatus=0:findButtonContainer()?meetingStatus=1:t&&(meetingStatus=2,clearInterval(checkCaptionStatusInterval),clearInterval(autoSaveInterval))},renewToken=e=>{return;chrome.storage.local.get(["refreshToken"],(function(t){let n=JSON.stringify({idToken:e,refreshToken:t.refreshToken}),o={Authorization:`Bearer ${e}`,"Content-Type":"application/json"};window.fetch(`${domainUrl}/api/v1/users/token`,{method:"POST",headers:o,body:n}).then((t=>{200===t.status?t.json().then((e=>{const t=new Date;chrome.storage.local.set({last_refresh_date:t},(function(){console.log(t)})),chrome.storage.local.set({token:e.id_token,refreshToken:e.refreshToken},(function(){console.log("Token refreshed")}))})):401===t.status?(debug("Unauthorized"),t.json().then((t=>{saveLog(`renewToken 401 fail ${e} ${t.message}`)})),reDisplayPrompt(),reDisplayRemindLogin()):(t.json().then((t=>{saveLog(`renewToken request fail ${e} ${t.message}`),debug(t.message)})),reDisplayPrompt(),reDisplayRemindLogin())})).catch((t=>{saveLog(`renewToken request exception ${e} ${t}`),debug(t),reDisplayPrompt(),reDisplayRemindLogin()}))}))},checkToken=()=>{return;chrome.storage.local.get(["token"],(function(e){if(e.token){tryTo((()=>{1e3*JSON.parse(atob(e.token.split(".")[1])).exp-Date.now()<6048e5&&(debug("Refresh JWT token"),renewToken(e.token))})(),"check whether token expired");const t={method:"GET",headers:{Authorization:`Bearer ${e.token}`}},n=()=>window.fetch(`${domainUrl}/api/v1/users/info`,t),o=()=>window.fetch(`${domainUrl}/api/v2/templates?quick-note=true`,t),s=e=>window.fetch(`${domainUrl}/api/v2/templates/${e}/topics`,t);Promise.all([n(),o()]).then((n=>{if(200===n[0].status&&200===n[1].status){loginStatus=1;const e=document.getElementById("login-prompt");e&&(e.style.display="none");const o=document.getElementById("laxis-remindLogin");o&&(o.style.display="none"),n[0].json().then((e=>{const n=e;appUser=`${n.firstName} ${n.lastName}`,window.fetch(`${domainUrl}/api/v1/teams/isAdmin`,t).then((e=>{e.json().then((e=>{e.isAdmin||e.hasTeam?window.fetch(`${domainUrl}/api/v1/teams/info`,t).then((e=>{e.json().then((e=>{console.log(e),displayGoogleMeetQuota(e.usedSecondsQuota/60,-1)}))})):"free"===n.plan?displayGoogleMeetQuota(n.usedSecondsQuota/60,n.secondsQuota/60):displayGoogleMeetQuota(n.usedSecondsQuota/60,-1)}))}))})),n[1].json().then((e=>{let t=bookmarkList;s(e.items[0].id).then((e=>e.json().then((e=>{e.items.forEach((e=>{let n=bookmarkList.findIndex((t=>t.code===e.color));if(-1!==n){if(t[n].name=e.name,document.getElementById(`laxis-highlight-${e.color}-title`)){document.getElementById(`laxis-highlight-${e.color}-title`).innerHTML=e.name}let o=document.getElementById(`laxis-highlight-${e.color}`),s=document.getElementById(`laxis-highlight-${e.color}-mini`);o&&(o.title=`Highlight as ${e.name}`),s&&(s.title=`Highlight as ${e.name}`)}}))})))),bookmarkList=t}))}else 401===n[0].status||401===n[1].status?(401===n[0].status?n[0].json().then((t=>{saveLog(`checkToken 401 fail ${e.token} ${t.message}`)})):n[1].json().then((t=>{saveLog(`checkToken 401 fail ${e.token} ${t.message}`)})),reDisplayPrompt(),reDisplayRemindLogin()):n[0].json().then((e=>{window.alert(e.message)}))})).catch((e=>{window.alert(e)}))}else saveLog(`checkToken token empty ${e.token}`),reDisplayPrompt(),reDisplayRemindLogin()}))};let hasCaptionButtons=!1;const checkCaptionStatus=()=>{if(checkMeetingStatus(),1===meetingStatus){const e=getElementWithXPathFallback(document,XPATH_TURN_ON_CAPTIONS_BUTTON,XPATH_TURN_ON_CAPTIONS_BUTTON_V20210602),t=getElementWithXPathFallback(document,XPATH_TURN_OFF_CAPTIONS_BUTTON,XPATH_TURN_OFF_CAPTIONS_BUTTON_V20210602);if(null==e&&null==t)return void tryTo(startTranscribing(),"start");hasCaptionButtons=!0;let n=!1;if(e&&!t&&(n=!1),!e&&t&&(n=!0),firstStart||n!==weTurnedCaptionsOn){firstStart=!1,weTurnedCaptionsOn=n;const e=document.getElementById("feature"),t=document.getElementById("laxis-caption-toggle");e&&t&&(n?(isCaptionTurnedOn=!0,notificationsOn(),tryTo(startTranscribing(),"start")):tryTo(stopTranscribing(),"stop"))}}},setCurrentTranscriptDetails=()=>{const e=new Date,t=`${e.getFullYear()}-${pad(e.getMonth()+1)}-${pad(e.getDate())}`,n=`${document.location.pathname.match(/\/(.+)/)[1]}-${t}`,o=get(KEY_TRANSCRIPT_IDS)||[],s=!o.includes(n);if(currentTranscriptId=n,chrome.runtime.sendMessage({type:"meetingId",meetingId:n}),s){o.unshift(currentTranscriptId),set(KEY_TRANSCRIPT_IDS,o),currentSessionIndex=increment(`hangout_${currentTranscriptId}`);const e=getMeetingName();e&&set(`${makeTranscriptKey(currentTranscriptId)}_name`,e)}else{currentSessionIndex=get(makeTranscriptKey(currentTranscriptId))||0;const e=get(makeTranscriptKey(currentTranscriptId,currentSessionIndex))||0,t=get(makeTranscriptKey(currentTranscriptId,currentSessionIndex,e));if(t){const e=new Date(t.endedAt),n=parseInt(get(CURRENT_INTERVAL))||10;(new Date).getTime()-e.getTime()>6e4*n?currentSessionIndex=increment(`hangout_${currentTranscriptId}`):checkTranscriptionId()}else checkTranscriptionId()}debug({currentTranscriptId:currentTranscriptId,currentSessionIndex:currentSessionIndex}),loadLocalStorage=1};function saveLog(e){const t={message:e,time:(new Date).toISOString()};chrome.storage.local.get("logs",(function(e){var n=structuredClone(e.logs);void 0===n?n=[t]:n.push(t),chrome.storage.local.set({logs:n})}))} \ No newline at end of file diff --git a/google-meet-transcripts-extension/feature/record/storage.js b/google-meet-transcripts-extension/feature/record/storage.js index 1f145cb..408467d 100644 --- a/google-meet-transcripts-extension/feature/record/storage.js +++ b/google-meet-transcripts-extension/feature/record/storage.js @@ -1 +1 @@ -const makeFullKey=e=>`__laxis_${e}`,makeTranscriptKey=(...e)=>{const[t,r,o]=e,n=[`hangout_${t}`];return e.length>=2&&(n.push(`session_${r}`),e.length>=3&&n.push(`speaker_${o}`)),n.join("_")},get=e=>{const t=window.localStorage.getItem(makeFullKey(e));return"string"==typeof t||t instanceof String?(debug(e,t),JSON.parse(t)):t},set=(e,t,r=!1)=>{const o=makeFullKey(e),n=JSON.stringify(t),a=makeFullKey("rotationKeys");let s=JSON.parse(window.localStorage.getItem(a))||[],i=new Set(s);for(;;)try{window.localStorage.setItem(o,n),r&&(i.add(o),window.localStorage.setItem(a,JSON.stringify(Array.from(i))));break}catch(e){if(e instanceof DOMException&&("QuotaExceededError"===e.name||e.code===DOMException.QUOTA_EXCEEDED_ERR)){if(console.log("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),debug("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),i.size>0){for(let e=0;e<5&&i.size>0;e++){const e=i.values().next().value;i.delete(e),window.localStorage.removeItem(e)}window.localStorage.setItem(a,JSON.stringify(Array.from(i)));continue}console.error("No keys available to delete.");break}console.error("Unexpected error:",e);break}},remove=e=>{debug(`remove ${makeFullKey(e)}`),window.localStorage.removeItem(makeFullKey(e))},getOrSet=(e,t)=>{const r=get(e);return null==r?(set(e,t),t):r},increment=e=>{const t=get(e);if(null==t)return set(e,0),0;{let r=t+1;return set(e,r),r}},setSpeaker=e=>{set(makeTranscriptKey(e.transcriptId,e.sessionIndex,e.speakerIndex),{image:e.image,person:e.person,text:e.text,startedAt:e.startedAt,endedAt:e.endedAt,highlight:e.highlight},!0)},getTranscript=e=>{const t=get(makeTranscriptKey(e))||0;let r=[];const o=get(makeTranscriptKey(e,t))||0;for(let n=0;n<=o;n+=1){const o=get(makeTranscriptKey(e,t,n));if(o&&o.text&&o.text.match(/\S/g)){startTimeStored||(startTimeStored=new Date(o.startedAt),startTime=new Date(o.startedAt));const a={transcriptId:e,sessionIndex:t,speakerIndex:n,person:o.person in SPEAKER_NAME_MAP?SPEAKER_NAME_MAP[o.person]:o.person,startedAt:new Date(o.startedAt),endedAt:new Date(o.endedAt),image:o.image,text:o.text,highlight:o.highlight};r.push(a)}}return r},deleteTranscript=e=>{const t=get(makeTranscriptKey(e));for(let r=0;r<=t;r+=1){const t=get(makeTranscriptKey(e,r));for(let o=0;o<=t;o+=1)remove(makeTranscriptKey(e,r,o));remove(makeTranscriptKey(e,r))}remove(makeTranscriptKey(e)),get(makeTranscriptKey(`${e}_name`))&&remove(makeTranscriptKey(`${e}_name`));let r=get(KEY_TRANSCRIPT_IDS)||[],o=get(APPLICATION_SPEECH_IDS)||[];const n=r.indexOf(e),a=o.findIndex((t=>t.ext===e));r.splice(n,1),o.splice(a,1),debug("would set transcript to",r),debug("would set transcript pairs to",o),set(KEY_TRANSCRIPT_IDS,r),set(APPLICATION_SPEECH_IDS,o);const s=document.querySelector(`#${e}`);if(s){const e=s.parentNode;e.removeChild(s),0===e.children.length&&(e.parentNode.removeChild(e.previousSibling),e.parentNode.removeChild(e))}else debug(`transcriptNode doesn't exist for ${e}`)},deletePreviousTranscripts=()=>{let e=get(KEY_TRANSCRIPT_IDS)||[];if(e.length>1)for(let t of e)t!==currentTranscriptId&&deleteTranscript(t)}; \ No newline at end of file +const makeFullKey=e=>`__laxis_${e}`,makeTranscriptKey=(...e)=>{const[t,r,o]=e,n=[`hangout_${t}`];return e.length>=2&&(n.push(`session_${r}`),e.length>=3&&n.push(`speaker_${o}`)),n.join("_")},get=e=>{const t=window.localStorage.getItem(makeFullKey(e));return"string"==typeof t||t instanceof String?(debug(e,t),JSON.parse(t)):t},set=(e,t,r=!1)=>{const o=makeFullKey(e),n=JSON.stringify(t),a=makeFullKey("rotationKeys");let s=JSON.parse(window.localStorage.getItem(a))||[],i=new Set(s);for(;;)try{window.localStorage.setItem(o,n),r&&(i.add(o),window.localStorage.setItem(a,JSON.stringify(Array.from(i))));break}catch(e){if(e instanceof DOMException&&("QuotaExceededError"===e.name||e.code===DOMException.QUOTA_EXCEEDED_ERR)){if(console.log("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),debug("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),i.size>0){for(let e=0;e<5&&i.size>0;e++){const e=i.values().next().value;i.delete(e),window.localStorage.removeItem(e)}window.localStorage.setItem(a,JSON.stringify(Array.from(i)));continue}console.error("No keys available to delete.");break}console.error("Unexpected error:",e);break}},remove=e=>{debug(`remove ${makeFullKey(e)}`),window.localStorage.removeItem(makeFullKey(e))},getOrSet=(e,t)=>{const r=get(e);return null==r?(set(e,t),t):r},increment=e=>{const t=get(e);if(null==t)return set(e,0),0;{let r=t+1;return set(e,r),r}},setSpeaker=e=>{set(makeTranscriptKey(e.transcriptId,e.sessionIndex,e.speakerIndex),{image:e.image,person:e.person,text:e.text,startedAt:e.startedAt,endedAt:e.endedAt,highlight:e.highlight},!0);try{chrome.runtime.sendMessage({type:"TRANSCRIPT_UPDATE",sessionId:String(e.transcriptId),speaker:e.person,text:e.text,startedAt:e.startedAt,endedAt:e.endedAt})}catch(t){}},getTranscript=e=>{const t=get(makeTranscriptKey(e))||0;let r=[];const o=get(makeTranscriptKey(e,t))||0;for(let n=0;n<=o;n+=1){const o=get(makeTranscriptKey(e,t,n));if(o&&o.text&&o.text.match(/\S/g)){startTimeStored||(startTimeStored=new Date(o.startedAt),startTime=new Date(o.startedAt));const a={transcriptId:e,sessionIndex:t,speakerIndex:n,person:o.person in SPEAKER_NAME_MAP?SPEAKER_NAME_MAP[o.person]:o.person,startedAt:new Date(o.startedAt),endedAt:new Date(o.endedAt),image:o.image,text:o.text,highlight:o.highlight};r.push(a)}}return r},deleteTranscript=e=>{const t=get(makeTranscriptKey(e));for(let r=0;r<=t;r+=1){const t=get(makeTranscriptKey(e,r));for(let o=0;o<=t;o+=1)remove(makeTranscriptKey(e,r,o));remove(makeTranscriptKey(e,r))}remove(makeTranscriptKey(e)),get(makeTranscriptKey(`${e}_name`))&&remove(makeTranscriptKey(`${e}_name`));let r=get(KEY_TRANSCRIPT_IDS)||[],o=get(APPLICATION_SPEECH_IDS)||[];const n=r.indexOf(e),a=o.findIndex((t=>t.ext===e));r.splice(n,1),o.splice(a,1),debug("would set transcript to",r),debug("would set transcript pairs to",o),set(KEY_TRANSCRIPT_IDS,r),set(APPLICATION_SPEECH_IDS,o);const s=document.querySelector(`#${e}`);if(s){const e=s.parentNode;e.removeChild(s),0===e.children.length&&(e.parentNode.removeChild(e.previousSibling),e.parentNode.removeChild(e))}else debug(`transcriptNode doesn't exist for ${e}`)},deletePreviousTranscripts=()=>{let e=get(KEY_TRANSCRIPT_IDS)||[];if(e.length>1)for(let t of e)t!==currentTranscriptId&&deleteTranscript(t)}; \ No newline at end of file diff --git a/google-meet-transcripts-extension/feature/record/transcript.js b/google-meet-transcripts-extension/feature/record/transcript.js index 736c54a..c711714 100644 --- a/google-meet-transcripts-extension/feature/record/transcript.js +++ b/google-meet-transcripts-extension/feature/record/transcript.js @@ -111,21 +111,17 @@ reDisplayPrompt = () => { e && (e.style.display = "block"), chrome.storage.local.remove("token") }, addRemindLogin = () => { - console.log("add login"); - const e = document.getElementById("laxis-miniPanel"), - t = document.getElementById("laxis-expandPanel"), - o = document.createElement("div"); - o.title = "Login to autosave notes", o.id = "laxis-remindLogin", o.style.width = "40px", o.style.height = "40px", o.classList.add("miniButtonContainer"), o.style.border = "0", o.addEventListener("click", signup), o.style.padding = "0"; - const n = createRemindLoginIcon(); - n.id = "remindLoginIcon", o.appendChild(n), n.id = "remindLoginIcon", o.style.display = "none", e.insertBefore(o, t) + // غیرفعال شد: دکمه‌ی «Login to autosave» مربوط به laxis cloud بود. }, reDisplayRemindLogin = () => { console.log("redisplay"); const e = document.getElementById("laxis-remindLogin"); e && (e.style.display = "block"), chrome.storage.local.remove("token") }, -getTopics = (e, t) => window.fetch(`${domainUrl}/api/v2/templates/${e}/topics`, t), +getTopics = (e, t) => Promise.reject(new Error("Laxis cloud disabled")), Export2App = async (e, t = !1) => new Promise(((o, n) => { + // غیرفعال شد: آپلود transcript به laxis cloud حذف شده است. هیچ داده‌ای بیرون نمی‌رود. + return n(new Error("Laxis cloud disabled")); chrome.storage.local.get(["token"], (function(i) { if (i.token) { let s = document.getElementById("laxis-openDownloadMenu"), @@ -260,10 +256,10 @@ Export2App = async (e, t = !1) => new Promise(((o, n) => { a = () => Export2Txt(s, e); break; default: - a = () => Export2App(o) + a = () => Export2Txt(s, e) } - a().then((e => { - "app" === i && window.open(`${domainUrl}/transcript/${e}`, "_blank") + a().then((() => { + // قبلاً برای گزینه‌ی «app» صفحه‌ی laxis cloud باز می‌شد؛ حذف شد. })).catch((e => { window.alert(e); const t = get(ERROR_SAVING) || []; diff --git a/google-meet-transcripts-extension/image/logo.png b/google-meet-transcripts-extension/image/logo.png index ee5537c752bf5ed87dee9afb065008caca74836f..b896274198ba4b0b551619440af23c0550683524 100644 GIT binary patch literal 11458 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 T!py*KGMxMe6`3k&laT)b*tb5V literal 29101 zcmeFZc|6qX|2ICJ&WRRjld?vNV<-Ds3TZ)QEGhetrLm8F=@2JEkv4`(MV4$88C!%9 zvX7l?VGP;Valfw7Irr!OJ-+w-yC1(let&)Eobx!$yyse8*K2(~Uza<&+L}x|*mhtr z7$&W=XD(wfjC>f(ZzYV|;2qL>q#OKiyVF@CR}6-AANp^LO=1%~^~a*Eo}ruJ#S03S zjyFZktsE_IqMkRM;Asp-N!8QI+|nNBcEkc_V~bbjog$U+9n`lOK*Eic`II3l_N@?3NXM;oSXR(&zlZ-R|QXHUi!ES@H2W_jQ0rr5jT5f z-cxA8BZe1skDPXN!5xti6&JA-7neLDD=#V`BPAs#CwxRwTvA+2Tvkj%Ttre*L0m>b z`oxi6FJ73<#mZXY@)?a^bHQ)Qyf@t3oD{^wJUl!^Jx+)^y4Z+G$ji%%iA#z}N{YY} zBCcL|H*-%Byz9|_MmU3WwREv{aEB`TuD`?t0u%Ez zcM_8j6-Qg5KWJt7?{iM>E)Mj`tt`cG4!D~*yqhaLEAj8MPB$Fg99?fX{%?l<_v8O% z0ub%Ri~r8?U#{im&3~tGbyFw6Hhu}@zkJ$N&&vrXb{XgD=5mbQ4bi9i42E4VY}Za8ILbZ#PWXyWpE5^@Ss zQVQ^^xRipp_~xS*9j$Dwz5abv0zMNFm(`PyRFINakdpmh9tEYbGIuloe|^}>Qo-8M z<)%4o+4iQn4NlAnZ^L`!UqLFIc64xbfr(*tC;sz&t<$Gh*(=m$cRXZOInGT zTj8ulq~zsfWz5atBWu}zzJJEi(j6%Q`u?UpSUFn482@v5G8WSE))M9tBIa@+D=8T% zaS?f(wYZ3!y!i<^8F8Gryo|*^qv^QVf=QY?{O71>RaP*fxwxdfmAI6Jh_$7pl!%n2 zjD?7~l&pk^>lKhKQ**?)Mie;x;y zh<^On7sD_AdUG5ehIRp8y(R0DH3nmUP3z1lJ9n6aLoUn2XdS$D`}c>9;itZZhv4|`HHKTw^u0?D zaacU}18b4@^mxH+uBJU`i!lWpDlUbKtz?yGBk{SMTGIB^lI3bF=8sli1VEhiA<@r5o zjID91M%XFeQNiGsf;7SH=BmNtv8QIQ`TM>qbzWZW>kT7fFx|H{9a~oP%9uaDUbRil zy|o714PW!C^I}ZpRCd)Tg_E2yU9_1lJJuw2Bl*?v^mx^{;PI-^$yp6Z{hiM2g4`a1 z35;!Qz)wEJF3fwi(ukM5?0h=71D?){1*Zob3wAmdyyt0!)i=T84K@{PW98Ik)<^5h z7>rrWFK-)I*XGFKR?sof&Q?uv(u*&Y|1&S~nqJ1MsJ(cb1ou46j2@xTo>?)gVc+c7 z5?UScXM;l3&yQO$W<9@r&ZW7h3?yRZv|>`lo$8llua$faMvu!c>m11PvqR=nM`Ngz z{t9AagJ>mX3ns9@6-W2-PU+qm<@GOa(As~leSSq2@sPA7=1bWUJ>Mu6xo3QQyp~o@ z(|lWn{|&z4tVWLjPQu=ksJr=Q=-_x_Vsr09TnzW! zC=s)>&xNbf5)3t-XtFLm>7vF$ftTHH0t4CEkxr%4%$RZd_ zR_d>{zOu`!>U%O|+!xW~%(2aQG?8*~!Bol1soXLHgP9gBK8tEQ%)=L5?5{KQY2V#C zN&85{auK9t(cwwr1V6LMf~OD$Qy3w3Zwpci<_vf++-qXRq3z=V(y@@jTvr~)scWiU z6T?09c?EyX%lxW~`xSc7x3^#q>DcQ+*Y9O!t7~cejz!&fkc`qwkDw7T`Hqfv_daD; z)$lxj|9wtj?rfh9l~fEL`Ci(EKJU(Z@rCA~azewC1Y@Z6c6{`A*+}LJ_yc2>wEWj0 z`VeB1cpA~DciEQ`iGARqbM5i_4>V&69NFL1zb3IE(v}9sUb-G5R0*;#dWhZRrme+g zD`GH54hH>>#^_IQ%c~?Urz|FsIo-v{Uj5ZEt74{+J+KgoLz_8)M@Fu)woP-}`E12; zt1Lg7qc(N@-F_12U1Z$mu{gD-O0MTs6lQahlC;A#gyP=k(d^MW^ zO(MUgM@wUViO<+)T7*~?6(l=owW%}Rv0l9^U1TPAnQoOuZtb2zs)P{+BNHO#zj**H zy)`8xsUghCH{Ru_C#_FedVm5sOKd7ouYrwCbUlBD>fN5~-P`!N@?FN~Cx~QDP+kr; zln3=TIdWX{ZD3C`U8}m;OM5HZJ<=1sxO!-6*|7Cy??P|G{qDNVlIE%ueBb?U_wq>< zjrke##iPTVz3do_%DdDH{&~Vp!i1^@ zXv*kN_rOgdJDZsk%)7Q?oU9ZSW87ry6gkcB^eP=2$XjT*_SG^E7JkVE>^5s(jo|tu zY`G~=}!p>isaGFy(eC;Ed3Es%75cWAv zC|6*+Nu;f#cbr;4{{nw_*9E!hyNQ0LrX;-+EaoP`kn^qb5*^ns#1-qS{-&N3OY~k} z8Kw*70s02ZJzp8Zsq37ChLS_{r?Cge+AU?%b3w#sC}1OU=imfSy5V9??)2zt_H=xD zzqtU5wk0B&X~H4Ly{6S2R)j=xq(HsHTME{R8>T#=KbBd4r|`0&n0^GLReIl3yr2TrnRX3KVaF1Hc&Q8h}WLQQH#BAO3gfh6S9&)oApPFIt9 z({ z5%fW)?S3$5)B;Vdc~oS_);$tz65P^$b@&7RQ8k1_e+f|n{FPY1)4qFWI~+C zI=K@ZtAGe=AKF~$h*tFYGK_gd$hx7D?gHqKKGLb!os!0b8!_{vf0~=Y+RPAkOuY(pzUkEZC9Ycw&PPyxTPsCdN!{hZ+^znDo#}}-n{6tP0l0am}fq6rr zJvpsJHg>U3ZT;{2`cz#d=n}3%)+l$mS$%32tv%!5=6UAThW4aIEUamO&ea~wR`7}aU*^cFt z6tNK5-Cd)~XJP*})eJ=wlm0Y47=N7EWOX2;CkmW93fD1czgFOeTG9Fik{c?}`W3aB zW=v&`-{SLGJ1q32Mq|V(sqpq73OyWmOjgB-Vx6o=npauqDvI%lX7ODbdO?)+ye!Ral7uj-Y&lk<2G-B`Pb_n!7(>1va{HhCdFR&+^{`^7QL?hZm ziuI;KkYb}qeRz-K4V6?w^{ya;L{5jq0D6%AjPeWN>zRIJqXTsih}<9O-)3;#f=P+B z?e+UAFhEpT{uJRLv0g`C4N7RT+_16W?jVe{7Ros!_R;lqu|{Cv(MnQ#0J)l3tXE;) z>@dnpElz-|d-ygzC$oy~aNVSl7wFD}q0^fWtFn-ezRnm(YlvL2;ri&_bO_E(4W+!1 zrM``u5vX<3B~F_Uc}=nRP^Vq96)nhz*VgU~w0*3y0P86gL@Cb0)OM9_ku>4(o=ciq zSQQl5F)HcRRugu4ib}-S>U$w~wW>&$b6#RjRO$Bb@5(j?kK2k8xbs--SS%8i@XROk zRbMWo2oKFAJ38(2sxk=nGv#Cv%{OsVrd@1B1@2k1*{t;ZL=gG#hp8!SQD|fO^3Dp7 zy4vF;J4BOnsYi?I`@$@v%Gd2&*C4bRXr=h?qdS!v z&w}eCxind*Rq)C3-ZF%S^&i@f*SikW22UqZ$250;P#w|x9n$HY$TpiEU{_wD&5F|P zp%$H~Q%hwtQ7Lhed*c0W5>Z1rWP6eCRH$N7qOz22Vxe#TeB(s64+is08wH+Mf7VnQ zAUB^Bufj@SK(ptEn9*>WWzoo~wwql3ukP1x%Z}@P`|Zsrk99U(7d- zhA_fp+$h})od0Ie-1O6J6$R6CY2S5_PM_R0xO_)4c)Dm$+2TrsF$ovzSCsP9lo(62 z+~|Fw%-wtA;IgZ&^hO)xoj$V~i~0NE2q)=Bc;#KoVTaGGGOjEJ8j{A-vq)%aWe}t~dR`CFh)7JcUam+sT{F(Q#fC zNr*t+6khaLxuok&{8W+im7d!H2!J9Ty!um+yt&U|k4062-djIm-j?C&_0MD|$t(@M z{2-zV4ViXm!}*^joK9N?kT1Be zH(ZzJ-wj@K&80c{KH)d%0b#S!&Xhk*9DyF+!(rpy71xR|ddWR^JJ}eTDt0 zwy&mS2bRqJuHm}sLqjx;oCSRgZ`5WW9dv(yowfKjV|BDU(?2I58}tp!v#z%HX^BKy z0c(i4ETPTsNiuQLtMlUlwS4$@W+Bo3pyOQ@wpPlMmx3FD+gmqW$tqKN;EXXCI}{Z2 z#jsCImirgK_gt;`G5*q7lCZ5sO|?hoG-IM*8s$9hOR;#y$|H@|H)NwKiv;%%qei2b zJ-goQFp?L0S}`=mHaz#_onRvKbMNJ>*^`GQzzU&!e1xKo=|S~b6m=e8!)+{NO9dyA zS4r5jnZl`UQxP>+-rn|#uuB@s zoAYgo>didj9UZHvDM>E{1NM-%=xeExV!_rNu&)eWW2e%RX7mn{-e|v$$k^Fj*%reS z%zv8kIo(UdsDexBD5q}qxj%~*M|MK;R8CWyIg3((hs$Q*C$_7}^co)x&aw0L(~Dp8 zr6jmZ-o#&jER&b8n@5QlJNx21N)a(S+`=ck)bT63+K{pAva!0RYx)V#F3}|1gDRT z9s85M55qv23Imb8<<_x=^8vXo(+apcTA(U_ZJ$REeK0kN@R3}(aOJ{VF z=y5HX03k^3d7iVyRONnfZwMm5H_#>4Y}79Fbn5g~7D%H42iqLG$HCc`G}P9Y9A@Fa zHovc$RJFaQBSZBdyotV-P^5Kp2{c3Wi$2*d+(S8LN*dAHe_40?kd%`)V z%BIMSF}y;T-57y^m4AQ3pS@9h4^T#3^{P)U|C(Mzvc^sW$@HgitmX9`Z~!_4>mc@w zilqrH>{?Y2O|zDK6OxOn`k_%<#_Hr6zV&(Do60Kob1Kf{)w2glJ27M>`uJ~B=jiE* zC|G%&EA^dK#{5$T+mz^r`kMOLq}$DV#*aAOH2q@S)mK-u9PmTOB;YM1{hy^=D>yA{~^uX zlzJ{e*3@C$+z4mljyVL# zdZ#!DyY^rLTT!aj{T}b}So^r~e`JicZT4I*vEpY8Wn&hq&%=MLSHpikJE8132T)^L zb{zCunER}pV~d{4Rc~5bk(y9dx{)|54J)0Yuk_~|@)mdL8wq|AgssBoGl$OvUlg~W z`(P8%8-)gn;a2j_$32EzvV4KkTC3jVYw=Ab;1~hUzX+u<)T`LnreUyCqJ@)4LwthP z`ux>U$6b3zLKx$$+J4+@_~5N-SJ$T`@kx9OB;Yq2OeP;_4KmMEE=)0B)mDSRREreQ zK3sj)!-Ad~DDPMDJySP-H7L|3oH`qxV|zXKIq20wddKP|8*k4fWp-L^&}5WJRL}32 zEc}O&Ox41(T>(-zh@=CMHJTkpyCOAB?pBPce#p-|b$VZzjiqeqIutHqqBSD{Yz-fj zkDLRmC@exKV_{33<8}Kw&5bp)RZB0*aZI2>S3=1{@D--7JK7R=$JzMyF=5hUr2t0P z-H%S3C2a?PMGhmZ?gUhmJzhR~4jzDZTz@EoB0QSZrkXd+kX#uy2=)8F<v&UE03rXxQpY2d)uu;JexDDo6IA7}P7biT3(3$vRC0;(MWK=v1^YTzlVe}cO}u9y z>YoU3mh7S7jSh4D9AGgLQtYOE@gT*lv+8j4PAl_cvKl{xwJY|_Ofv*gzMh|bnafFH z!1U*PIWc0EPO9HmK8z|#9M$c2{i}d&$2a(tcXYpD6z+TJgVuy6e4pkPt-=FZ^W2o` z=M|m6q1{A;auVJ^F>L8%?h}5YDb1^OiQtX$nkRNOY1^+6or~B1nlO|*r`}exo1n5# zwlE#*SP9>e6Ez;5AZp9|IzOQLx6kjwl)gsUbgx04A{guQx6yl0Hr<=iewb3vnlZ7Q z?g7>jXPM(@RAhzk z&d21L^hN;0@09nUtnS_Q2$Og|MKBK-LjkE-WY~-48z?~Qe=3yY{h>6Lg{mI&J&fxNCBo6Di4{Dwn|FHt8Lq)eFD4wMLRU1F3R#MC^GNFN0po) zE_9op&p?JD=QVJ9Kf}hf6FRUwrQa!cYj$f(gF7i#9oc8k=u1c@y2Y)4pYmV7VU(XXQ-9^@RtZf#$RCja$5 z0G*K8P7feRYU&d9jltEvQ0rLcZ4tSUq{{?J{1#`Hpl+y#`HKhO)Gs=}MC zyZaoq{9PvO*Q5Rl87pFkVY9-aNX2*e^!{+6gcO`pThgLzYwKdS41O)~8+-DXt43fU z1YmB8L;t`;UQq4O8GY{Rx3`mCp|=4NfeG|TsTn$tlmY<5G($@(G1N=Ao*LZ9zyjW;${ zq4X_X89N>{HPqTRig37ecC;&2IKc7k4XZKw!+=T|nxd~Y7y29{Fr@$WX<=DNkzr%$ zJ^i~slJmF{^ty_Bw_)UdLs?80Ut+{{U4x*?ClCn?b%@j@`%1=X?NEM|mAUHmM|3)8 zJTs9x6{v-!_Q3J73WR(qB{zd=J zJMeUi0VL`C!}VzhwroR$-}KIkO_QlfT|NO*s`k)s$a2pYUEWE31}qTlv;^gc6rLRgT{a2hUCmuicjqB6v{VBf*cacu z`ew>;OCU)yQ=uK90}eZw$@GPf%(j(-G6*nLK*?{&P3#@A&n=u?CUfe_0)120X}lo< zXkpe0;+U;*RhUDV{>R8!Kkwqc?xwW1uBEmNm~(n4KEO?$+XKP}q)yZ+6NT)6!CJK4 z)>fdmwZk|~UAJO5j)F=Y(ZkKq{nDc(_8^J_7b$3E0w1B+thMN9;c>EuDX(EY^ooz_ zme3;4j?q z#UU%31q%;d9pa?3LYy1zIDx_8FXxT9S1~@SMM&xXPTEmzLyUx+7geSXS*aI_)K-C~ zL~*vo0Q-95gYaeMuri6mi#i*o0B!`zS*tHXDGy7lM@u97w$be9o2o#YO0Hj6`tH`^ zB?rRQ|%NvpV?H*ttC)Y6SA7~SbF0; zWgGd-AdNbkj5k?a7sEK*dr?EXh`}F-Ezif7oSZ(Q{t2#*=vJbbNRio@C zpUEWzkS?&7Ab%~VlAAMe<|NRP$corgg`2Ao(vn+4j4V~2aEMZzEnE-HRy2zNrKCN- z0%M<;V`q3&E9lC51XH?W+H4obA%)I|halLk17x9N^fKV;!MA5`JqBJn4Jr(DR7nPU z9s3dFNKySoU*H;pSvK0gORIXpq{s;=xUjQoKikL9=lwUkh z8e88XYnvL-rSlSwYsqA$Qg-XfjL8^TQ$ThZFivhO5kc46rNQs+2Gu2>tl(WOf@so` z@)~lWXC?|=gW_t-XKvH44OeeMhwnJg_q~C#DiYl-l)cO}4&YaBljy?-^ z{Dku3W6UNc2T5CGItqVWL(I{yiH*KqWkx`_@K=<}pjio{UK5u=sMK8^^?tOL39*}B z7b})ejd}y!eEIee`1HatUkb1&-|V?4h{x+Mk{P+hiXpp!|OO(1*@ zF!Iu(Kbt85cY2)So6?0XWF5i;`9i?_1ee_?0G#xq3RLC{z>=R4EGf;otN8?EJo=`n z?LlJ_>8j6(2?xiPKjnT=k}nwRz;?hJvP8pvv$QtJ*Q__Gg3DIOFn^7h{UzZpIPf~b z0H~j&FWrvtj+IUi*P0r-eI>J`X=sQ1_#Ofim;lGj}S$jn#&%=-)${7?^Xev@z}qJbhJT4lTv|NL!bjQu)>$C?SJQl={pHJo$hZnYN~%e|WMp zMDQiMD`#~oS#|ZEgwUD$hfKlJ1i;b^U3)T|6%wVzP?HAV1E~4T=CRlu2_1_v1!F12 zOQzNS@l6`b7myx>RC0e_>7IQ@l4!r5{uiy^-IDw7mpkrkIfy9@LrN`eaJ#>{X;tci z4$$(Y<7r`TmIzHO{$IL(r|13D5)I-gkkCAC;SIH=7H z6q2VbeL>hK3g>7lZfT!@!p~Zvx2)j(RKPP0h_FpkS|-|o5R}`!Y(DCZIMK+9mqIl3 zgGWkKsSU<_b*jqLhL2-87C%8t%&xHD1|jW%5+DIiBK>1U;lPiY)qDE0Hc;`bTx-Wn zyt*Je+V>=4;X6d1i;~+F4WMAjW-|FOnn{ghPriFO%INaL08Yfb(CMpEwh4y_#m&y5 z$Y|a5BZRGK4em@?Cg95e@{c^(mxkE=BEI2`_Tg&ZXT>TH`1*@Nk>ZK;{#OeYcnS9n{%^zw*4d8sOH==0CpO(xw-! zbVzYPZioR`;kb2iLe=Xh$BK4p%AX@YEul>V8Zvar*P++cRc2xS=$R&aF0QUvIgpql zIt$Biqgi^_LI{dp>BXsMyX%}QY=q;k zm}caHj7L9z?q_Igxv@G2)l$`)#wUu_D$%@BIhS%)d#t6Ey9FJ4XW!@>hqsG)gnbGR zs_c05+IFMtl9Y=_L6WiTLW7DP;0krI@ln^o-4dlOps`9bNjQfd`Hy~0h`Vh<*`5luI(t-CznOrezh^z{cze&?Lr0(TPfmI@(Jmc4s1EM^V%qnHXhd$Xz&|r1 zt5}}+RJD}}=mm9txWL6FQ-7#3<8Ok>(#p#K;07GhQ_peHT|FutO4&eq&A#8dQVcvr z&$I1V+?csl<27PPk#0$?lx1sO6N}a@o?hVJ8rtz>!m+4<@N)&vXs#H%2mETQpq7de zUtLxB%Vi-12#F-TN^P2J<_S*AR*XFDPp&gA>R`4Rgla6$p#TJ=l{341FSsFePeNo* zdC&P8I@MBLBM86>Xc!e`TWyzER9F(-!rcd95M~ph}P>2d83)iEq`uHpk&-{To{XB0`}i&sWS4FV1GcCvb)C?=!qTVF=@k5yHx2)|Y#)WEi!Gr-pHfU)`1b7#5bNwUb4 z%{PTb3&-MsdY0X3X*b>#A0qLDjAhMEYWVmc1rVX2_eMi|Hw@^3!gg{gV;O#-1>$>7w~gs10T)F85Q$FL2Vz4>c3B0kq&vyc>KX)G_k=>;(4gy z;|xuE={ap~a{2BngWBzXvFQHtMVw=gz;5|<}dQIX!vKrYp70;9hx z8~QFzdE3Im#BKFW!Qew!V+BD%>YSlO46S-+HoHYNGs%aTQEg!1QJOl=w8(V89LG0m z$qK0{jZWbY{~5kn!Kc4&5Q`>IcG*3<_7W1xTZ`UZho0ZVM6ArT$ax za%=#5>UaPs?PY8xCGq1@LT24=f-FIE2j)Ghf$Xg$>{Sp}-jwAj1BG(d7j~|(s}kuM1*&7!Ev3mM%VtnmVq}iD|m;0k~D=5Z!1XzImq9y+pjHW;Y zC*=H^*kjb3B4#idi3{496){gZ&J>LZCVW0=%nb=EKWL;Bx>7gZ0F zcs_u`ws%jC`b-TZUu_;ZV*HinPT3u(U|**Zv_FqF;F_x+V3RdlDvO`=_Z1`T$2iTQ z!t~;S*~%%@r>F5)l@-$ZlvEVymR{i{m-x%=JVdxlzt(g(%7pvwAqDM8Q(! z&0CvlbMivbKrjDd!?kN^dpSy-tHX?(zxPM*lzpm>x*pL3;EEWEms^RGS_O|~dTs{% z!aAfc9n(H*xVLDi|IQ_MyPHv8RU8tr#~I;rRw7mx-Yu z?Z;5`qW&-YXM!35kk zA0D$1t3Oo$c;oK&y`NjfBY(DW3PC^LfsqK5&U!5E&kv13(!Z)iiCPW@b|YDNUpqY_ zGzL0fgXPZB92)kQq{qLx^cy=IWf(CikqCWnjcMLnfTZe=O}^F$MJJ+3?B3>{7=uIn zpFN~4nno`pgCYc&!g-l0N?`xpId(K?@|Ed<#9_v7_U45uVpZq$q~F`x7Y2l)D2c`S z5=Z4yl1wI?WC>fRTH`3D><)<@txNzuav5$3EJ!P}vEXmH#0C{-{KAnF9(% z%G!aE1}KW&jkRMxv!tt-63=0yeKi5B;O)|%szS9&wNe;UY8#7!T5IdW;UOB6Ky`jl<`RJWK5o}#Ka$CNB{t)tt|gIJ{nlr4N@rgxH?%ZTwb>4` z2dJG)d+e(WUD1%lKsnoTN@e`|;rh7t{%6|4CCXRekr zC#pB(z$&3lui<*vNzMxz>xGT+rVgkP5Pyp~TEw~nkZIMMcY!l-h%oVK8)JkV6X4R5 z5p}a|baX$YEQU{azpVi9O693H9yE_aXGhQDyDP&|Qz~s^5pWQHP*IcVWO-sD$$k72 z>W^DYh8AmB7n6~Mj{-W|0&pkKUPI*+4%IUrapr9TJ*4dC7h(^ zl_qyMF}_&@$%2C<^H>f-J-f{rT`Q5y;GQb-MmA z#l1dv1%|(Od@#dBVN$7SGE(IcYWNJTdjN|Z%3x3Aq9{#AkVQ&e=#9bKpuEHQilf1w z*VpsGl>>SU0NcR?#(@NE_#6aXnM?a>SS@7JpK8d@?~t;ymmD*_=}~E%(bhu#+p?8? z>Ux)oFK(i(dM5~X%flpSIsT0A7ujrcPeYx+<6{T{PCfh@OlC{IqLx5nR8N*&Q_Y*b zEnQZHu?^OfwLR1yLbg{AZFd<(D?oS)~j-1dfnc<+yiitQRg&lEdK2+*p?B8Mqm zMI|(wVEEh}s8~5lpqmKJQdo1|H%UTv-_Y6lN}u*7m!DI>0-~fi3HjAa7ZOpIbh-+C zmlkc){X4U^Z_D1Q|4k@+Yk&DTLDMurn^fMB4&9GCCvJ9k6#>Pes^~@M*GgTmJOJlx zQ#)Jx9h@haGHPtGdrkLY4uEl#USf|0vjF*&*L!J3UO|Lkv)x70_zNq2BDK)`S#Ax4 z6=7oNHtPrG02*9)DxEoi8MR(+MEFgLLHDoFzqIk@a1ZxRrm*3wvPev9i)d2&Y zKB@Qni$ic~RyHovGu7*f1Yj4rdkb#>$d)yBb@pe!*{B+7jW+ay{7Ux(_P)O|sP|8+ z;k7AfQ0N(7&~Q{_;c*xI?b2}1e%5VW%)4}`CBDGjI&HxHGf;j;9--I8$N#`kQTtuX zSBp+hIl@+lr+gW3eI)D7%2@G~!L!PO^~Pb_UKmB_6+rLC$y~kzpEClMTqpGQeQw`?siIQtsE^sm2?5y7K@AnL!gKl!thW6G{corInowiEXE97t0@> zH+2&HEp5DisH&Lcw^RdoC1&xh0P0SH)C!U;NTO31(5c;rau3e%HJ*=ulD&q}J=)6a2QIx- zLV-mQlkd|*`|;haiIWb~8u#OoWM-&$CN#F|G8eTC43n7(UwO+DwqSIbi#vV`Wc%}A zN6Ftkx*oINXmG=(pQ^{R4w-pr&)8h0X~NwVv5zlqDM+3RToFj4fMOO7(2oSIzVF7C zlNa5zFDO(9cLA^EfxF@P;&;0jJI0a$tm01ZsVj+z^63LcgS*l=Dfo)U_N*rB@Omh# z_NYNq3c8=5%UxXkTVO#-*~S4y^Xm?`l})!gJ4TN?TJk%*cwq+}>D&723ycTfbtDwa z5#8Q?#^1s%RR|1>xSI-%GSk4!IVvZ_{-wn3glx zmq-OgQ|}?9qMtg~ybj6~=&@39$h=L8%|hg&H-E%FvkGXACyo^Yk-=HW^Ck>j324Xw zyAUSj&hpDKmcP#f@)`r*BV7rh+M--G?2CbU>Dd z;sBGI_7Dl~J7_^)BTzIZzb=WSLT4^Cj21qJt8c8iBLT7+W>Fnis+) z%32QHs*{rYS=tw0;MF6yoA<|iW-$d$^J7K(fWJ&(X8yeOB+~WbOw z)BFH#lV&FNf$GPlKbED%0au>>FHIMCQA(_H;}~Q>s0AFQE4tjs18jv0ROcum?pJP8 z&I3*Kh%cjXhd{J?YbTXIj|-^9xRln~k`oQakANw37|dvXq)q7oJKjy^>uH;|pH=TV zg0FzSbX4fKpjMEUlc-*mO^;K*0T)VC~I`Rh}J9sub zjdlmf%@?Gx_sh*G1l7iLv^8fddSb>?Z|vov&?LRENGv`alNQ z0Wg&GG{Kiqpam+?d~5C?FxfXB{J-fe_%UzQOwT=`HR?4BkV#fprE#(tv2ghcq&2*b zk$X+(zPW7j)o^#UGYt6Cj0=-qfN=I;cWLHHN1%K33-<}+NJ`~MT5ZTckxa@ti#Wu5 z(H&H?!uP9rfSht3KH75nvb_9mOuj6tZNH~VE?a=)hdZ}X)&0L!gB6#(?DWb>r1;`aDOrqv^#j1&JICnC|`H^bDP`PvRj` z)?e!?ExO@$5Je0ajTW$4oiK$ntL`mK9oPWI-d&tJg)SGgoyt?BynDJF9^(t#rg6T# zHfp20!|)ShR>6`>fm)=kLu!r&j(YyS_3#EK>6YwU{Nqq6+~~-{r}8f6y|oJ@0UvQQ zar3sJu0an~e9}$BG!6wvbTxq4V+zt7?Z~Y#Ea*jge$H7=Bd_3t&x?987euS5&$y!B zt>l9HGuL+)bBigTg3`{tvEzx`>!f;UTY08!P0WA*ak9tFr{oat*O|hNcS3CAuizd7 z;?P>6(3n~EcYI|bsWA**I04EV%9p{>P^fmiwLTUvel8Fv5Z-dBm{dM17JPGwn=W5Ur#>JE(-ovpD`qV2et6 zef`k)&7&9xCYc5Zb@~fn?Fg$$wWzrwqVl_wWUsgFQ;*CCul8dmghfWQ@4~i?HgCOS zS-=p=U&9&$cJRQ}hfj93%nDvlV{fPF>DR_31&eQ2jH3@i-zVPpna{bPxDiCKTnybQ2SEAVoD9AC^uy4~7#L+ovR zWky34wV!z0eDs{q1^*yqxCza^Lm&4Rnid9NjkRznJ@NU5qD~!O|nE zGdg`Gch6s^%62@xO}(vtqkhGFDlwvr8lLS#wE~Xs>amWg5Zxk4=x;QkaLE`0JIn_6 z^4wDg-fKLjRf@|Od8n57f0Hu~kqz0t@pf0oR3sZoM@;^{kFHgn*lpQLUfuJ+@pu*9Owm!Er(fmkHag7K0R{RurqI5Q9kK@0 zRU$y7%%c%Rs?b$tXYV!wj%E5& z&9^U=14GRb;>r|kUNNEDGHG%-jYzYYiZgm|c!)PEtj22rT9#zAWI0q6&k-`YTC2FF z8mRLJeMTe42WM%iQa%9wmR>rS`dt-Wup38BZ2i}dS1*jNzDi@y&xOmB0N0T67$+y4 zU1rA2T?&5agJ@InCMu~7?~6Qdde}S9)=pZ?%{=qMOWIKYm1oL4X)}y+r&+Dp%Rpf@ z?ige@db}}mDInq0Y14pov_dcEG~e~&V%biULVstpj-u~v9#3)p zayR0O%-*KtVC+`+zC2mf_a$ntK4dNBn13ZuV#Y7_3>B+NMSyWYte71 zVyNd4KVp7Gs?1G%2T4q0xqEnS(ZaX)qW-Hf*bs)}_CL89s-;UA4kh!yJ#=gu+7Y~T zcc;9{xlnunnCW+Fw5n{C#VoH8JAIKb!^D97B2S4`=<5;V!{k5UmJXo%nC{!M8r$>k z?pwOO_AJh(&SDQ~YrjcsI3*2KS$$vFRMpzrq{Z~LuMH)(uX`qSKzj^7)Xz;eu8d0_d;_O9T24yh) zr_mL_d@}O%LMnLs$zY2@iQRu=l8E5qYhoq*o zIYKn#mvSaX@0Vu5rM6tj{gR08jL~m;mxfB!5EAi^k{;!;W=>Y(2bP9Idx|#s27qdS z7TaFMw|&^O0uL?oa6=jr9j#5O6Z7!cn=nY6e@$(+9Wjs4{A(RB@Gkgb%MBJ&@MP1c z6!=}Dws0iz1;dpsp5S|W6#zzIQWD#ImE_1PWOUaI1PFbkS!a?esm*ngs@+!$2~hM_ z3A}-e$G-=XrlIyR^uTq>0dZ}1`08vULeVg7sJSRTOT%X1&=T8IxWr^WNZCoWnEnp5 z62v1p?`88mhxymg>9S?oKUEM_K87Bf>;&c^kH}~Mx&dj(jIzP9=$8eDNw-!X*>pWZ zSBXFWd2V;f=M%x$Tg# zmp39`kvoF47t@RXrtk<>3f)M}n|J6Z(B&k3!)Rg_sh1I0=r(!F!MUFr= zOn$~&e0)k=hMX3YALDXg1+^U+`WeXw&|xqYgsR^@rZo=?=EKahPdidRt`0qO$2WnFs?&IJD%`QcS)nT)*0#r!r#!(l+x$4UK1{>OP3> zGPWu40SLubj6T9fCyc;s(XQtYmy3uU+^E6O;@k(IZMrk@{O0`s|?0TGo!-ODQmv52z4@U)+-OW-INBtiVf8f&+`Pszx;jP#;y{F zrD{6*Y=+kwPGi^M&SuGu=bg+vg)VS~h%3##`IXVKG0*SdW--rX6q?KXo=SUXp8zNw zHDC&f*44Z~f0%?J&9?UkzTxy@#`Ch9?+*^1(*H`MeNZ*(88he^;qUdm;!M#YFcF~u5fU)Zz)D!#oKp-Qo*YF;{?Z}wXC>2?`9ht%Vdx~X8xYuZ~!Xv)4Ayt z1nMhtiSGSbZM}3P7hoVMZ~Jwhlvw7CL^9LV$OE)I(Uq9?L}l{I4LXFRY718#``3>} z%rpRYBxpZejylKmsor6}-+&5V>j1I9T}wWp(G5)?yv>x+!G%G}bQSCXH|aa=O9@f* znnfcA#qE%+c{;0+6Sm^ZdlI0l@3v z7*ThUkf`bp&)DAzqX6NPB$ThhA6V#nWn_fk+j_zU7;QCOINyoVMMmv4QQ*E`20UOUY8e?e*J1{Azg(l^el+Xq*O8iG0)~-=P zNcWjZeYH;k=-myA&pV%1v2hp~iX*+I*3t*|c#HledfUbTURp6%2l-cw=XuqVWy-0I zBxtLEKMn%oILXAPwFH+;4b@j|M1kNLzz+xY&g*-ul+#xNf7L+18rolAWWJb0#-Gi}$DpxD#ba+UEH zB$OA`0B6xkq-8>fBy0C80GB4a3XQgg ze-M9uY9%vqYp76$Ga}O<)0mQ8lW;C3Jb-O;k{uP}XnxP&?l z=6O6@JHDI1X;_X8*<=?pT@9}VR4R6*vsPYlT5J%*dPPTJ0R9Ib2Pus$VESQl0wpXO zO)hxavjCIG$U(ywdP|T8xZ{O?7Ap6S{!#EM*jycwt)~xT*TjGhZVKWex8nPv4m$4j zRs%o!&ufIb(*c@h$K#`E1zT24HIXAvVp<9qyN@oki3qXOF!_pk>}+5K`+`2F>Rs+ zxHTvS-nvhH#Tm)?ayy7}*e_@I+PLgi-Gz#ea|hri04Uk%2>L7_IfH3j^&sUG>mhWt z_X-H~QEyEOXacEg>nh;b;9?30#Mqba-^@!aoXu|1Lb4ohE2Jg&!!0*~Qq$XuqGeA5 zJH^|8se*HONmh>xfvQS)zrqFgXas}by$arpCfsHp=XDQ^=dtV*WqfHXfD+K!5jxT~ z#aw-=6^g-M;Kq;;R{6s5or<_(f(ne@#!PTb4ahJRE$?i*|LO+6XF)8Au;O`V2k)8ZLB(p@ zDn%KYy&J^%${XV3DyJocAq65s)nZG*wp8BSZ{S@w?_H%6q4<{VoC3;uM-H5D^H;qE z$&XOz%2^#W_F*PQ6H{+g$Ulx8x?BWyo#Z~r+hFX{^gYnMa8Y(uzn2k@f)95lqOH=k z;g?;&T#!!lQdQe@MafDB6Nb8r97HvDSzG|ifLF9?MqT-o_ZLPBet@*0%5UnG&S*?q zYD2T*!d#r_fcsnm1uG6Y{ut0^aA75QF`e9LH|d4GDINj1!_15(#y#-#b!08-%Dsmm z0$wvWVW2zOL$?RHIsw?mx+6zMBy8Uegz}eYh`by|51&ws7eXpgRRc&AIBRt_1m1Pu zu2*@n^mp23)R%ID7mIK^^1!GcW#^<3#KBcT%-kE~s)c2M0CZgKV&XFZ(g1HG(piWi zhNIe`%SGt&g~3+3ntOt5!izlbhCx*J=oFlK6JH`+MZus+(SCz$h_jy!Wp5FI0MDzzG-roX_%fUW8kE8crzUq;4&Pgf` zIcD4qMnNlN=-d#PB7;r6%BaW-+(5v=tnezsw88AJ1-t#IuN;L2CDR)v}Ov}TOJzN|ARR;dFb>EQz zgt>tN|J8b_S5P07o!dgV!j|-U=4OFmkOI4|^Sx`;4@rIf0qD~)y@>E~mfh zKNfuH@v}F=WFBRJ0Af5)B_9JZHPedZ4Kr_WpH`tcSZv^LVAU#pj>wy#@ZFE}3ldrb ziv_uqOsm)H-6tW~<^%)()UVL`O{n#F81`Baq`A&F_oD}A}Uv)!u}Y7@fRz4u#1h;e)M927 z{#_Y8?y}Tw>&x5=K+iD*%Y0`)UaLrrh@2RI0IKu_v$QQ7mBqcc5ka(FvCqDqW$Z!K3m=_A+*23urGr2k|08-Kpm( zK?I6}jK~XH?a&$O>^lqtX_SB9o3ay`Wh}$O!aB?5R-5m>!1068bC9>qP&4p!1|JK;kIpHmk4CeVM0b@A|#;@X*EJ3)<|bKB^1DQuy>Lbqx)%6srNqF%?; zaECudPn3;V9dhh3&C)s8(4n(Y8{{Am1k5}1B!;7r(Ox8>Jb6$Yz1t`~OGlOqz^Q?t$*xp{gp{93C0GRLzs|kyA z?PahOAdia@n{=qF38kds4!wAHE2DiL%_>9h<)A})@-AOJbv;}HWE++=umE5Npd&il z^)*9ZRjzsHXA|!7q&47aR2+gB_Hb_Hap?K3Xr~w(pDfm9ZIuwKh8Jdq>);gpd<;sH-A!Tc3&zZ067-;gU+ zQoxYUOz$r~di9a$qw|u8Q5o&+x%jZ~dp3vCbP3{|%sj1GL1_eD->z$q?=VdDNbq`w zJ_1{Tx=7rzH-x^CXVu9^@#!LXZVdL^)Ivgc(Bh|GO8sCTY))QKA9OIc+4T7oeuaBj zAq#5WcNdtN!eTYreq)2}@K~~Hf;R*bvoRj0jSM_Z>+FkMA6|wH(D`}cA1kKRSI5{_ z`X4HP_GSb?ugC^`JR}#MS(>>H8YjpSm)3Q=DL~|25#*BM1 z>Gk@>BU7?DCf-vfkCxG@gmm(l)Ur~V$^vZFSX+S%j6<= z4`aNIQACYC`zjs4gXJU?h5{^^5=i*h0~SDBQZ&YGzSzpbB>v3bkEBlBNu6a^Z1dV# zFy84GE1v+&BPxu-AY>S;SWTDCfKqQ>4RGQ1Fs=vRsO0{_c;CjjQJdw_<)6O7^iEr& z>nNzyAic$?X3#R&T065Ypo|h*QF52gu*l6WzdcWJ>3n+2HZ$3unLm~aP?&vFw(gm8 zZti>N9PMG<;!8|cQkRjLRTK46W+m4R)NOQV;EFJ_dV)C_K)WyB9AK@#rW7tM(kv?) z8Td5!(~p~KDwYF@dI3bZHQAr2Lvf3GEBxf|@Q!;Nd@@>y*(*eu^sWCI*xQ5%o43l2 z-p@&1>PMBQM{*~pO3SY#Ej29JHQi-)gQaau$-kwC)J_1a{*2_Sd9`t2H-NE=2kFd% zU2Tm0YWGD)>96eK&oIeP>?8?xqXwrJ;vVe_3zYB7UO@-39$-;jHW^?g?oV8ITWxj_ zbdW>0Fr}lJ?kj#@jE{vBF@kU|6~)rtm$LgT6#80uX6w>n0;+#M4!Vxp(_8_ahX9wr zoSNLG%|5=USD8ciMLDlpQqdFP31HtlE)rf-4hV}bIVh)$II5wZ&9{p@fp{@#(SuL$ zL`3UF#{GZkhj`gbB*V+ObH7feX=*q^uG{z>7ENXmFV<1k$r!bLEnTqwOc}`x>7v6{ zCNXg=0p#~sbcWb7i#Jgy6_c)9 zVuXCKUnGn6q>E&s&ka5dbqQ1_7t%x z`JlA>-cud8l3-j!>+>iC*iD9e-$#l-Lmf5DDkf4T$pxjA0851(M}Ci5U0r<)B1z1< z1d!l5p1Q(RObeoZ2&y)};y(5F_f~bbo7k>yw;sMYro-Bbu_?H|9o9n#Q3%y~)>|*Lu!j=2tA>?iX6KbS@%no! zLzbt+4HzxYOZ#2DJ6^xzN$cf0G&gh)ZUj+Py%vMq(5hVz40laW<&MO)l#SsQXoqWbS1n}Q?)c4Jx=^s1bc)T`^5_z`mb#{V!*7)up$WF8dx zdkgwOuy4HB9&AXa1i|jH)M}A5)p%X??8<6-w~(GPvYq&#uZ%8pGm%lmI@2p-W+>uM z+%6%zcw|6l`X%;?PT$`|y*o+R->@&8%|rrY^Noj!!}IO)FCcvWJqS=p{J;3Yi!n`P zcN(UWws6MB?N|^A4%E;_Ar{TIBc`xLm~RAQIF(_I4?;Wh?GeI&%s2A9fIR$Dz8urc zhriC|!EScGeSReF^H}0=pN$Cj*?VxG!v+rbIbQy63&mGCAHEbttofl4tt)$fcKzwj It|Nc^8}2eAy#N3J diff --git a/google-meet-transcripts-extension/image/logo128x128.png b/google-meet-transcripts-extension/image/logo128x128.png index 0d205afd64d52cca2b88f6fbce8e50730cc705ed..2f9b57f3e1907ef18bfa718a8ef249f40fd0ba44 100644 GIT binary patch literal 6638 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 literal 9934 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 delta 643 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 JPDHLkV1lJk_Rs(T delta 3178 zcmV-w43+ca6z&+1BYyxHbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU; z5J^NqRCwC#S$SNPmJ;SYg?hgFbn^p`@znkaq z9<<_D z_vFBu&--rT@E|Q7hXE~GJ>nCx*EA}1%V!Sq!tBA`sA<;V(?f+gktu;S*9<*O8Y(ZHnpopBpYQaqpb?{9d1b-#f7Q zQ1RPJt>N{zV*D^F)DcVe5Y?UDZYF}WQ3E=$_Ujv|G<2!RrQyN)saz0s`F z;eX%-F?pZ2W^sJD@{t?x{%MgNfz6aSiv7!#a->%%FfY;@4N4t$T&nt0t>++qs2`HA z)}UIZ#^5j)?5Erydu2nqyC&Q(h}@F_7b+FAXtE7;w!)6A8u&R_Vk{YRD7%L06w^+& ze-|fH((XwqN4TFo{OS5gS|#|F+)Zyy>VKhldRp%-e5E}?uFu(&pq)Xvu zV~*Hv&iM9^3NpypPS%#sGwr~4$BPk0l{mVG8--FWtSxz8ZCEt8{E-`wE>W=FUw`ye zfE`?jsO8ou@jT&wGjW`=*Bd*?$3}P}zp@#*<>Y;;0V0x5zuuttbh7&TK{0{5GT{Al z6|odH!BfJWkxJFJp+yfikrS`hL1%0q`#gfB=LS+SORa#Ly(NZ)yJByumJV4t#_-Sz%{RT}ST|mGE*Th<`m_fr$gW zp`+wJoFRdL&-?!8rIGSSZa_kA9bc(8&Yu$Igi@IX7s>l<9-Jy|LLF7r4qe#*FQicu zQ&y)!Y(Ebiq{^%}FfBpuf(;Lv&i^d~b`~^DA;@Rb274It$$n{~!_M*7)7O2z6}NfTb`?hKhm zPcW+LAU|t}53W|pkyhG>nZx{WmQrluLu=orQVRULhLSDmU3gOUKdOBlUUITg5 z4DnqZu`9m;3SIlc6-XW*7+{Zt6tSs&jG+Sea|#Qc+#Nd?54!#^41c&|KhUhvbDlX+ zvUISg0RDCsI9A$>7rQ#ZKz!#wamVlpbrnF=(1MGlGQ3570oxbX%C$_8tM$77^^66- z7Wk6fP_Fl}| zlj6^ZIN&ga5$pXwAAc&v6DK-ESTCf;WmI=pY(6I<+Q2=zWo|_7?Q(~%)$_i)B%Ptw z=;vojlo8IBT#h~c59j8&kx9|pu8p_8OKX+TYYnxe{Mt@;piXUA8YHwpe}c~C=Oq~B zZwCiT;9b``dOy-%2w#HMqq(&h*WCq1Qsz)rHOq(A-Yz$0SARjz&r>DaEjj4^0@0AM zT^!(UYXPOkaO4n`PzaY3a5%1u|KC4W62nL2(}UfrR3ec`ISBetei-$`^L?h zCw!wHC3aptCK3=lP3z4~5+SR)V=b{IsvbMU4}Uw(lp%_|?wjbywZ7 z4y4tnxN;ry;dp;r$g~FREtO*?p^%>9FrAR-=6H^F=lv*e>?c;b`g9q_5zTmMfG1WQ zEQC^P5PR737qfC>50_0*8I0pr4s^#^i3(Dc0rPsfV0C&8Buf2XpKOoqg5Ps%P)7a3 zynpdskw`RLZ)C1?^AmpCX&qRZQ9E93V8WlK?cH4>XM1wI5G2H|(pC=ULkYK}r#psu z*+m?kn6hPtI!}1+Dh@m<%hB~@sTe4U-`+w8U zcVdz|t;CbhRQ4@u(ne1U7UCEsE?cZ7QLGY=$J{PLWY?1ucCuQ&adMc6aZWf@(XCRU%iKKFgQyS( zA7s{nOY4}i_|D?J{sv8;nYcEb`CN3N{vzDN#zaAOr-+$Gxn?u*t9QSj6Ms?DsR5JE zR`n+DXDsPv&x!G{ZgtB|p&J#E-3c~)it1aCB5Fb!HS6XaWIOV?OMji&^YrZt4?SAG zoiQ4x?Tq&9uukjROxrW`WJne5KvsDZPUY32S)omL;`83vy=Y*k?Pr?-1NY^9NxIG8 z-2NWR&^ouhRiUuqGB8+hxnp$}Jo9V;m$Q9+Y|!xlN2_+U^u5!$!lMn`Y@>m(Xsxcd z)@$?nO@7mQk1;pnR$KCTmubzzGY5MedA6^o?#V06C*#R@GM fetch(`${domainUrl}/api/v2/templates/${e}/topics`, t); -chrome.tabs.onRemoved.addListener(((e, t) => { - chrome.storage.session.get(["sessionList"], (e => { - if (e.sessionList) { - let t = e.sessionList; - console.log(t), chrome.storage.local.get(["token"], (e => { - if (e.token) { - let o = e.token; - fetch(`${domainUrl}/api/v2/templates?quick-note=true`, { - method: "GET", - headers: { - Authorization: `Bearer ${o}`, - "Content-Type": "application/json" - } - }).then((e => { - 200 === e.status ? e.json().then((e => { - e.items.length ? getTopics(e.items[0].id, { - method: "GET", - headers: { - Authorization: `Bearer ${o}`, - "Content-Type": "application/json" - } - }).then((s => { - s.json().then((s => { - const n = s.items; - let r = []; - if (t.forEach((({ - image: e, - person: t, - text: o, - startedAt: s, - endedAt: i, - highlight: a - }) => { - let c = []; - if (a) { - const e = bookmarkList.find((e => e.color === a[0])); - if (e) { - const t = n.find((t => t.color.toLowerCase() === e.code.toLowerCase())); - t && (c = [t.id]) - } - } - o && r.push({ - imageUrl: e, - person: "You" === t ? username : t, - startedAt: s, - endedAt: i || s, - highlights: c, - text: o - }) - })), r.length) { - let t = JSON.stringify({ - meetingId: meetingId, - meetingName: meetingName, - templateId: e.items[0].id, - transcripts: r, - isEnded: !0 - }), - s = { - Authorization: `Bearer ${o}`, - "Content-Type": "application/json" - }; - transcriptId ? fetch(`${domainUrl}/api/v1/speeches/google-meet/${transcriptId}`, { - method: "PUT", - headers: s, - body: t - }).then((e => { - 200 === e.status ? (console.log("save success"), chrome.tabs.create({ - url: `${domainUrl}/transcript/${transcriptId}` - })) : 401 === e.status ? console.log("Expired token") : e.json().then((e => { - console.log(e.message) - })) - })).catch((e => { - console.log(e) - })) : fetch(`${domainUrl}/api/v1/speeches/google-meet`, { - method: "POST", - headers: s, - body: t - }).then((e => { - 200 === e.status ? (chrome.tabs.create({ - url: `${domainUrl}/transcript/${transcriptId}` - }), console.log("success")) : 401 === e.status ? console.log("Expired token") : e.json().then((e => { - console.log(e.message) - })) - })).catch((e => { - console.log(e) - })) - } else console.log("Empty transcript") - })) - })).catch((e => reject(e))) : console.log("No quick note template") - })) : 401 === e.status ? console.log("Expired token") : e.json().then((e => console.log(e.message))) - })) - } - })) - } - })) -})), chrome.runtime.onMessage.addListener((e => { - console.log(e), "meetingId" === e.type && (console.log(e.meetingId), meetingId = e.meetingId), "transcriptId" === e.type && (console.log(e.transcriptId), transcriptId = e.transcriptId), "meetingName" === e.type && (meetingName = e.meetingName), "username" === e.type && (username = e.data) -})), chrome.runtime.onInstalled.addListener((e => { - // e.reason === chrome.runtime.OnInstalledReason.INSTALL && chrome.tabs.create({ - // url: signupUrl - // }, (function(e) {})) -})), chrome.runtime.onMessageExternal.addListener((function(e, t, o) { - t.origin === domainUrl && (o("extension received the token"), chrome.storage.local.set({ - token: e.token, - refreshToken: e.refreshToken - }, (function() {})), chrome.tabs.query({ - url: googleMeetUrl - }, (function(t) { - t[0] && chrome.tabs.sendMessage(t[0].id, { - token: e.token, - refreshToken: e.refreshToken - }, (function(e) {})) - }))) -})), chrome.runtime.setUninstallURL("https://www.laxis.tech/uninstall-survey"); \ No newline at end of file + +// فقط نگه‌داشتن وضعیتِ لوکال؛ هیچ درخواست شبکه‌ای انجام نمی‌شود. +chrome.runtime.onMessage.addListener((e => { + if (!e || !e.type) return; + if ("meetingId" === e.type) meetingId = e.meetingId; + else if ("transcriptId" === e.type) transcriptId = e.transcriptId; + else if ("meetingName" === e.type) meetingName = e.meetingName; + else if ("username" === e.type) username = e.data; +})); + +// در نسخه‌ی laxis این‌جا صفحه‌ی signup باز می‌شد؛ حالا عمداً خالی است. +chrome.runtime.onInstalled.addListener((() => {})); diff --git a/google-meet-transcripts-extension/manifest.json b/google-meet-transcripts-extension/manifest.json index 57ded18..9ae0007 100644 --- a/google-meet-transcripts-extension/manifest.json +++ b/google-meet-transcripts-extension/manifest.json @@ -1,9 +1,7 @@ { -"update_url": "https://clients2.google.com/service/update2/crx", - "manifest_version": 3, - "name": "Google Meet Transcripts & AI Summary", - "description": "Google Meet Transcription, AI Summary and Insight. Get the most out of Google Meet!", + "name": "Meet Transcripts (Local Bridge)", + "description": "Capture Google Meet captions locally and stream them to a local bridge. No cloud, no login, no tracking.", "version": "4.3.12", "icons": { "16": "image/logo16x16.png", @@ -57,5 +55,5 @@ } ], "permissions": ["storage"], - "host_permissions": ["https://meet.google.com/*"] + "host_permissions": ["https://meet.google.com/*", "ws://127.0.0.1:8765/*"] } diff --git a/google-meet-transcripts-extension/runtime.js b/google-meet-transcripts-extension/runtime.js index 8c09ee6..d2056d6 100644 --- a/google-meet-transcripts-extension/runtime.js +++ b/google-meet-transcripts-extension/runtime.js @@ -1 +1,29 @@ -addRoot(),addMiniPanel(),addRemindLogin();const checkOngoingMeeting=setInterval(tryTo(addCaptionPanel,"adding button"),1e3);let notificationsTimeout;const notificationsTimeoutDuration=3e3;let checkCaptionStatusInterval;function saveLog(e){const t={message:e,time:(new Date).toISOString()};chrome.storage.local.get("logs",(function(e){var n=structuredClone(e.logs);void 0===n?n=[t]:n.push(t),chrome.storage.local.set({logs:n})}))}syncSettings(),window.addEventListener("click",(e=>{const t=document.getElementById("laxis-downloadMenu");e.target===t&&(t.style.display="none")})),chrome.runtime.onMessage.addListener((function(e,t,n){if(t.id===extensionId)if(e.token){n("token received by content script");const t={method:"GET",headers:{Authorization:`Bearer ${e.token}`}},o=()=>window.fetch(`${domainUrl}/api/v1/users/info`,t),i=()=>window.fetch(`${domainUrl}/api/v2/templates?quick-note=true`,t),s=e=>window.fetch(`${domainUrl}/api/v2/templates/${e}/topics`,t);Promise.all([o(),i()]).then((t=>{if(200===t[0].status&&200===t[1].status){loginStatus=1;const e=document.getElementById("login-prompt");e&&(e.style.display="none");const n=document.getElementById("laxis-remindLogin");n&&(n.style.display="none"),t[0].json().then((e=>{appUser=`${e.firstName} ${e.lastName}`,chrome.runtime.sendMessage({type:"username",data:`${e.firstName} ${e.lastName}`})})),t[1].json().then((e=>{let t=bookmarkList;s(e.items[0].id).then((e=>e.json().then((e=>{e.items.forEach((e=>{let n=bookmarkList.findIndex((t=>t.code===e.color));if(-1!==n){if(t[n].name=e.name,document.getElementById(`laxis-highlight-${e.color}-title`)){document.getElementById(`laxis-highlight-${e.color}-title`).innerHTML=e.name}let o=document.getElementById(`laxis-highlight-${e.color}`),i=document.getElementById(`laxis-highlight-${e.color}-mini`);o&&(o.title=`Highlight as ${e.name}`),i&&(i.title=`Highlight as ${e.name}`)}}))})))).catch((e=>console.warn(e))),bookmarkList=t}))}else 401===t[0].status||401===t[1].status?(401===t[0].status?t[0].json().then((t=>{saveLog(`chrome.runtime.onMessage.addListener 401 fail ${t.message} ${e.token}`)})):t[1].json().then((t=>{saveLog(`chrome.runtime.onMessage.addListener 401 fail ${t.message} ${e.token}`)})),reDisplayPrompt(),reDisplayRemindLogin()):t[0].json().then((e=>{window.alert(e.message)}))})).catch((e=>{window.alert(e)}))}else saveLog(`chrome.runtime.onMessage.addListener token empty fail ${e.token}`),reDisplayRemindLogin()})); \ No newline at end of file +// runtime.js — نقطه‌ی شروع content script (آخرین فایل لودشده). +// +// نسخه‌ی پاک‌سازی‌شده: +// - فراخوانی addRemindLogin() حذف شد (دیگر دکمه‌ی «Login to autosave» ساخته نمی‌شود). +// - listener دریافت توکن و fetch به laxis (users/info, templates) کاملاً حذف شد. +// بقیه‌ی منطق (ساخت پنل، مانیتور جلسه، تنظیمات، منوی دانلودِ محلی) دست‌نخورده است. + +addRoot(); +addMiniPanel(); + +const checkOngoingMeeting = setInterval(tryTo(addCaptionPanel, "adding button"), 1e3); +let notificationsTimeout; +const notificationsTimeoutDuration = 3e3; +let checkCaptionStatusInterval; + +function saveLog(e) { + const t = { message: e, time: (new Date).toISOString() }; + chrome.storage.local.get("logs", (function(e) { + var n = structuredClone(e.logs); + void 0 === n ? n = [t] : n.push(t), chrome.storage.local.set({ logs: n }) + })) +} + +syncSettings(); + +window.addEventListener("click", (e => { + const t = document.getElementById("laxis-downloadMenu"); + e.target === t && (t.style.display = "none") +})); diff --git a/meet-transcripts/.gitignore b/meet-transcripts/.gitignore new file mode 100644 index 0000000..d9a9eb9 --- /dev/null +++ b/meet-transcripts/.gitignore @@ -0,0 +1,4 @@ +.venv/ +transcripts/ +__pycache__/ +*.pyc diff --git a/meet-transcripts/README.md b/meet-transcripts/README.md new file mode 100644 index 0000000..7338b31 --- /dev/null +++ b/meet-transcripts/README.md @@ -0,0 +1,117 @@ +# meet-transcripts — سرور MCP زیرنویس Google Meet + +سرور پایتونی که caption های Google Meet را از افزونه‌ی Chrome می‌گیرد و آن‌ها را به‌صورت +transcript تمیز و **SRT** به Claude (از طریق MCP) می‌دهد. در یک پراسس دو کار می‌کند: + +``` +Google Meet (content script) + │ chrome.runtime.sendMessage({type:"TRANSCRIPT_UPDATE", …}) + ▼ +افزونه: service worker (bridge.js) + │ WebSocket ws://127.0.0.1:8765 + ▼ +ws_server.py ──▶ storage.py ──▶ transcripts/.{srt,txt,json} + ▲ +mcp_server.py (MCP stdio) ─────────┘ ──▶ Claude Desktop +``` + +## ساختار + +| فایل | کار | +|------|-----| +| `storage.py` | مدل داده، dedup (بر اساس `startedAt`)، رندر SRT/TXT، خواندن/نوشتن دیسک | +| `ws_server.py` | سرور WebSocket؛ caption را از افزونه می‌گیرد و به storage می‌دهد | +| `mcp_server.py` | **نقطه‌ی ورود**؛ WebSocket + MCP را با هم اجرا می‌کند | +| `_smoke_test.py` | تست بدون Chrome | + +## اجرا (با venv) + +```powershell +cd meet-transcripts +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +``` + +```powershell +python mcp_server.py # WebSocket + MCP (حالت عادی / برای Claude Desktop) +python mcp_server.py --ws-only # فقط WebSocket +python ws_server.py # فقط WebSocket (معادلِ بالا، برای تست دستی) +``` + +> همه‌ی لاگ‌ها روی **stderr** اند چون stdout برای پروتکل MCP (JSON-RPC) رزرو است. +> برای تستِ دستیِ WebSocket از `ws_server.py` یا `--ws-only` استفاده کن (وگرنه پراسس +> منتظر اتصال MCP روی stdin می‌ماند). + +## تست بدون Chrome + +```powershell +# ترمینال ۱: +python ws_server.py +# ترمینال ۲: +python _smoke_test.py +``` + +باید `PONG` + چند `ACK` بگیری و `transcripts/meeting-abc123.{srt,txt,json}` ساخته شود. + +## اتصال به Claude Desktop (MCP) + +به `claude_desktop_config.json` اضافه کن (مسیر را با مسیر واقعی عوض کن): + +```jsonc +{ + "mcpServers": { + "meet-transcripts": { + "command": "C:\\...\\audio-voice-converter\\meet-transcripts\\.venv\\Scripts\\python.exe", + "args": ["C:\\...\\audio-voice-converter\\meet-transcripts\\mcp_server.py"] + } + } +} +``` + +Claude Desktop خودش `mcp_server.py` را اجرا می‌کند؛ همان پراسس WebSocket را هم بالا می‌آورد. +اگر یک نمونه‌ی دیگر از قبل پورت ۸۷۶۵ را گرفته باشد، این نمونه فقط MCP را سرو می‌کند و +transcript ها را از روی دیسک می‌خواند (مشکلی پیش نمی‌آید). + +### ابزارها / resourceها + +| ابزار | کار | +|-------|-----| +| `list_sessions` | فهرست جلسه‌ها + تعداد segment + آخرین خط | +| `get_status(session_id?)` | **چک سبکِ تغییر** بدون متن: `latest_seq`, `count`, `updated_at` | +| `get_updates(session_id?, after_seq)` | **خواندن افزایشی**: فقط segmentهای بعد از `after_seq` | +| `get_latest_transcript()` | متن تمیزِ آخرین جلسه (با `[mm:ss]`) | +| `get_transcript(session_id)` | متن تمیزِ یک جلسه | +| `get_latest_srt()` / `get_srt(session_id)` | زیرنویس SRT | +| `transcript://{id}` , `srt://{id}` | resourceها | + +### خواندن افزایشی (به‌جای خواندن کل transcript هر بار) + +هر segment یک `seq` یکتا دارد. برای دنبال‌کردن یک جلسه‌ی زنده بدون خواندن دوباره‌ی همه‌چیز: + +۱. `get_status` بزن (خیلی ارزان، بدون متن). اگر `latest_seq`/`updated_at` عوض شد → +۲. `get_updates(after_seq=)` تا فقط موارد جدید + segmentِ در‌حالِ‌تکمیل را بگیری. + +## dedup و SRT — چه‌طور کار می‌کند + +افزونه برای هر «حرف» چند بار پیام می‌فرستد (snapshotِ روبه‌رشد) ولی `startedAt` ثابت می‌ماند. +سرور با همین `startedAt` می‌فهمد این‌ها یک segment‌اند و فقط کامل‌ترین نسخه را نگه می‌دارد — +پس متن تکراری ذخیره نمی‌شود. چون زمان شروع/پایان داریم، برای هر segment یک بلوک SRT با +تایم‌کد ساخته می‌شود. برای هر جلسه: `.srt` (زیرنویس)، `.txt` (متن خوانا با `[mm:ss]`)، +`.json` (داده‌ی خام برای resume). + +## پروتکل پیام‌ها + +افزونه → سرور: + +| پیام | توضیح | +|------|-------| +| `{type:"PING", ts}` | heartbeat هر ۲۰ ثانیه | +| `{type:"TRANSCRIPT_UPDATE", sessionId, speaker, text, startedAt, endedAt}` | یک caption | + +سرور → افزونه: + +| پیام | توضیح | +|------|-------| +| `{type:"PONG", ts}` | پاسخ heartbeat | +| `{type:"ACK", ok:true}` | تأیید دریافت | diff --git a/meet-transcripts/_smoke_test.py b/meet-transcripts/_smoke_test.py new file mode 100644 index 0000000..fd679e6 --- /dev/null +++ b/meet-transcripts/_smoke_test.py @@ -0,0 +1,37 @@ +"""تست سریع بدون Chrome: شبیه‌سازی چیزی که افزونه می‌فرستد (با startedAt/endedAt). + +اول سرور را بالا بیاور: python ws_server.py +بعد: python _smoke_test.py +باید PONG + چند ACK بگیری و فایل‌های transcripts/meeting-abc123.{srt,txt,json} ساخته شوند. +""" +import asyncio +import json +import websockets + + +async def main(): + base = 1_700_000_000_000 # epoch ms + async with websockets.connect("ws://127.0.0.1:8765") as ws: + await ws.send(json.dumps({"type": "PING", "ts": 1})) + print("PONG:", await ws.recv()) + + # حرف ۱: سه snapshotِ روبه‌رشد با startedAt ثابت → باید در یک segment جمع شوند + for end, txt in [(1200, "سلام"), (2600, "سلام به همه"), (4000, "سلام به همه خوش آمدید")]: + await ws.send(json.dumps({ + "type": "TRANSCRIPT_UPDATE", "sessionId": "meeting-abc123", + "speaker": "Arash", "text": txt, + "startedAt": base, "endedAt": base + end, + })) + print("ACK:", await ws.recv()) + + # حرف ۲: speaker دیگر، startedAt جدید + for end, txt in [(7000, "ممنون"), (8500, "ممنون از دعوت")]: + await ws.send(json.dumps({ + "type": "TRANSCRIPT_UPDATE", "sessionId": "meeting-abc123", + "speaker": "Vahid", "text": txt, + "startedAt": base + 6000, "endedAt": base + end, + })) + print("ACK:", await ws.recv()) + + +asyncio.run(main()) diff --git a/meet-transcripts/mcp_server.py b/meet-transcripts/mcp_server.py new file mode 100644 index 0000000..135bbe0 --- /dev/null +++ b/meet-transcripts/mcp_server.py @@ -0,0 +1,155 @@ +""" +mcp_server.py — نقطه‌ی ورود. WebSocket و MCP را با هم در یک پراسس اجرا می‌کند. + + - ws_server.run_forever() به‌عنوان background task (گرفتن caption از افزونه) + - FastMCP روی stdio (تا Claude Desktop transcript را بخواند) + +هر دو حافظه/فایل‌های مشترکِ storage را می‌بینند. + +اجرا: + pip install -r requirements.txt + python mcp_server.py # WebSocket + MCP (برای Claude Desktop) + python mcp_server.py --ws-only # فقط WebSocket (یا مستقیم: python ws_server.py) + +کانفیگ Claude Desktop به همین فایل اشاره می‌کند (پایین README). +نکته: stdout برای پروتکل MCP رزرو است؛ همه‌ی لاگ‌ها در storage.log به stderr می‌روند. +""" + +import asyncio +import json +import sys +from mcp.server.fastmcp import FastMCP + + + + +import storage +from storage import ( + log, safe_session_id, latest_session, read_txt, read_srt, + read_segments, json_mtime, fmt_clock, list_session_files, +) +import ws_server + + +# --------------------------------------------------------------------------- +# MCP +# --------------------------------------------------------------------------- +def build_mcp() -> "FastMCP": + mcp = FastMCP("meet-transcripts") + + @mcp.tool() + def list_sessions() -> str: + """فهرست جلسه‌های ضبط‌شده (از روی دیسک): تعداد segment و آخرین خط.""" + files = list_session_files() + if not files: + return "هیچ جلسه‌ای هنوز ضبط نشده است." + latest = latest_session() + out = [] + for p in files: + lines = p.read_text(encoding="utf-8").splitlines() + last = lines[-1] if lines else "" + mark = " ← آخرین" if p.stem == latest else "" + out.append(f"- {p.stem}{mark}: {len(lines)} segment | آخرین: {last[:80]}") + return "\n".join(out) + + @mcp.tool() + def get_status(session_id: str = "") -> str: + """وضعیت سبک برای «چیزی تغییر کرد؟» بدون خواندن متن. اگر session_id ندهی، آخرین جلسه. + خروجی JSON: latest_seq، count، live_len، updated_at. اگر latest_seq/updated_at عوض شد، + get_updates بزن.""" + sid = safe_session_id(session_id) if session_id else latest_session() + if not sid: + return json.dumps({"session_id": None, "latest_seq": 0, "count": 0, + "live_len": 0, "updated_at": 0}, ensure_ascii=False) + segs = read_segments(sid) + return json.dumps({ + "session_id": sid, + "latest_seq": segs[-1]["seq"] if segs else 0, + "count": len(segs), + "live_len": len(segs[-1].get("text", "")) if segs else 0, + "updated_at": round(json_mtime(sid), 3), + }, ensure_ascii=False) + + @mcp.tool() + def get_updates(session_id: str = "", after_seq: int = 0) -> str: + """خواندن افزایشی: فقط segmentهای از after_seq به بعد (نه کل transcript). + بار اول after_seq=0 (همه را می‌گیری). دفعات بعد latest_seqِ قبلی را بده تا فقط + موارد جدید + segmentِ زنده را بگیری. خروجی JSON: {latest_seq, count, segments:[{seq,t,speaker,text}]}.""" + sid = safe_session_id(session_id) if session_id else latest_session() + if not sid: + return json.dumps({"session_id": None, "latest_seq": 0, "count": 0, + "segments": []}, ensure_ascii=False) + segs = read_segments(sid) + base = min((s["start"] for s in segs), default=0) + sel = [s for s in segs if s.get("seq", 0) >= after_seq] if after_seq > 0 else segs + out = [{"seq": s.get("seq", 0), "t": fmt_clock(s["start"] - base), + "speaker": s.get("speaker", ""), "text": (s.get("text") or "").strip()} + for s in sel] + return json.dumps({ + "session_id": sid, + "latest_seq": segs[-1]["seq"] if segs else 0, + "count": len(segs), + "segments": out, + }, ensure_ascii=False) + + @mcp.tool() + def get_latest_transcript() -> str: + """متن تمیزِ آخرین جلسه (با [mm:ss]، بدون تکرار).""" + sid = latest_session() + if not sid: + return "هنوز هیچ caption ای دریافت نشده است." + return read_txt(sid) or "" + + @mcp.tool() + def get_transcript(session_id: str) -> str: + """متن تمیزِ یک جلسه با sessionId (با [mm:ss]).""" + txt = read_txt(session_id) + return txt if txt is not None else f"جلسه‌ای با id «{safe_session_id(session_id)}» نیست." + + @mcp.tool() + def get_latest_srt() -> str: + """زیرنویس SRT آخرین جلسه (با تایم‌کد استاندارد).""" + sid = latest_session() + if not sid: + return "هنوز هیچ caption ای دریافت نشده است." + return read_srt(sid) or "" + + @mcp.tool() + def get_srt(session_id: str) -> str: + """زیرنویس SRT یک جلسه با sessionId.""" + srt = read_srt(session_id) + return srt if srt is not None else f"جلسه‌ای با id «{safe_session_id(session_id)}» نیست." + + @mcp.resource("transcript://{session_id}") + def transcript_resource(session_id: str) -> str: + return read_txt(session_id) or "" + + @mcp.resource("srt://{session_id}") + def srt_resource(session_id: str) -> str: + return read_srt(session_id) or "" + + return mcp + + +# --------------------------------------------------------------------------- +# اجرا +# --------------------------------------------------------------------------- +async def main(): + asyncio.create_task(ws_server.run_forever()) + log(f"📁 transcripts در: {storage.TRANSCRIPTS_DIR}") + + ws_only = "--ws-only" in sys.argv + if FastMCP is not None and not ws_only: + log("🔗 MCP server (stdio) فعال است — منتظر اتصال Claude Desktop …") + await build_mcp().run_stdio_async() + else: + if FastMCP is None and not ws_only: + log("ℹ️ پکیج mcp نصب نیست؛ فقط WebSocket اجرا می‌شود (pip install mcp).") + await asyncio.Future() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + log("\n👋 خاموش شد.") diff --git a/meet-transcripts/requirements.txt b/meet-transcripts/requirements.txt new file mode 100644 index 0000000..e52e775 --- /dev/null +++ b/meet-transcripts/requirements.txt @@ -0,0 +1,2 @@ +websockets>=12.0 +mcp>=1.2.0 diff --git a/meet-transcripts/storage.py b/meet-transcripts/storage.py new file mode 100644 index 0000000..c124477 --- /dev/null +++ b/meet-transcripts/storage.py @@ -0,0 +1,234 @@ +""" +storage.py — مدل داده، dedup، و خواندن/نوشتن transcript روی دیسک. + +این ماژول هیچ I/O شبکه‌ای ندارد؛ فقط منطقِ segment و فایل‌ها: + - segmentهای هر جلسه را نگه می‌دارد (با dedup بر اساس startedAt) + - برای هر جلسه سه فایل می‌نویسد: .srt / .txt / .json + - توابع خواندن برای لایه‌ی MCP فراهم می‌کند + +چرا segment‌محور با dedup؟ افزونه برای هر «حرف» چند بار پیام می‌فرستد و هر بار متن +کمی بلندتر می‌شود (snapshotِ روبه‌رشد) ولی startedAt ثابت می‌ماند. پس startedAt کلیدِ +هویتِ segment است: تکرار شد → همان segment به‌روز می‌شود (نه append). این هم تکرار را +حذف می‌کند و هم چون startedAt/endedAt داریم، زمان‌بندیِ SRT دقیق می‌شود. +""" + +import json +import re +import sys +import time +from pathlib import Path + +TRANSCRIPTS_DIR = Path(__file__).parent / "transcripts" +TRANSCRIPTS_DIR.mkdir(exist_ok=True) + +# segmentهای هر جلسه در حافظه: { sid: [ {seq, speaker, text, start, end, start_key}, ... ] } +sessions_segments: "dict[str, list[dict]]" = {} + + +def log(*args) -> None: + """همه‌ی لاگ‌ها به stderr می‌روند؛ stdout برای پروتکل MCP رزرو است.""" + print(*args, file=sys.stderr, flush=True) + + +def safe_session_id(session_id: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", session_id or "") + return cleaned or "default" + + +# --------------------------------------------------------------------------- +# زمان و رندر +# --------------------------------------------------------------------------- +def to_epoch_s(v) -> "float | None": + """startedAt/endedAt را به ثانیه‌ی epoch تبدیل می‌کند (epoch ms، رشته‌ی عددی یا ISO).""" + if v is None or isinstance(v, bool): + return None + if isinstance(v, (int, float)): + return v / 1000.0 if v > 1e11 else float(v) + if isinstance(v, str): + s = v.strip() + if not s: + return None + if s.isdigit(): + n = float(s) + return n / 1000.0 if n > 1e11 else n + try: + from datetime import datetime + return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +def is_continuation(prev: str, cur: str) -> bool: + """fallback وقتی timestamp نداریم: آیا cur ادامه/سوپرستِ همان حرفِ prev است؟""" + if not prev or not cur: + return False + short, long = (prev, cur) if len(prev) <= len(cur) else (cur, prev) + common = 0 + for a, b in zip(short, long): + if a == b: + common += 1 + else: + break + return common >= max(8, int(len(short) * 0.7)) + + +def _fmt_srt(seconds: float) -> str: + if seconds < 0: + seconds = 0 + ms = int(round(seconds * 1000)) + h, ms = divmod(ms, 3600000) + m, ms = divmod(ms, 60000) + s, ms = divmod(ms, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def fmt_clock(seconds: float) -> str: + if seconds < 0: + seconds = 0 + total = int(seconds) + h, rem = divmod(total, 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +def render_srt(segments: "list[dict]") -> str: + if not segments: + return "" + base = min(seg["start"] for seg in segments) + out = [] + for i, seg in enumerate(segments, 1): + start = seg["start"] - base + end = max(seg["end"], seg["start"]) - base + if end <= start: + end = start + 1.0 # حداقل ۱ ثانیه نمایش + speaker = (seg.get("speaker") or "").strip() + text = (seg.get("text") or "").strip() + line = f"{speaker}: {text}" if speaker else text + out.append(f"{i}\n{_fmt_srt(start)} --> {_fmt_srt(end)}\n{line}\n") + return "\n".join(out) + + +def render_txt(segments: "list[dict]") -> str: + if not segments: + return "" + base = min(seg["start"] for seg in segments) + lines = [] + for seg in segments: + ts = fmt_clock(seg["start"] - base) + speaker = (seg.get("speaker") or "").strip() + text = (seg.get("text") or "").strip() + lines.append(f"[{ts}] {speaker}: {text}" if speaker else f"[{ts}] {text}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# نوشتن / به‌روزرسانی +# --------------------------------------------------------------------------- +def ensure_loaded(sid: str) -> "list[dict]": + """segmentهای جلسه را می‌دهد؛ اگر در حافظه نبود از .json می‌خواند (resume).""" + if sid not in sessions_segments: + p = TRANSCRIPTS_DIR / f"{sid}.json" + if p.exists(): + try: + sessions_segments[sid] = json.loads(p.read_text(encoding="utf-8")) + except Exception: + sessions_segments[sid] = [] + else: + sessions_segments[sid] = [] + return sessions_segments[sid] + + +def persist(sid: str) -> None: + segs = sessions_segments.get(sid, []) + (TRANSCRIPTS_DIR / f"{sid}.json").write_text( + json.dumps(segs, ensure_ascii=False), encoding="utf-8") + (TRANSCRIPTS_DIR / f"{sid}.srt").write_text(render_srt(segs), encoding="utf-8") + (TRANSCRIPTS_DIR / f"{sid}.txt").write_text(render_txt(segs), encoding="utf-8") + + +def upsert_segment(session_id, speaker, text, started_at, ended_at) -> None: + """یک caption را اضافه یا (اگر همان حرف باشد) به‌روز می‌کند، سپس روی دیسک می‌نویسد.""" + sid = safe_session_id(session_id) + text = (text or "").strip() + if not text: + return + segs = ensure_loaded(sid) + + start_s = to_epoch_s(started_at) + end_s = to_epoch_s(ended_at) + recv = time.time() + + matched = False + if segs: + last = segs[-1] + if last.get("speaker") == speaker: + lk = last.get("start_key") + if start_s is not None and lk is not None: + matched = abs(lk - start_s) < 0.001 + elif start_s is None and lk is None: + matched = is_continuation(last.get("text", ""), text) + if matched: + last = segs[-1] + last["text"] = text # جدیدترین snapshot کامل‌ترین است + last["end"] = end_s if end_s is not None else recv + else: + seq = (segs[-1]["seq"] + 1) if segs else 1 + segs.append({ + "seq": seq, + "speaker": speaker, + "text": text, + "start": start_s if start_s is not None else recv, + "end": end_s if end_s is not None else (start_s if start_s is not None else recv), + "start_key": start_s, + }) + persist(sid) + + +# --------------------------------------------------------------------------- +# خواندن (برای لایه‌ی MCP) +# --------------------------------------------------------------------------- +def _read(sid: str, ext: str) -> "str | None": + p = TRANSCRIPTS_DIR / f"{safe_session_id(sid)}.{ext}" + if p.exists(): + try: + return p.read_text(encoding="utf-8") + except Exception: + return None + return None + + +def read_txt(sid: str) -> "str | None": + return _read(sid, "txt") + + +def read_srt(sid: str) -> "str | None": + return _read(sid, "srt") + + +def read_segments(sid: str) -> "list[dict]": + p = TRANSCRIPTS_DIR / f"{safe_session_id(sid)}.json" + if p.exists(): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return [] + return [] + + +def json_mtime(sid: str) -> float: + p = TRANSCRIPTS_DIR / f"{safe_session_id(sid)}.json" + return p.stat().st_mtime if p.exists() else 0.0 + + +def latest_session() -> "str | None": + files = list(TRANSCRIPTS_DIR.glob("*.txt")) + if not files: + return None + return max(files, key=lambda p: p.stat().st_mtime).stem + + +def list_session_files(): + """فایل‌های .txt جلسه‌ها را به‌ترتیبِ جدیدترین برمی‌گرداند.""" + return sorted(TRANSCRIPTS_DIR.glob("*.txt"), + key=lambda p: p.stat().st_mtime, reverse=True) diff --git a/meet-transcripts/ws_server.py b/meet-transcripts/ws_server.py new file mode 100644 index 0000000..b40d632 --- /dev/null +++ b/meet-transcripts/ws_server.py @@ -0,0 +1,79 @@ +""" +ws_server.py — سرور WebSocket که caption ها را از افزونه‌ی Chrome می‌گیرد. + +افزونه (service worker → bridge.js) هر caption را با این پیام می‌فرستد: + {type:"TRANSCRIPT_UPDATE", sessionId, speaker, text, startedAt, endedAt} +و heartbeat هم: + {type:"PING", ts} + +این فایل را می‌توان مستقل اجرا کرد (فقط WebSocket، برای تست با _smoke_test.py): + python ws_server.py +ولی در حالت عادی mcp_server.py همین را به‌عنوان background task بالا می‌آورد. +""" + +import asyncio +import json +from datetime import datetime + +import websockets + +from storage import upsert_segment, log + +HOST = "127.0.0.1" +PORT = 8765 + + +async def handle_client(websocket): + peer = getattr(websocket, "remote_address", "?") + log(f"🔌 افزونه وصل شد: {peer}") + try: + async for raw in websocket: + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + log(f"⚠️ پیام نامعتبر: {raw!r}") + continue + + msg_type = data.get("type") + + if msg_type == "PING": + await websocket.send(json.dumps({"type": "PONG", "ts": data.get("ts")})) + continue + + if msg_type == "TRANSCRIPT_UPDATE": + text = (data.get("text") or "").strip() + if not text: + continue + session_id = data.get("sessionId", "default") + speaker = data.get("speaker", "Unknown") + upsert_segment(session_id, speaker, text, + data.get("startedAt"), data.get("endedAt")) + stamp = datetime.now().strftime("%H:%M:%S") + log(f"[{stamp}] ({session_id[:8]}) {speaker}: {text[:60]}") + await websocket.send(json.dumps({"type": "ACK", "ok": True})) + continue + + log(f"❓ نوع ناشناخته: {msg_type}") + except websockets.ConnectionClosed: + pass + finally: + log(f"❌ افزونه قطع شد: {peer}") + + +async def run_forever(): + """WebSocket را بالا می‌آورد. اگر پورت اشغال بود (نمونه‌ی دیگری بالاست) graceful رد می‌شود.""" + try: + async with websockets.serve(handle_client, HOST, PORT): + log(f"🚀 WebSocket روی ws://{HOST}:{PORT} گوش می‌دهد …") + await asyncio.Future() + except OSError as e: + log(f"⚠️ نتوانست ws://{HOST}:{PORT} را بگیرد ({e}).") + log(" این نمونه فقط MCP را سرو می‌کند و transcript ها را از روی دیسک می‌خواند.") + await asyncio.Future() + + +if __name__ == "__main__": + try: + asyncio.run(run_forever()) + except KeyboardInterrupt: + log("\n👋 خاموش شد.")