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 ee5537c..b896274 100644 Binary files a/google-meet-transcripts-extension/image/logo.png and b/google-meet-transcripts-extension/image/logo.png differ diff --git a/google-meet-transcripts-extension/image/logo128x128.png b/google-meet-transcripts-extension/image/logo128x128.png index 0d205af..2f9b57f 100644 Binary files a/google-meet-transcripts-extension/image/logo128x128.png and b/google-meet-transcripts-extension/image/logo128x128.png differ diff --git a/google-meet-transcripts-extension/image/logo16x16.png b/google-meet-transcripts-extension/image/logo16x16.png index d5bbf2e..3361619 100644 Binary files a/google-meet-transcripts-extension/image/logo16x16.png and b/google-meet-transcripts-extension/image/logo16x16.png differ diff --git a/google-meet-transcripts-extension/image/logo48x48.png b/google-meet-transcripts-extension/image/logo48x48.png index b5f25da..46f7a86 100644 Binary files a/google-meet-transcripts-extension/image/logo48x48.png and b/google-meet-transcripts-extension/image/logo48x48.png differ diff --git a/google-meet-transcripts-extension/login.js b/google-meet-transcripts-extension/login.js index ebd2764..cbea20b 100644 --- a/google-meet-transcripts-extension/login.js +++ b/google-meet-transcripts-extension/login.js @@ -1,127 +1,35 @@ +// login.js — service worker افزونه. +// +// نسخه‌ی پاک‌سازی‌شده: تمام ارتباط با laxis cloud حذف شده است. +// دیگر هیچ توکنی دریافت نمی‌شود و هیچ transcript ای به سروری بیرون فرستاده نمی‌شود. +// این ورکر فقط: +// ۱) اسکریپت‌های لازم (share, panel) و bridge محلی را import می‌کند، +// ۲) چند مقدار وضعیتِ لوکال (meetingId / meetingName / username) را نگه می‌دارد، +// ۳) bridge.js را بالا می‌آورد تا caption ها به سرور لوکال (ws://127.0.0.1:8765) برسند. + try { - importScripts("config/share.js"), importScripts("config/panel.js") + importScripts("config/share.js"), importScripts("config/panel.js"), importScripts("bridge.js") } catch (e) { console.error(e) } + let meetingId = null, transcriptId = null, meetingName = null, username = ""; + chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }); -const getTopics = (e, t) => 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👋 خاموش شد.")