Import Google Meet captions project and extension backups

This commit is contained in:
Arash_M
2026-06-08 13:30:43 +03:30
commit e99d6fb287
61 changed files with 4126 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.qodo
+19
View File
@@ -0,0 +1,19 @@
"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)
+5
View File
@@ -0,0 +1,5 @@
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;
+64
View File
@@ -0,0 +1,64 @@
"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)
}
})
+280
View File
@@ -0,0 +1,280 @@
"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);
}
})();
+47
View File
@@ -0,0 +1,47 @@
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 };
+43
View File
@@ -0,0 +1,43 @@
{
"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/*"
]
}
]
}
+93
View File
@@ -0,0 +1,93 @@
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;
}
+21
View File
@@ -0,0 +1,21 @@
<html lang="en">
<head>
<title>My popup</title>
<link rel="stylesheet" type="text/css" href="popup.css">
</head>
<body>
<div class="container">
<label class="switch">
<input id="slider" type="checkbox">
<span class="slider round"></span>
</label>
<button class="downloadBtn">Download .srt</button>
<div id="errorMessage" class="error-message"></div>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
"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