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👋 خاموش شد.")