Skip to content

Instantly share code, notes, and snippets.

@nviet
Last active June 10, 2026 08:50
Show Gist options
  • Select an option

  • Save nviet/06085c2d08edddf4f98cb72e8b9367d9 to your computer and use it in GitHub Desktop.

Select an option

Save nviet/06085c2d08edddf4f98cb72e8b9367d9 to your computer and use it in GitHub Desktop.
Save Teams meeting captions to a file
javascript:var ccUserAvatar,ccCollection,ccIsVisible,ccActiveConversation,ccDesktopBucket,ccEndCheckTimer,ccObserverList,ccObserverDocument,ccListEl,ccSearchLast,ccDirtyBuckets,ccActiveCount,ccWarnedDuration,ccWarnedStructure,ccQuotaFailing,ccLastAutoExport,ccMeterTick,ccPanelMoved,ccStatusLineEl,ccStatusPreviewEl,CC_SELECTORS={captionList:"[data-tid='closed-caption-v2-virtual-list-content']",captionItem:".fui-ChatMessageCompact",captionIdHost:"[data-tid='closed-captions-v2-items-renderer']",captionIdAttr:"data-lpc-hover-target-id",author:"[data-tid='author']",text:"[data-tid='closed-caption-text']",callDuration:"[data-tid='call-duration']",userAvatar:"[data-tid='me-control-avatar-trigger']",callMonitorTitle:"[data-tid='call-monitor-title-style-container']"},CC_STORAGE_PREFIX="ccCaptureStore:",CC_STORAGE_CAP=5242880,CC_AUTOEXPORT_COOLDOWN=3e5,CC_LOG_MAX_LINES=200;function ccFormatTime(e){var t=new Date(e),c=function(e){return String(e).padStart(2,"0")};return t.getFullYear()+"-"+c(t.getMonth()+1)+"-"+c(t.getDate())+"_"+c(t.getHours())+"-"+c(t.getMinutes())+"-"+c(t.getSeconds())}function ccRenderConversation(e,t,c){var n={csv:",",tsv:"\t",txt:" ",srt:"\r\n",vtt:"\r\n"},o={srt:",",vtt:"."};if(""===e||!ccCollection.hasOwnProperty(e))return ccLog("Select a conversation first."),null;if(!n.hasOwnProperty(t))return ccLog("Choose an export format."),null;var r=Object.entries(ccCollection[e].message);r.sort(function(e,t){return e[1].time<t[1].time?-1:e[1].time>t[1].time?1:0});var a={},i=0,l=function(e){return c?(a.hasOwnProperty(e.author)||(i++,a[e.author]="Speaker "+i),a[e.author]):e.author},s="vtt"==t?["WEBVTT\r\n"]:[];for(let e=0;e<r.length;e++){const c=r[e][1],a=l(c);var d=c.time,u=e===r.length-1?"09:59:59":r[e+1][1].time,v=d==u?"999":"000";switch("srt"!=t&&"vtt"!=t||(d+=o[t]+"000",u+=o[t]+v),t){case"csv":case"tsv":var p=['"'+a.replace(/"/g,'""')+'"',c.time,'"'+c.text.replace(/"/g,'""')+'"'];break;case"txt":p=[a,"("+c.time+"):",c.text];break;case"srt":p=[(e+1).toString(),d+" --\x3e "+u,a+": "+c.text+"\r\n"];break;case"vtt":p=[d+" --\x3e "+u,"<v "+a+">"+c.text+"\r\n"]}s.push(p.join(n[t]))}return s.join("\r\n")}function ccExportConversation(e,t,c){var n=ccRenderConversation(e,t,c);if(null===n)return!1;var o=new Blob([n],{type:"text/plain;charset=utf-8"}),r=document.createElement("a");r.href=URL.createObjectURL(o),r.download=e+"."+t,document.body.appendChild(r),r.click(),document.body.removeChild(r),URL.revokeObjectURL(r.href),ccLog('Exported "'+e+'" as '+t.toUpperCase()+(c?" (anonymized).":"."))}function ccCopyConversation(){var e=document.getElementById("ccConversationSelect").value,t=document.getElementById("ccFormatSelect").value,c=document.getElementById("ccAnonymize").checked;""===t&&(t="txt",ccLog("No format chosen - copying as plain text."));var n=ccRenderConversation(e,t,c);null!==n&&navigator.clipboard.writeText(n).then(function(){ccLog('Copied "'+e+'" to the clipboard as '+t.toUpperCase()+(c?" (anonymized).":"."))},function(){ccLog("Could not access the clipboard - use Download instead.")})}function ccStorageUsage(){for(var e=0,t=0;t<localStorage.length;t++){var c=localStorage.key(t);e+=c.length+(localStorage.getItem(c)||"").length}return e}function ccUpdateStorageMeter(){var e=document.getElementById("ccStorageFill"),t=document.getElementById("ccStorageText");if(e&&t){var c=ccStorageUsage(),n=Math.min(100,Math.round(c/CC_STORAGE_CAP*100));e.style.width=n+"%",e.style.background=n>=85?"var(--cc-danger)":"var(--cc-accent)",t.textContent="Storage: "+(c/1048576).toFixed(1)+" MB ("+n+"%)"}}function ccShowStorageWarning(e){var t=document.getElementById("ccStorageWarn");t&&(t.style.display=e?"block":"none")}function ccMaybeAutoExport(){var e=document.getElementById("ccAutoExport");if(e&&e.checked&&!(Date.now()-ccLastAutoExport<CC_AUTOEXPORT_COOLDOWN)){var t=ccActiveConversation;t&&ccCollection.hasOwnProperty(t)&&(ccLastAutoExport=Date.now(),ccLog('Storage is full - auto-exporting "'+t+'" as CSV.'),ccExportConversation(t,"csv",!1))}}function ccSaveToStorage(){var e=Object.keys(ccDirtyBuckets);if(0!==e.length){for(var t=!1,c=0;c<e.length;c++){var n=e[c];if(ccCollection.hasOwnProperty(n)){var o=CC_STORAGE_PREFIX+n,r=ccCollection[n];try{var a=JSON.parse(localStorage.getItem(o)||"null");if(a&&a.message){for(var i in r.message)a.message[i]=r.message[i];a.time=Math.min(a.time,r.time),r=a}}catch(e){}try{localStorage.setItem(o,JSON.stringify(r)),delete ccDirtyBuckets[n]}catch(e){t=!0}}else delete ccDirtyBuckets[n]}t?(ccShowStorageWarning(!0),ccQuotaFailing||(ccQuotaFailing=!0,ccLog("Could not save: storage is full. Capturing continues in memory; delete a conversation to free space.")),ccMaybeAutoExport(),ccUpdateStorageMeter()):(ccShowStorageWarning(!1),ccQuotaFailing&&(ccQuotaFailing=!1,ccLastAutoExport=0,ccLog("Storage is writable again.")))}}function ccLoadFromStorage(){for(var e=0;e<localStorage.length;e++){var t=localStorage.key(e);if(0===t.indexOf(CC_STORAGE_PREFIX)){var c;try{c=JSON.parse(localStorage.getItem(t)||"null")}catch(e){continue}if(c&&c.message){var n=t.slice(CC_STORAGE_PREFIX.length);if(ccCollection.hasOwnProperty(n))for(var o in c.message)ccCollection[n].message.hasOwnProperty(o)||(ccCollection[n].message[o]=c.message[o]);else ccCollection[n]=c,ccAddConversationOption(n)}}}}function ccRemoveFromStorage(e){localStorage.removeItem(CC_STORAGE_PREFIX+e)}function ccAddConversationOption(e){const t=document.getElementById("ccConversationSelect");for(let c=0;c<t.options.length;c++)if(t.options[c].value===e)return;const c=document.createElement("option");c.value=e,c.textContent=e,t.appendChild(c),ccLog('Conversation "'+e+'" added.')}function ccRemoveConversationOption(e){const t=document.getElementById("ccConversationSelect");for(let c=0;c<t.options.length;c++)if(t.options[c].value===e)return void t.remove(c)}function ccRenameBucket(e,t,c){if(!ccCollection.hasOwnProperty(e))return!1;if(e===t)return!1;if(ccCollection.hasOwnProperty(t))return ccLog('Rename skipped: "'+t+'" already exists.'),!1;ccCollection[t]=ccCollection[e],delete ccCollection[e];for(var n=document.getElementById("ccConversationSelect"),o=0;o<n.options.length;o++)if(n.options[o].value===e){n.options[o].value=t,n.options[o].textContent=t;break}return c||(n.value=t),ccRemoveFromStorage(e),delete ccDirtyBuckets[e],ccDirtyBuckets[t]=!0,ccSaveToStorage(),ccActiveConversation===e&&(ccActiveConversation=t),ccLog((c?'Auto-renamed "':'Renamed "')+e+'" to "'+t+'".'),!0}function ccRenameConversation(){const e=document.getElementById("ccConversationSelect").value;if(""!==e){var t=window.prompt("Rename this conversation to:",e);if(null!==t){var c=t.trim();if(""!==c){if(c!==e){if(ccCollection.hasOwnProperty(c))return window.alert('A conversation named "'+c+'" already exists. Pick a different name, or use Merge.'),void ccLog('Rename cancelled: "'+c+'" already exists.');window.confirm('Rename "'+e+'" to "'+c+'"?\n\nCaptured messages are grouped by conversation name. If this meeting is still live, new captions will keep arriving under the original name and create a separate entry. Rename anyway?')&&ccRenameBucket(e,c,!1)}}else ccLog("Rename cancelled: the name cannot be empty.")}}else ccLog("Select a conversation to rename.")}function ccDeleteConversation(){const e=document.getElementById("ccConversationSelect"),t=e.value;""!==t?window.confirm('Delete all captured messages for "'+t+'"?\n\nThis cannot be undone.')&&(delete ccCollection[t],ccRemoveConversationOption(t),e.value="",ccActiveConversation===t&&(ccActiveConversation=""),ccRemoveFromStorage(t),delete ccDirtyBuckets[t],ccShowStorageWarning(!1),ccUpdateStorageMeter(),ccLog('Deleted "'+t+'".')):ccLog("Select a conversation to delete.")}function ccMergeConversation(){const e=document.getElementById("ccConversationSelect"),t=e.value;if(""!==t){var c=Object.keys(ccCollection).filter(function(e){return e!==t});if(0!==c.length){for(var n='Merge "'+t+'" into which conversation?\n\n',o=0;o<c.length;o++)n+=o+1+". "+c[o]+"\n";n+="\nEnter a number:";var r=window.prompt(n,"");if(null!==r){var a=parseInt(r,10);if(a>=1&&a<=c.length){var i=c[a-1],l=t===ccActiveConversation?"\n\nNote: this conversation is still capturing, so new captions will recreate it under the original name.":"";if(window.confirm('Merge all captions from "'+t+'" into "'+i+'"?\n"'+t+'" will then be removed.'+l)){var s=ccCollection[t],d=ccCollection[i];for(var u in s.message)if(s.message.hasOwnProperty(u)){for(var v=u;d.message.hasOwnProperty(v);)v+="+";d.message[v]=s.message[u]}s.time<d.time&&(d.time=s.time),delete ccCollection[t],ccRemoveConversationOption(t),e.value=i,ccActiveConversation===t&&(ccActiveConversation=""),ccRemoveFromStorage(t),delete ccDirtyBuckets[t],ccDirtyBuckets[i]=!0,ccSaveToStorage(),ccUpdateStorageMeter(),ccLog('Merged "'+t+'" into "'+i+'".')}}else ccLog('Merge cancelled: "'+r+'" is not a valid choice.')}}else ccLog("Nothing to merge into - there is only one conversation.")}else ccLog("Select a conversation to merge.")}function ccResolveBucket(e){return ccUserAvatar?e:(ccDesktopBucket||ccLog('Desktop call named "'+(ccDesktopBucket=e+" "+ccFormatTime(Date.now()))+'".'),ccDesktopBucket)}function ccCallStillMinimized(e){for(var t=document.querySelectorAll(CC_SELECTORS.callMonitorTitle),c=0;c<t.length;c++)if(t[c].innerText.trim()===e)return!0;return!1}function ccUpdateStatus(e,t,c){if(ccStatusLineEl){var n="Capturing: "+e+" · "+t+" caption"+(1===t?"":"s");if(ccStatusLineEl.textContent!==n&&(ccStatusLineEl.textContent=n),ccStatusPreviewEl&&c){var o="["+c.time+"] "+c.author+": "+c.text;o.length>160&&(o=o.slice(0,159)+"…"),ccStatusPreviewEl.textContent!==o&&(ccStatusPreviewEl.textContent=o)}}}function ccUpdateStatusIdle(){ccStatusLineEl&&(ccStatusLineEl.textContent="Idle — waiting for captions"),ccStatusPreviewEl&&(ccStatusPreviewEl.textContent="")}function ccCaptureVisible(){var e=ccListEl;if(e&&e.isConnected){var t=e.querySelectorAll(CC_SELECTORS.captionItem);if(0!==t.length){var c=document.title.split("|"),n=(ccUserAvatar?c.slice(1,-1):c.slice(0,-1)).join("|").trim();if(""!==n){var o=document.querySelector(CC_SELECTORS.callDuration);if(o){var r=o.innerText;r.length<=5&&(r="00:"+r);var a=ccResolveBucket(n);a!==ccActiveConversation&&(ccActiveConversation=a,ccActiveCount=ccCollection.hasOwnProperty(a)?Object.keys(ccCollection[a].message).length:0),ccCollection.hasOwnProperty(a)||(ccCollection[a]={time:Date.now(),message:{}},ccAddConversationOption(a));var i=ccCollection[a].message,l=null;for(let e=t.length-1;e>=0;e--){var s=t[e].querySelector(CC_SELECTORS.captionIdHost),d=t[e].querySelector(CC_SELECTORS.author),u=t[e].querySelector(CC_SELECTORS.text),v=s?s.getAttribute(CC_SELECTORS.captionIdAttr):null;if(v&&d&&u){var p=v.split("-").pop(),g=d.innerText,m=u.innerText,C=i[p];C?C.author===g&&C.text===m||(C.author=g,C.text=m,ccDirtyBuckets[a]=!0):(i[p]={author:g,text:m,time:r},ccActiveCount++,ccDirtyBuckets[a]=!0,C=i[p]),e===t.length-1&&(l=C)}else ccWarnedStructure||(ccWarnedStructure=!0,ccLog("Warning: a caption was missing its expected parts and was skipped. Teams may have changed its layout (check CC_SELECTORS at the top of the script)."))}ccUpdateStatus(a,ccActiveCount,l)}else ccWarnedDuration||(ccWarnedDuration=!0,ccLog("Warning: the call timer was not found while captions are visible. Captions can't be timestamped - Teams may have changed its layout (check CC_SELECTORS at the top of the script)."))}}}}function ccObserveList(e){ccObserverList&&ccObserverList.disconnect(),(ccObserverList=new MutationObserver(ccCaptureVisible)).observe(e,{childList:!0,subtree:!0,characterData:!0}),ccCaptureVisible()}function ccHandleCaptionsGone(){var e=ccActiveConversation;clearTimeout(ccEndCheckTimer),ccEndCheckTimer=setTimeout(function(){document.querySelector(CC_SELECTORS.captionList)||ccUserAvatar&&(document.querySelector(CC_SELECTORS.callDuration)||(ccCallStillMinimized(e)?ccLog('Call "'+e+'" minimized - will keep capturing when reopened.'):e&&ccCollection.hasOwnProperty(e)&&ccRenameBucket(e,e+" "+ccFormatTime(ccCollection[e].time),!0)))},1e3)}function ccCreateElement(e,t,...c){var n=document.createElement(e);if(t)for(var o in t)n.setAttribute(o,t[o]);for(var r=0;r<c.length;r++)n.appendChild("string"==typeof c[r]?document.createTextNode(c[r]):c[r]);return n}function ccInjectStyles(){if(!document.getElementById("ccPanelStyle")){var e=document.createElement("style");e.id="ccPanelStyle",e.textContent="#ccPanel{--cc-accent:#5b5fc7;--cc-accent-hover:#4f52b3;--cc-danger:#c4314b;--cc-danger-bg:#fdf3f4;--cc-fg:#242424;--cc-muted:#616161;--cc-bg:#fff;--cc-subtle:#f5f5f5;--cc-hover:#f0f0f0;--cc-line:#e0e0e0;--cc-field-line:#d1d1d1;position:absolute;z-index:2147483000;width:400px;background:var(--cc-bg);color:var(--cc-fg);border:1px solid var(--cc-line);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.16);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.4}html[class*='theme-dark'] #ccPanel{--cc-accent-hover:#7579eb;--cc-danger:#f1707b;--cc-danger-bg:#3e2329;--cc-fg:#f0f0f0;--cc-muted:#a6a6a6;--cc-bg:#292929;--cc-subtle:#1f1f1f;--cc-hover:#3d3d3d;--cc-line:#3d3d3d;--cc-field-line:#666;box-shadow:0 8px 24px rgba(0,0,0,.5)}#ccPanel *{box-sizing:border-box}#ccHeader{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--cc-subtle);border-bottom:1px solid var(--cc-line);font-weight:600;user-select:none;cursor:move;touch-action:none}#ccClose{border:none;background:none;font-size:16px;line-height:1;color:var(--cc-muted);cursor:pointer;padding:2px 6px;border-radius:4px}#ccClose:hover{background:var(--cc-hover);color:var(--cc-fg)}#ccBody{display:flex;flex-direction:column;gap:12px;padding:12px}.cc-field{display:flex;flex-direction:column;gap:6px}.cc-label{font-size:11px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:var(--cc-muted)}.cc-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}.cc-grow{flex:1 1 0%;min-width:0}.cc-status{display:flex;flex-direction:column;gap:2px;min-width:0}#ccStatusLine{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#ccStatusPreview{font-size:11px;color:var(--cc-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:15px}.cc-select{width:100%;padding:6px 8px;border:1px solid var(--cc-field-line);border-radius:4px;background:var(--cc-bg);color:var(--cc-fg);font:inherit}.cc-select:focus-visible,.cc-btn:focus-visible{outline:2px solid var(--cc-accent);outline-offset:1px}.cc-btn{padding:6px 10px;border:1px solid var(--cc-field-line);border-radius:4px;background:var(--cc-bg);color:var(--cc-fg);font:inherit;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s,color .12s}.cc-btn:hover{background:var(--cc-hover)}.cc-btn-primary{background:var(--cc-accent);border-color:var(--cc-accent);color:#fff}.cc-btn-primary:hover{background:var(--cc-accent-hover);border-color:var(--cc-accent-hover)}.cc-btn-danger:hover{border-color:var(--cc-danger);color:var(--cc-danger);background:var(--cc-danger-bg)}.cc-check{display:flex;align-items:center;gap:5px;font-size:12px;white-space:nowrap;cursor:pointer;user-select:none}.cc-check input{margin:0;accent-color:var(--cc-accent)}#ccLog{width:100%;height:84px;resize:vertical;padding:6px 8px;border:1px solid var(--cc-line);border-radius:4px;background:var(--cc-subtle);color:var(--cc-muted);font-family:Consolas,'Courier New',monospace;font-size:11px;line-height:1.45}#ccStorageBar{height:6px;background:var(--cc-hover);border-radius:3px;overflow:hidden}#ccStorageFill{height:100%;width:0;background:var(--cc-accent);transition:width .2s,background .2s}#ccStorageWarn{display:none;margin-top:2px;font-size:11px;font-weight:600;color:var(--cc-danger)}",document.head.appendChild(e)}}function ccMakeDraggable(e,t){t.addEventListener("pointerdown",function(c){if("ccClose"!==c.target.id){var n=c.clientX-e.offsetLeft,o=c.clientY-e.offsetTop,r=function(t){var c=Math.min(Math.max(0,t.clientX-n),document.documentElement.clientWidth-60),r=Math.min(Math.max(0,t.clientY-o),document.documentElement.clientHeight-30);e.style.left=c+"px",e.style.top=r+"px",ccPanelMoved=!0},a=function(e){t.releasePointerCapture(e.pointerId),t.removeEventListener("pointermove",r),t.removeEventListener("pointerup",a)};t.setPointerCapture(c.pointerId),t.addEventListener("pointermove",r),t.addEventListener("pointerup",a)}})}function ccCreatePanel(){ccInjectStyles();var e=document.getElementById("ccPanel");if(e||((e=document.body.appendChild(ccCreateElement("div",{id:"ccPanel"},ccCreateElement("div",{id:"ccHeader"},ccCreateElement("span",null,"Caption Capture"),ccCreateElement("button",{type:"button",id:"ccClose",title:"Close"},"×")),ccCreateElement("div",{id:"ccBody"},ccCreateElement("div",{class:"cc-status"},ccCreateElement("div",{id:"ccStatusLine"},"Idle — waiting for captions"),ccCreateElement("div",{id:"ccStatusPreview"})),ccCreateElement("div",{class:"cc-field"},ccCreateElement("label",{class:"cc-label",for:"ccConversationSelect"},"Conversation"),ccCreateElement("select",{id:"ccConversationSelect",class:"cc-select"},ccCreateElement("option",{value:""},"Select a conversation"))),ccCreateElement("div",{class:"cc-row"},ccCreateElement("button",{type:"button",id:"ccRename",class:"cc-btn"},"Rename"),ccCreateElement("button",{type:"button",id:"ccDelete",class:"cc-btn cc-btn-danger"},"Delete"),ccCreateElement("button",{type:"button",id:"ccMerge",class:"cc-btn",title:"Combine this conversation's captions into another one"},"Merge")),ccCreateElement("div",{class:"cc-row"},ccCreateElement("select",{id:"ccFormatSelect",class:"cc-select cc-grow"},ccCreateElement("option",{value:""},"Export Format"),ccCreateElement("option",{value:"csv"},"CSV"),ccCreateElement("option",{value:"tsv"},"TSV"),ccCreateElement("option",{value:"txt"},"TXT"),ccCreateElement("option",{value:"srt"},"SRT"),ccCreateElement("option",{value:"vtt"},"VTT")),ccCreateElement("label",{class:"cc-check",title:"Replace speaker names with Speaker 1, 2, ... in downloads and copies"},ccCreateElement("input",{type:"checkbox",id:"ccAnonymize"}),"Anonymize"),ccCreateElement("button",{type:"button",id:"ccDownload",class:"cc-btn cc-btn-primary"},"Download"),ccCreateElement("button",{type:"button",id:"ccCopy",class:"cc-btn",title:"Copy the transcript to the clipboard"},"Copy")),ccCreateElement("div",{class:"cc-field"},ccCreateElement("label",{class:"cc-label",for:"ccLog"},"Log"),ccCreateElement("textarea",{id:"ccLog",readonly:"readonly",wrap:"off"})),ccCreateElement("div",{class:"cc-field"},ccCreateElement("div",{id:"ccStorageText",class:"cc-label"},"Storage"),ccCreateElement("div",{id:"ccStorageBar"},ccCreateElement("div",{id:"ccStorageFill"})),ccCreateElement("div",{id:"ccStorageWarn"},"Storage full — delete a conversation to free space."),ccCreateElement("label",{class:"cc-check",title:"If saving fails because storage is full, download a CSV backup of the live conversation (at most once per 5 minutes)"},ccCreateElement("input",{type:"checkbox",id:"ccAutoExport",checked:"checked"}),"Auto-export when storage is full")))))).querySelector("#ccDownload").addEventListener("click",function(){ccExportConversation(document.getElementById("ccConversationSelect").value,document.getElementById("ccFormatSelect").value,document.getElementById("ccAnonymize").checked)}),e.querySelector("#ccCopy").addEventListener("click",ccCopyConversation),e.querySelector("#ccRename").addEventListener("click",ccRenameConversation),e.querySelector("#ccDelete").addEventListener("click",ccDeleteConversation),e.querySelector("#ccMerge").addEventListener("click",ccMergeConversation),e.querySelector("#ccClose").addEventListener("click",function(){document.getElementById("ccPanel").style.visibility="hidden"}),ccMakeDraggable(e,e.querySelector("#ccHeader")),ccStatusLineEl=document.getElementById("ccStatusLine"),ccStatusPreviewEl=document.getElementById("ccStatusPreview")),!ccPanelMoved)if(ccUserAvatar){var t=ccUserAvatar.getBoundingClientRect();e.style.left=t.left-e.offsetWidth+"px",e.style.top=t.top+"px"}else{e.style.left=document.documentElement.clientWidth-e.offsetWidth-12+"px",e.style.top="12px"}e.style.visibility="visible"}function ccLog(e){var t=new Date,c="["+(t.getFullYear()+"-"+String(t.getMonth()+1).padStart(2,"0")+"-"+String(t.getDate()).padStart(2,"0")+" "+String(t.getHours()).padStart(2,"0")+":"+String(t.getMinutes()).padStart(2,"0")+":"+String(t.getSeconds()).padStart(2,"0"))+"] "+e;console.log("cc - "+c);var n=document.getElementById("ccLog");if(n){var o=(n.value+c+"\r\n").split("\r\n");o.length>CC_LOG_MAX_LINES+1&&(o=o.slice(o.length-(CC_LOG_MAX_LINES+1))),n.value=o.join("\r\n"),n.scrollTop=n.scrollHeight}}function ccBootstrap(){ccUserAvatar=document.querySelector(CC_SELECTORS.userAvatar),ccCreatePanel(),null==ccObserverDocument&&(ccLog("Waiting for closed captions..."),ccCollection={},ccIsVisible=!1,ccActiveConversation="",ccActiveCount=0,ccDesktopBucket=null,ccEndCheckTimer=null,ccObserverList=null,ccListEl=null,ccSearchLast=0,ccDirtyBuckets={},ccWarnedDuration=!1,ccWarnedStructure=!1,ccQuotaFailing=!1,ccLastAutoExport=0,ccMeterTick=0,ccPanelMoved=!1,(ccObserverDocument=new MutationObserver(function(){if(ccObserverList){if(ccListEl&&ccListEl.isConnected)return;return ccObserverList.disconnect(),ccObserverList=null,ccListEl=null,ccSaveToStorage(),ccHandleCaptionsGone(),ccUpdateStatusIdle(),void(ccIsVisible&&(ccLog("Closed captions are no longer visible."),ccIsVisible=!1))}var e=Date.now();if(!(e-ccSearchLast<250)){ccSearchLast=e;var t=document.querySelector(CC_SELECTORS.captionList);t&&(ccListEl=t,clearTimeout(ccEndCheckTimer),ccObserveList(t),ccIsVisible||(ccLog("Closed captions are visible."),ccIsVisible=!0))}})).observe(document.body,{childList:!0,subtree:!0}),document.addEventListener("keydown",function(e){if(e.ctrlKey&&e.shiftKey&&("Digit0"===e.code||"Numpad0"===e.code)){var t=document.getElementById("ccPanel");t&&(t.style.visibility="hidden"===t.style.visibility?"visible":"hidden"),e.preventDefault()}}),setInterval(function(){ccSaveToStorage(),++ccMeterTick>=5&&(ccMeterTick=0,ccUpdateStorageMeter())},3e3)),ccLoadFromStorage(),ccUpdateStorageMeter()}!function(){var e=Date.now();!function t(){!(!window.chrome||!window.chrome.webview)||document.querySelector(CC_SELECTORS.userAvatar)||Date.now()-e>8e3?ccBootstrap():setTimeout(t,200)}()}();
Click to expand full code
// ==UserScript==
// @name         Teams Live Caption Capture
// @namespace    teams-cc-capture
// @version      1.1.0
// @author       nviet
// @license      MIT
// @website      https://github.com/nviet
// @description  Capture live Teams closed captions and export as CSV/TSV/TXT/SRT/VTT
// @match        https://teams.microsoft.com/*
// @run-at       document-idle
// @noframes
// @grant        none
// ==/UserScript==

/*
 * Microsoft Teams - Live Closed-Caption Capture
 * ----------------------------------------------
 * Licensed under the MIT License.
 *
 * Teams' web client renders live captions into a *virtualized* list: only the
 * captions currently on screen exist in the DOM, and older ones are recycled
 * away as new ones arrive. This script watches that list with a MutationObserver
 * and copies each caption into an in-memory store the instant it appears, before
 * Teams can recycle it. Captions can then be exported as CSV / TSV / TXT / SRT /
 * VTT, copied to the clipboard, merged, renamed, anonymized, or deleted.
 *
 * Key concepts
 *  - ccCollection : the in-memory store. Captions are grouped ("bucketed") by
 *                   conversation name. Each caption is keyed by a stable per-caption
 *                   id so revisions overwrite rather than duplicate (Teams updates
 *                   a caption's text in place as the speech is decoded).
 *  - Bucket names : on WEB the bucket key is the plain conversation name from the
 *                   page title; when a call ends it is stamped with its start time
 *                   so the next same-named call starts fresh. On DESKTOP each call
 *                   has its own window but all windows share localStorage, so the
 *                   name is stamped from the very first caption instead (no end-of-
 *                   call hook exists there).
 *  - Minimized    : a web call that is minimized / put on hold survives as a small
 *                   monitor modal. We detect that so we don't mistake a minimize
 *                   for a call end (which would wrongly stamp + split the bucket).
 *  - Persistence  : changes are mirrored to localStorage continuously, so a call
 *                   window that closes abruptly loses nothing - re-run the script
 *                   in any Teams window to recover it. Each conversation lives
 *                   under its own storage key and only changed ones are written,
 *                   so a save costs what changed - not everything ever kept.
 *  - Two observers: an OUTER one on <body> that detects the caption list being
 *                   mounted / unmounted, and an INNER one on the list itself that
 *                   fires on every caption change. The outer observer runs on every
 *                   Teams mutation all day long, so its hot path is a single cached
 *                   connectivity check, and the document-wide search only happens
 *                   (throttled) while no list is attached.
 *
 * Note: the UI is built with document.createElement (no innerHTML) on purpose -
 * Teams enforces Trusted Types, which blocks innerHTML assignment.
 */


/* ===========================================================================
 * Tunables
 * ======================================================================== */

// Every part of Teams' page this script touches, gathered in one place. These
// are Teams' internal markers, not a public API - they are what breaks when
// Microsoft redesigns the page, and the only thing that should need repairing
// when that happens. The "Teams may have changed its layout" log warnings
// point here. (The panel's own ids - ccPanel, ccLog, ... - are ours, stable,
// and deliberately not in this table.)
var CC_SELECTORS = {
	// The virtualized list that live captions are rendered into.
	captionList: "[data-tid='closed-caption-v2-virtual-list-content']",
	// One caption row inside that list.
	captionItem: ".fui-ChatMessageCompact",
	// The element in a row that carries the stable caption id...
	captionIdHost: "[data-tid='closed-captions-v2-items-renderer']",
	// ...in this attribute; the segment after the last "-" is the id.
	captionIdAttr: "data-lpc-hover-target-id",
	// Speaker name and spoken text inside a caption row.
	author: "[data-tid='author']",
	text: "[data-tid='closed-caption-text']",
	// The call timer; doubles as the "call is in the foreground" signal.
	callDuration: "[data-tid='call-duration']",
	// The account avatar: present on web, absent in the desktop app. The
	// platform signal, and the panel's position anchor on web.
	userAvatar: "[data-tid='me-control-avatar-trigger']",
	// The small monitor window shown for a minimized / on-hold call (web).
	callMonitorTitle: "[data-tid='call-monitor-title-style-container']"
};

// Each conversation is stored under its own localStorage key: this prefix plus
// the conversation name. Per-bucket keys mean a save only touches what changed
// (cost no longer scales with everything ever kept), delete and rename become
// single-key operations, and two capturing windows never contend for one key.
// Namespaced to avoid colliding with Teams' own keys (this runs on Teams' origin).
var CC_STORAGE_PREFIX = "ccCaptureStore:";

// Assumed per-origin quota, used only to turn raw usage into a percentage. This
// is approximate: real limits vary by browser and are counted in UTF-16 units.
var CC_STORAGE_CAP = 5 * 1024 * 1024;

// While storage stays full, an automatic backup download of the live
// conversation re-fires at most this often. (It also re-arms immediately the
// moment a save succeeds again.) No UI knob on purpose - edit here if needed.
var CC_AUTOEXPORT_COOLDOWN = 5 * 60 * 1000;

// The on-screen log keeps at most this many lines (oldest trimmed first).
var CC_LOG_MAX_LINES = 200;


/* ===========================================================================
 * Small utilities
 * ======================================================================== */

// Format an epoch (ms) as a filename-safe stamp: "2026-06-09_14-30-05".
// Used to make a call's bucket name unique (the name doubles as the filename).
function ccFormatTime(epochMs)
{
	var d = new Date(epochMs);
	var pad = function(n) { return String(n).padStart(2, "0"); };
	return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
		"_" + pad(d.getHours()) + "-" + pad(d.getMinutes()) + "-" + pad(d.getSeconds());
}


/* ===========================================================================
 * Rendering + export + copy
 * ======================================================================== */

// Build the text of one conversation in the requested format. Returns the full
// string, or null (with a log message) if the inputs aren't usable. Shared by
// Download, Copy, and the automatic storage-full backup.
// `anonymize` replaces each author with "Speaker 1", "Speaker 2", ... in order
// of first appearance, for handing transcripts to third parties.
function ccRenderConversation(conversation, format, anonymize)
{
	// Per-format line/column separator, and the decimal mark used in timestamps.
	var delimiters = {
		"csv": ",",
		"tsv": "\t",
		"txt": " ",
		"srt": "\r\n",
		"vtt": "\r\n"
	};
	var timeFormats = {
		"srt": ",",
		"vtt": "."
	};

	// Guard clauses: nothing selected, an unknown bucket, or no format chosen.
	if (conversation === "" || !ccCollection.hasOwnProperty(conversation))
	{
		ccLog("Select a conversation first.");
		return null;
	}
	if (!delimiters.hasOwnProperty(format))
	{
		ccLog("Choose an export format.");
		return null;
	}

	var messages = Object.entries(ccCollection[conversation].message);

	// Sort by first-seen call time. "HH:MM:SS" strings compare correctly as
	// plain text, so this is cheap - and necessary: insertion order is NOT
	// chronological (the capture loop walks newest-first, and merged buckets
	// interleave two calls). It also future-proofs the output against Teams
	// ever using non-numeric caption ids, which today only *happen* to keep
	// the store ordered because JS sorts integer-like keys ascending.
	messages.sort(function(a, b)
	{
		return (a[1].time < b[1].time) ? -1 : ((a[1].time > b[1].time) ? 1 : 0);
	});

	// Anonymization map, built lazily in speaking order.
	var speakers = {};
	var speakerCount = 0;
	var authorOf = function(message)
	{
		if (!anonymize) return message.author;
		if (!speakers.hasOwnProperty(message.author))
		{
			speakerCount++;
			speakers[message.author] = "Speaker " + speakerCount;
		}
		return speakers[message.author];
	};

	// VTT files must begin with this signature line; the other formats have no header.
	var lines = (format == "vtt") ? ["WEBVTT\r\n"] : [];

	for (let i = 0; i < messages.length; i++)
	{
		const message = messages[i][1];
		const author = authorOf(message);

		// A caption's end time is just the next caption's start time. The final
		// caption has no "next", so we use a sentinel far in the future
		// (this assumes meetings run under 10 hours).
		var timeStart = message.time;
		var timeEnd = (i === (messages.length - 1)) ? "09:59:59" : messages[i + 1][1].time;

		// If two captions share the same whole-second timestamp, give the cue a
		// non-zero length (...999 ms) so subtitle players still render it.
		var timeEndMs = (timeStart == timeEnd) ? "999" : "000";

		if (format == "srt" || format == "vtt")
		{
			timeStart += timeFormats[format] + "000";
			timeEnd += timeFormats[format] + timeEndMs;
		}

		switch (format)
		{
			case "csv":
			case "tsv":
				// Quote author/text and escape embedded quotes as "" (RFC 4180).
				var line = ["\"" + author.replace(/"/g, '""') + "\"", message.time, "\"" + message.text.replace(/"/g, '""') + "\""];
				break;
			case "txt":
				var line = [author, "(" + message.time + "):", message.text];
				break;
			case "srt":
				var line = [(i + 1).toString(), timeStart + " --> " + timeEnd, author + ": " + message.text + "\r\n"];
				break;
			case "vtt":
				var line = [timeStart + " --> " + timeEnd, "<v " + author + ">" + message.text + "\r\n"];
				break;
		}

		lines.push(line.join(delimiters[format]));
	}

	return lines.join("\r\n");
}

// Render one conversation and download it as a file.
function ccExportConversation(conversation, format, anonymize)
{
	var content = ccRenderConversation(conversation, format, anonymize);
	if (content === null) return false;

	// Download via a Blob URL - handles arbitrarily long transcripts, unlike a data: URI.
	var blob = new Blob([content],
	{
		type: "text/plain;charset=utf-8"
	});
	var link = document.createElement("a");
	link.href = URL.createObjectURL(blob);
	link.download = conversation + "." + format;
	document.body.appendChild(link);
	link.click();
	document.body.removeChild(link);
	URL.revokeObjectURL(link.href);
	ccLog("Exported \"" + conversation + "\" as " + format.toUpperCase() + (anonymize ? " (anonymized)." : "."));
}

// Render the selected conversation and put it on the clipboard - the shortest
// path from "meeting happened" to "pasted into an AI chat". If no format is
// chosen, plain text is assumed, since that's what a chat box wants.
function ccCopyConversation()
{
	var conversation = document.getElementById("ccConversationSelect").value;
	var format = document.getElementById("ccFormatSelect").value;
	var anonymize = document.getElementById("ccAnonymize").checked;

	if (format === "")
	{
		format = "txt";
		ccLog("No format chosen - copying as plain text.");
	}

	var content = ccRenderConversation(conversation, format, anonymize);
	if (content === null) return;

	navigator.clipboard.writeText(content).then(function()
	{
		ccLog("Copied \"" + conversation + "\" to the clipboard as " + format.toUpperCase() + (anonymize ? " (anonymized)." : "."));
	}, function()
	{
		ccLog("Could not access the clipboard - use Download instead.");
	});
}


/* ===========================================================================
 * Persistence (localStorage) + usage meter + storage-full backup
 * ======================================================================== */

// Approximate total localStorage usage (this origin), in characters. We sum ALL
// keys, not just ours, because the quota is shared with Teams - that is what
// actually determines whether the next write succeeds.
function ccStorageUsage()
{
	var total = 0;
	for (var i = 0; i < localStorage.length; i++)
	{
		var key = localStorage.key(i);
		total += key.length + (localStorage.getItem(key) || "").length;
	}
	return total;
}

// Refresh the on-screen storage bar + label from current usage. This reads
// every stored value, so it runs on a slow cadence (~15 s) and after events
// that change usage (delete / rename / merge / quota trouble), not on every save.
function ccUpdateStorageMeter()
{
	var fill = document.getElementById("ccStorageFill");
	var text = document.getElementById("ccStorageText");
	if (!fill || !text) return;

	var used = ccStorageUsage();
	var pct = Math.min(100, Math.round(used / CC_STORAGE_CAP * 100));

	fill.style.width = pct + "%";
	fill.style.background = (pct >= 85) ? "var(--cc-danger)" : "var(--cc-accent)";
	text.textContent = "Storage: " + (used / 1048576).toFixed(1) + " MB (" + pct + "%)";
}

// Show / hide the "storage full" warning line under the bar.
function ccShowStorageWarning(show)
{
	var warn = document.getElementById("ccStorageWarn");
	if (warn) warn.style.display = show ? "block" : "none";
}

// If storage is full and the user opted in, download a CSV backup of the LIVE
// conversation - that's the one at risk (everything older is already safely in
// storage). Fires immediately on the first failure, then at most once per
// cooldown while failures continue; a successful save re-arms it instantly.
// CSV is used regardless of the format dropdown because it round-trips
// structure (author / time / text); real names are kept - this is the user's
// own safety copy, not a handout.
function ccMaybeAutoExport()
{
	var box = document.getElementById("ccAutoExport");
	if (!box || !box.checked) return;
	if (Date.now() - ccLastAutoExport < CC_AUTOEXPORT_COOLDOWN) return;

	var name = ccActiveConversation;
	if (!name || !ccCollection.hasOwnProperty(name)) return;

	ccLastAutoExport = Date.now();
	ccLog("Storage is full - auto-exporting \"" + name + "\" as CSV.");
	ccExportConversation(name, "csv", false);
}

// Persist changed conversations to localStorage, each under its own key.
// Only buckets marked dirty are touched, so a save costs what changed - not
// everything ever kept. Each write still MERGES with that bucket's stored copy
// so multiple windows cooperate: captions another window saved are kept, and
// on conflict memory wins (it holds the freshest text - captions revise in
// place). A bucket that fails to write (quota) simply stays dirty and is
// retried on the next tick.
function ccSaveToStorage()
{
	var names = Object.keys(ccDirtyBuckets);
	if (names.length === 0) return;

	var failed = false;

	for (var i = 0; i < names.length; i++)
	{
		var name = names[i];

		// The bucket may have been renamed, merged away, or deleted since it
		// was marked - nothing to write then.
		if (!ccCollection.hasOwnProperty(name))
		{
			delete ccDirtyBuckets[name];
			continue;
		}

		var key = CC_STORAGE_PREFIX + name;
		var payload = ccCollection[name];

		try
		{
			var stored = JSON.parse(localStorage.getItem(key) || "null");
			if (stored && stored.message)
			{
				for (var id in payload.message)
				{
					stored.message[id] = payload.message[id];
				}
				stored.time = Math.min(stored.time, payload.time);
				payload = stored;
			}
		}
		catch (e)
		{
			/* unreadable stored bucket - memory is authoritative, overwrite it */
		}

		try
		{
			localStorage.setItem(key, JSON.stringify(payload));
			delete ccDirtyBuckets[name];
		}
		catch (e)
		{
			failed = true; // almost always the quota; stays dirty, retries next tick
		}
	}

	if (failed)
	{
		// Warn once per episode (this runs every 3 s, so logging each failure
		// would flood the log); captures continue in memory either way, and the
		// auto-export gets them to disk.
		ccShowStorageWarning(true);
		if (!ccQuotaFailing)
		{
			ccQuotaFailing = true;
			ccLog("Could not save: storage is full. Capturing continues in memory; delete a conversation to free space.");
		}
		ccMaybeAutoExport();
		ccUpdateStorageMeter();
	}
	else
	{
		ccShowStorageWarning(false);
		if (ccQuotaFailing)
		{
			// Recovered: re-arm the auto-export so a future full-storage event
			// gets an immediate backup again.
			ccQuotaFailing = false;
			ccLastAutoExport = 0;
			ccLog("Storage is writable again.");
		}
	}
}

// Load saved captures and merge them into memory + the dropdown. Runs on every
// execution, so re-running the script in another window recovers a transcript
// whose meeting window has already closed. Keeps anything already in memory on
// conflict, filling in only what's missing. Each conversation lives under its
// own key, so one unreadable bucket can't block the others from loading.
function ccLoadFromStorage()
{
	for (var i = 0; i < localStorage.length; i++)
	{
		var key = localStorage.key(i);
		if (key.indexOf(CC_STORAGE_PREFIX) !== 0) continue;

		var stored;
		try
		{
			stored = JSON.parse(localStorage.getItem(key) || "null");
		}
		catch (e)
		{
			continue;
		}
		if (!stored || !stored.message) continue;

		var name = key.slice(CC_STORAGE_PREFIX.length);
		if (!ccCollection.hasOwnProperty(name))
		{
			ccCollection[name] = stored;
			ccAddConversationOption(name);
		}
		else
		{
			for (var id in stored.message)
			{
				if (!ccCollection[name].message.hasOwnProperty(id))
				{
					ccCollection[name].message[id] = stored.message[id];
				}
			}
		}
	}
}

// Remove one conversation's stored copy (used by delete / rename / merge so
// localStorage stays in sync and space is actually freed). With per-bucket
// keys this is a single removeItem - no read-modify-write needed.
function ccRemoveFromStorage(name)
{
	localStorage.removeItem(CC_STORAGE_PREFIX + name);
}


/* ===========================================================================
 * Conversation list (the dropdown) + rename / delete / merge
 * ======================================================================== */

// Add an <option> for a conversation, unless one already exists.
function ccAddConversationOption(conversation)
{
	const select = document.getElementById("ccConversationSelect");

	for (let i = 0; i < select.options.length; i++)
	{
		if (select.options[i].value === conversation)
		{
			return;
		}
	}

	const option = document.createElement("option");
	option.value = conversation;
	option.textContent = conversation;
	select.appendChild(option);
	ccLog("Conversation \"" + conversation + "\" added.");
}

// Remove a conversation's <option> from the dropdown, if present.
function ccRemoveConversationOption(conversation)
{
	const select = document.getElementById("ccConversationSelect");
	for (let i = 0; i < select.options.length; i++)
	{
		if (select.options[i].value === conversation)
		{
			select.remove(i);
			return;
		}
	}
}

// Core rename: move a bucket's key in memory, in the dropdown, AND in storage.
// Shared by the manual Rename button and the automatic end-of-call stamping.
// Returns true on success. `isAuto` only changes wording / selection handling.
function ccRenameBucket(oldName, newName, isAuto)
{
	if (!ccCollection.hasOwnProperty(oldName)) return false;
	if (oldName === newName) return false;
	if (ccCollection.hasOwnProperty(newName))
	{
		ccLog("Rename skipped: \"" + newName + "\" already exists.");
		return false;
	}

	// Memory.
	ccCollection[newName] = ccCollection[oldName];
	delete ccCollection[oldName];

	// Dropdown option.
	var select = document.getElementById("ccConversationSelect");
	for (var i = 0; i < select.options.length; i++)
	{
		if (select.options[i].value === oldName)
		{
			select.options[i].value = newName;
			select.options[i].textContent = newName;
			break;
		}
	}
	if (!isAuto) select.value = newName; // keep a manual rename selected

	// Storage: rename the key (drop the old entry, write the new one).
	ccRemoveFromStorage(oldName);
	delete ccDirtyBuckets[oldName];
	ccDirtyBuckets[newName] = true;
	ccSaveToStorage();

	// Keep tracking the active call if it was the one renamed.
	if (ccActiveConversation === oldName) ccActiveConversation = newName;

	ccLog((isAuto ? "Auto-renamed \"" : "Renamed \"") + oldName + "\" to \"" + newName + "\".");
	return true;
}

// Manual rename: validate + warn, then delegate to ccRenameBucket.
function ccRenameConversation()
{
	const select = document.getElementById("ccConversationSelect");
	const oldName = select.value;

	if (oldName === "")
	{
		ccLog("Select a conversation to rename.");
		return;
	}

	// Ask for the new name, pre-filled with the current one.
	var input = window.prompt("Rename this conversation to:", oldName);
	if (input === null) return; // user pressed Cancel
	var newName = input.trim();

	if (newName === "")
	{
		ccLog("Rename cancelled: the name cannot be empty.");
		return;
	}
	if (newName === oldName) return; // nothing changed

	// A name is also a bucket key, so two buckets cannot share one. Refuse rather
	// than silently merge two sets of captions (Merge exists for doing it on purpose).
	if (ccCollection.hasOwnProperty(newName))
	{
		window.alert("A conversation named \"" + newName + "\" already exists. Pick a different name, or use Merge.");
		ccLog("Rename cancelled: \"" + newName + "\" already exists.");
		return;
	}

	// The warning: captures are bucketed by the LIVE page title. Renaming a
	// meeting that is still running means new captions keep arriving under the
	// original title and pile into a fresh bucket - so the rename "splits" it.
	var proceed = window.confirm(
		"Rename \"" + oldName + "\" to \"" + newName + "\"?\n\n" +
		"Captured messages are grouped by conversation name. If this meeting is " +
		"still live, new captions will keep arriving under the original name and " +
		"create a separate entry. Rename anyway?"
	);
	if (!proceed) return;

	ccRenameBucket(oldName, newName, false);
}

// Delete the selected conversation - its bucket, its dropdown option, and its
// persisted copy (so the space is actually freed).
function ccDeleteConversation()
{
	const select = document.getElementById("ccConversationSelect");
	const name = select.value;

	if (name === "")
	{
		ccLog("Select a conversation to delete.");
		return;
	}

	var proceed = window.confirm(
		"Delete all captured messages for \"" + name + "\"?\n\nThis cannot be undone."
	);
	if (!proceed) return;

	delete ccCollection[name];
	ccRemoveConversationOption(name);
	select.value = ""; // fall back to the placeholder
	if (ccActiveConversation === name) ccActiveConversation = "";

	ccRemoveFromStorage(name);
	delete ccDirtyBuckets[name]; // a pending save must not resurrect it
	ccShowStorageWarning(false); // the user just freed space
	ccUpdateStorageMeter();
	ccLog("Deleted \"" + name + "\".");
}

// Merge the selected conversation into another one, then remove it. This is
// the repair tool for splits (caption blips, live meeting renames, stamped
// duplicates of a recurring call). The target is picked by number from a
// prompt - typing a full name would invite typos.
function ccMergeConversation()
{
	const select = document.getElementById("ccConversationSelect");
	const source = select.value;

	if (source === "")
	{
		ccLog("Select a conversation to merge.");
		return;
	}

	var others = Object.keys(ccCollection).filter(function(n) { return n !== source; });
	if (others.length === 0)
	{
		ccLog("Nothing to merge into - there is only one conversation.");
		return;
	}

	var menu = "Merge \"" + source + "\" into which conversation?\n\n";
	for (var i = 0; i < others.length; i++)
	{
		menu += (i + 1) + ". " + others[i] + "\n";
	}
	menu += "\nEnter a number:";

	var pick = window.prompt(menu, "");
	if (pick === null) return;
	var index = parseInt(pick, 10);
	if (!(index >= 1 && index <= others.length))
	{
		ccLog("Merge cancelled: \"" + pick + "\" is not a valid choice.");
		return;
	}
	var target = others[index - 1];

	// Merging the conversation that is still capturing is allowed but warned:
	// the capture loop will recreate it under the original name on the next tick.
	var liveNote = (source === ccActiveConversation) ?
		"\n\nNote: this conversation is still capturing, so new captions will recreate it under the original name." : "";
	var proceed = window.confirm(
		"Merge all captions from \"" + source + "\" into \"" + target + "\"?\n" +
		"\"" + source + "\" will then be removed." + liveNote
	);
	if (!proceed) return;

	var src = ccCollection[source];
	var dst = ccCollection[target];

	// Caption ids are only unique within one call, so two different calls can
	// both have a caption "17". On collision the incoming id gets a suffix
	// instead of overwriting - after a merge the ids' only job is to be unique
	// keys; export order comes from the timestamps, not the ids.
	for (var id in src.message)
	{
		if (!src.message.hasOwnProperty(id)) continue;
		var key = id;
		while (dst.message.hasOwnProperty(key)) key = key + "+";
		dst.message[key] = src.message[id];
	}

	// The merged bucket's start time is the earlier of the two.
	if (src.time < dst.time) dst.time = src.time;

	delete ccCollection[source];
	ccRemoveConversationOption(source);
	select.value = target;
	if (ccActiveConversation === source) ccActiveConversation = "";

	ccRemoveFromStorage(source);
	delete ccDirtyBuckets[source];
	ccDirtyBuckets[target] = true;
	ccSaveToStorage();
	ccUpdateStorageMeter();
	ccLog("Merged \"" + source + "\" into \"" + target + "\".");
}


/* ===========================================================================
 * Capture
 * ======================================================================== */

// Decide the bucket name to store a call's captions under, given the plain name
// parsed from the title. Web and desktop differ deliberately (see the header).
function ccResolveBucket(parsed)
{
	// Web: use the plain title. End-of-call stamping (ccHandleCaptionsGone)
	// makes it unique afterwards, so a later same-named call starts fresh.
	if (ccUserAvatar) return parsed;

	// Desktop: each call has its own window, but all windows share localStorage,
	// so same-named calls would merge. Fix a unique, timestamped name once at the
	// first caption and reuse it for this window's lifetime.
	if (!ccDesktopBucket)
	{
		ccDesktopBucket = parsed + " " + ccFormatTime(Date.now());
		ccLog("Desktop call named \"" + ccDesktopBucket + "\".");
	}
	return ccDesktopBucket;
}

// True if a minimized / on-hold call monitor with this exact title is on screen.
// Lets us tell "call minimized" apart from "call ended" (web only).
function ccCallStillMinimized(name)
{
	var monitors = document.querySelectorAll(CC_SELECTORS.callMonitorTitle);
	for (var i = 0; i < monitors.length; i++)
	{
		if (monitors[i].innerText.trim() === name) return true;
	}
	return false;
}

// Refresh the two status lines: what is being captured (and how many captions),
// and a one-line preview of the newest caption so the user can see at a glance
// that capture is keeping up. textContent is only touched when it changed.
function ccUpdateStatus(conversation, count, newest)
{
	if (!ccStatusLineEl) return;

	var line = "Capturing: " + conversation + " \u00B7 " + count + " caption" + (count === 1 ? "" : "s");
	if (ccStatusLineEl.textContent !== line) ccStatusLineEl.textContent = line;

	if (ccStatusPreviewEl && newest)
	{
		var preview = "[" + newest.time + "] " + newest.author + ": " + newest.text;
		if (preview.length > 160) preview = preview.slice(0, 159) + "\u2026";
		if (ccStatusPreviewEl.textContent !== preview) ccStatusPreviewEl.textContent = preview;
	}
}

// Status when no capture is running. The preview is cleared too: a stale
// caption lingering after the call reads as a stuck capture, and there is no
// reason to keep a meeting's last words on screen indefinitely.
function ccUpdateStatusIdle()
{
	if (ccStatusLineEl) ccStatusLineEl.textContent = "Idle \u2014 waiting for captions";
	if (ccStatusPreviewEl) ccStatusPreviewEl.textContent = "";
}

// Read every caption currently in the list and copy it into ccCollection.
// Runs on each mutation of the caption list (and once when first attached).
// Per-cycle work (title parse, bucket resolution, call-duration lookup) is
// hoisted out of the per-caption loop: it is identical for every caption in a
// cycle, and this is the hottest path in the script.
function ccCaptureVisible()
{
	var list = ccListEl;
	if (!list || !list.isConnected) return;

	var captions = list.querySelectorAll(CC_SELECTORS.captionItem);
	if (captions.length === 0) return;

	// Derive the conversation name from the title. Presence of the avatar means
	// web (drop the leading "Chat" segment too); absence means desktop (keep
	// everything but the trailing app name). Re-joining with "|" keeps a meeting
	// whose own name contains pipes intact.
	//   web     "Chat | Weekly Sync | Microsoft Teams"      -> drop first + last -> "Weekly Sync"
	//   desktop "Meeting with Claude AI | Microsoft Teams"  -> drop last only    -> "Meeting with Claude AI"
	var titleParts = document.title.split("|");
	var parsed = (ccUserAvatar ? titleParts.slice(1, -1) : titleParts.slice(0, -1)).join("|").trim();
	if (parsed === "") return; // no usable name yet -> skip this cycle

	// One timestamp per cycle. If the call timer is missing while captions are
	// on screen, Teams' DOM has changed (or is mid-teardown): say so once - a
	// loud canary beats a silent capture failure - and skip the cycle.
	var durationEl = document.querySelector(CC_SELECTORS.callDuration);
	if (!durationEl)
	{
		if (!ccWarnedDuration)
		{
			ccWarnedDuration = true;
			ccLog("Warning: the call timer was not found while captions are visible. Captions can't be timestamped - Teams may have changed its layout (check CC_SELECTORS at the top of the script).");
		}
		return;
	}
	var time = durationEl.innerText;
	// The timer reads "MM:SS" early in a call; normalise to "HH:MM:SS".
	if (time.length <= 5) time = "00:" + time;

	// Resolve to the actual bucket name (desktop = fixed & timestamped).
	var conversation = ccResolveBucket(parsed);

	// Keep the live caption count incrementally; recount only when the active
	// bucket changes (counting keys on every mutation would not scale to
	// multi-hour transcripts).
	if (conversation !== ccActiveConversation)
	{
		ccActiveConversation = conversation;
		ccActiveCount = ccCollection.hasOwnProperty(conversation) ?
			Object.keys(ccCollection[conversation].message).length : 0;
	}

	// First time we see this conversation: register it and add its dropdown option.
	if (!ccCollection.hasOwnProperty(conversation))
	{
		ccCollection[conversation] = {
			"time": Date.now(),
			"message":
			{}
		};
		ccAddConversationOption(conversation);
	}

	var bucket = ccCollection[conversation].message;
	var newest = null;

	// Walk newest-to-oldest so a fresh store keeps a sensible insertion order.
	for (let i = captions.length - 1; i >= 0; i--)
	{
		var renderer = captions[i].querySelector(CC_SELECTORS.captionIdHost);
		var authorEl = captions[i].querySelector(CC_SELECTORS.author);
		var textEl = captions[i].querySelector(CC_SELECTORS.text);
		var hoverId = renderer ? renderer.getAttribute(CC_SELECTORS.captionIdAttr) : null;

		// Structure canary: a caption without its expected parts means Teams
		// changed the markup. Warn once, skip the caption, keep going.
		if (!hoverId || !authorEl || !textEl)
		{
			if (!ccWarnedStructure)
			{
				ccWarnedStructure = true;
				ccLog("Warning: a caption was missing its expected parts and was skipped. Teams may have changed its layout (check CC_SELECTORS at the top of the script).");
			}
			continue;
		}

		// Stable per-caption id - what lets a revised caption overwrite its
		// earlier version instead of being stored as a brand-new line.
		var captionId = hoverId.split("-").pop();
		var author = authorEl.innerText;
		var text = textEl.innerText;
		var entry = bucket[captionId];

		if (entry)
		{
			// Known caption: keep its first-seen timestamp; update text/author
			// only if they actually changed (and only then mark the store dirty).
			if (entry.author !== author || entry.text !== text)
			{
				entry.author = author;
				entry.text = text;
				ccDirtyBuckets[conversation] = true;
			}
		}
		else
		{
			bucket[captionId] = {
				"author": author,
				"text": text,
				"time": time
			};
			ccActiveCount++;
			ccDirtyBuckets[conversation] = true;
			entry = bucket[captionId];
		}

		// The DOM list is oldest-to-newest, so the last element is the newest.
		if (i === captions.length - 1) newest = entry;
	}

	ccUpdateStatus(conversation, ccActiveCount, newest);
}

// Attach the INNER observer to the caption list so every change is captured.
// characterData is included as insurance: today Teams revises captions by
// replacing nodes (childList), but a switch to in-place text mutation would
// otherwise go unseen.
function ccObserveList(ccList)
{
	if (ccObserverList) ccObserverList.disconnect(); // safety: drop any previous one

	ccObserverList = new MutationObserver(ccCaptureVisible);
	ccObserverList.observe(ccList,
	{
		childList: true,
		subtree: true,
		characterData: true
	});

	// Capture once immediately for whatever is already on screen.
	ccCaptureVisible();
}

// Called when the caption list unmounts. The list disappears for several
// reasons, so we wait a beat (the monitor doesn't mount in the same tick) and
// then classify what happened to the call we were capturing:
//   - desktop            -> name is already timestamped; nothing to do
//   - captions came back -> it was a blip; nothing to do
//   - call still in foreground (call-duration present) -> captions toggled off
//   - minimized / held (monitor present)               -> still ongoing
//   - none of the above -> the call ended; stamp the bucket with its start time
//                          so a future same-named call starts fresh
function ccHandleCaptionsGone()
{
	var name = ccActiveConversation; // capture now; it may change later
	clearTimeout(ccEndCheckTimer);
	ccEndCheckTimer = setTimeout(function()
	{
		if (document.querySelector(CC_SELECTORS.captionList)) return; // came back
		if (!ccUserAvatar) return; // desktop: already stamped at start
		if (document.querySelector(CC_SELECTORS.callDuration)) return; // captions just toggled off
		if (ccCallStillMinimized(name)) // minimized / on hold
		{
			ccLog("Call \"" + name + "\" minimized - will keep capturing when reopened.");
			return;
		}
		if (name && ccCollection.hasOwnProperty(name))
		{
			ccRenameBucket(name, name + " " + ccFormatTime(ccCollection[name].time), true);
		}
	}, 1000);
}


/* ===========================================================================
 * UI panel
 * ======================================================================== */

// Tiny builder: ccCreateElement("button", {id: "x"}, "Label", childNode, ...).
// Strings become text nodes; anything else is appended as-is. Nesting calls
// lets the markup mirror the DOM tree visually.
function ccCreateElement(tag, attrs, ...children)
{
	var node = document.createElement(tag);
	if (attrs)
		for (var key in attrs) node.setAttribute(key, attrs[key]);
	for (var i = 0; i < children.length; i++)
	{
		node.appendChild(typeof children[i] === "string" ? document.createTextNode(children[i]) : children[i]);
	}
	return node;
}

// Inject the panel's stylesheet once. Kept out of the markup so the element
// code stays readable. A <style> element's text content is not a Trusted Types
// sink, so this is allowed under Teams' CSP. If it were ever blocked the panel
// would still work, just unstyled.
function ccInjectStyles()
{
	if (document.getElementById("ccPanelStyle")) return;

	var css =
		/* Palette. Vars are scoped to the panel so nothing leaks into Teams.
		   Every color goes through a variable, so dark mode (below) is a pure
		   palette swap - same rules, new values. */
		"#ccPanel{" +
		"--cc-accent:#5b5fc7;--cc-accent-hover:#4f52b3;--cc-danger:#c4314b;--cc-danger-bg:#fdf3f4;" +
		"--cc-fg:#242424;--cc-muted:#616161;--cc-bg:#fff;--cc-subtle:#f5f5f5;--cc-hover:#f0f0f0;" +
		"--cc-line:#e0e0e0;--cc-field-line:#d1d1d1;" +
		"position:absolute;z-index:2147483000;width:400px;background:var(--cc-bg);color:var(--cc-fg);" +
		"border:1px solid var(--cc-line);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.16);" +
		"font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.4}" +
		/* Dark mode. Teams tags <html> with a theme-dark* class; matching it in
		   the selector makes the switch automatic and live - no JS detection. */
		"html[class*='theme-dark'] #ccPanel{" +
		"--cc-accent-hover:#7579eb;--cc-danger:#f1707b;--cc-danger-bg:#3e2329;" +
		"--cc-fg:#f0f0f0;--cc-muted:#a6a6a6;--cc-bg:#292929;--cc-subtle:#1f1f1f;--cc-hover:#3d3d3d;" +
		"--cc-line:#3d3d3d;--cc-field-line:#666;" +
		"box-shadow:0 8px 24px rgba(0,0,0,.5)}" +
		"#ccPanel *{box-sizing:border-box}" +
		/* Header / title bar - also the drag handle. */
		"#ccHeader{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;" +
		"background:var(--cc-subtle);border-bottom:1px solid var(--cc-line);font-weight:600;user-select:none;" +
		"cursor:move;touch-action:none}" +
		"#ccClose{border:none;background:none;font-size:16px;line-height:1;color:var(--cc-muted);" +
		"cursor:pointer;padding:2px 6px;border-radius:4px}" +
		"#ccClose:hover{background:var(--cc-hover);color:var(--cc-fg)}" +
		/* Body + fields. */
		"#ccBody{display:flex;flex-direction:column;gap:12px;padding:12px}" +
		".cc-field{display:flex;flex-direction:column;gap:6px}" +
		".cc-label{font-size:11px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:var(--cc-muted)}" +
		".cc-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}" +
		".cc-grow{flex:1 1 0%;min-width:0}" +
		/* Status lines. */
		".cc-status{display:flex;flex-direction:column;gap:2px;min-width:0}" +
		"#ccStatusLine{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}" +
		"#ccStatusPreview{font-size:11px;color:var(--cc-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:15px}" +
		/* Controls. */
		".cc-select{width:100%;padding:6px 8px;border:1px solid var(--cc-field-line);border-radius:4px;" +
		"background:var(--cc-bg);color:var(--cc-fg);font:inherit}" +
		".cc-select:focus-visible,.cc-btn:focus-visible{outline:2px solid var(--cc-accent);outline-offset:1px}" +
		".cc-btn{padding:6px 10px;border:1px solid var(--cc-field-line);border-radius:4px;background:var(--cc-bg);" +
		"color:var(--cc-fg);font:inherit;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s,color .12s}" +
		".cc-btn:hover{background:var(--cc-hover)}" +
		".cc-btn-primary{background:var(--cc-accent);border-color:var(--cc-accent);color:#fff}" +
		".cc-btn-primary:hover{background:var(--cc-accent-hover);border-color:var(--cc-accent-hover)}" +
		".cc-btn-danger:hover{border-color:var(--cc-danger);color:var(--cc-danger);background:var(--cc-danger-bg)}" +
		/* Checkboxes. */
		".cc-check{display:flex;align-items:center;gap:5px;font-size:12px;white-space:nowrap;cursor:pointer;user-select:none}" +
		".cc-check input{margin:0;accent-color:var(--cc-accent)}" +
		/* Log. */
		"#ccLog{width:100%;height:84px;resize:vertical;padding:6px 8px;border:1px solid var(--cc-line);" +
		"border-radius:4px;background:var(--cc-subtle);color:var(--cc-muted);" +
		"font-family:Consolas,'Courier New',monospace;font-size:11px;line-height:1.45}" +
		/* Storage meter. */
		"#ccStorageBar{height:6px;background:var(--cc-hover);border-radius:3px;overflow:hidden}" +
		"#ccStorageFill{height:100%;width:0;background:var(--cc-accent);transition:width .2s,background .2s}" +
		"#ccStorageWarn{display:none;margin-top:2px;font-size:11px;font-weight:600;color:var(--cc-danger)}";

	var style = document.createElement("style");
	style.id = "ccPanelStyle";
	style.textContent = css;
	document.head.appendChild(style);
}

// Make the panel draggable by its header. Uses pointer events with capture so
// the drag survives fast mouse moves. Once the user has dragged it, re-running
// the script no longer snaps the panel back to its anchored position.
function ccMakeDraggable(panel, handle)
{
	handle.addEventListener("pointerdown", function(e)
	{
		if (e.target.id === "ccClose") return; // the close button is not a handle

		var offsetX = e.clientX - panel.offsetLeft;
		var offsetY = e.clientY - panel.offsetTop;

		var onMove = function(e)
		{
			// Clamp so at least a grabbable sliver always stays on screen.
			var x = Math.min(Math.max(0, e.clientX - offsetX), document.documentElement.clientWidth - 60);
			var y = Math.min(Math.max(0, e.clientY - offsetY), document.documentElement.clientHeight - 30);
			panel.style.left = x + "px";
			panel.style.top = y + "px";
			ccPanelMoved = true;
		};
		var onUp = function(e)
		{
			handle.releasePointerCapture(e.pointerId);
			handle.removeEventListener("pointermove", onMove);
			handle.removeEventListener("pointerup", onUp);
		};

		handle.setPointerCapture(e.pointerId);
		handle.addEventListener("pointermove", onMove);
		handle.addEventListener("pointerup", onUp);
	});
}

// Build (or, on re-run, re-show) the control panel and wire up its buttons.
function ccCreatePanel()
{
	ccInjectStyles();

	var panel = document.getElementById("ccPanel");
	if (!panel)
	{
		panel = document.body.appendChild(
			ccCreateElement("div",
				{
					id: "ccPanel"
				},
				// Header: title + close button. Also the drag handle.
				ccCreateElement("div",
					{
						id: "ccHeader"
					},
					ccCreateElement("span", null, "Caption Capture"),
					ccCreateElement("button",
					{
						type: "button",
						id: "ccClose",
						title: "Close"
					}, "\u00D7")
				),
				ccCreateElement("div",
					{
						id: "ccBody"
					},
					// Status: what's being captured + newest caption preview.
					ccCreateElement("div",
						{
							class: "cc-status"
						},
						ccCreateElement("div",
						{
							id: "ccStatusLine"
						}, "Idle \u2014 waiting for captions"),
						ccCreateElement("div",
						{
							id: "ccStatusPreview"
						})
					),
					// Conversation picker (own row).
					ccCreateElement("div",
						{
							class: "cc-field"
						},
						ccCreateElement("label",
						{
							class: "cc-label",
							for: "ccConversationSelect"
						}, "Conversation"),
						ccCreateElement("select",
							{
								id: "ccConversationSelect",
								class: "cc-select"
							},
							ccCreateElement("option",
							{
								value: ""
							}, "Select a conversation")
						)
					),
					// Manage row: rename / delete / merge.
					ccCreateElement("div",
						{
							class: "cc-row"
						},
						ccCreateElement("button",
						{
							type: "button",
							id: "ccRename",
							class: "cc-btn"
						}, "Rename"),
						ccCreateElement("button",
						{
							type: "button",
							id: "ccDelete",
							class: "cc-btn cc-btn-danger"
						}, "Delete"),
						ccCreateElement("button",
						{
							type: "button",
							id: "ccMerge",
							class: "cc-btn",
							title: "Combine this conversation's captions into another one"
						}, "Merge")
					),
					// Export row: format / anonymize / download / copy.
					ccCreateElement("div",
						{
							class: "cc-row"
						},
						ccCreateElement("select",
							{
								id: "ccFormatSelect",
								class: "cc-select cc-grow"
							},
							ccCreateElement("option",
							{
								value: ""
							}, "Export Format"),
							ccCreateElement("option",
							{
								value: "csv"
							}, "CSV"),
							ccCreateElement("option",
							{
								value: "tsv"
							}, "TSV"),
							ccCreateElement("option",
							{
								value: "txt"
							}, "TXT"),
							ccCreateElement("option",
							{
								value: "srt"
							}, "SRT"),
							ccCreateElement("option",
							{
								value: "vtt"
							}, "VTT")
						),
						ccCreateElement("label",
							{
								class: "cc-check",
								title: "Replace speaker names with Speaker 1, 2, ... in downloads and copies"
							},
							ccCreateElement("input",
							{
								type: "checkbox",
								id: "ccAnonymize"
							}),
							"Anonymize"
						),
						ccCreateElement("button",
						{
							type: "button",
							id: "ccDownload",
							class: "cc-btn cc-btn-primary"
						}, "Download"),
						ccCreateElement("button",
						{
							type: "button",
							id: "ccCopy",
							class: "cc-btn",
							title: "Copy the transcript to the clipboard"
						}, "Copy")
					),
					// Activity log.
					ccCreateElement("div",
						{
							class: "cc-field"
						},
						ccCreateElement("label",
						{
							class: "cc-label",
							for: "ccLog"
						}, "Log"),
						ccCreateElement("textarea",
						{
							id: "ccLog",
							readonly: "readonly",
							wrap: "off"
						})
					),
					// Storage meter + full-storage warning + auto-export opt-in.
					ccCreateElement("div",
						{
							class: "cc-field"
						},
						ccCreateElement("div",
						{
							id: "ccStorageText",
							class: "cc-label"
						}, "Storage"),
						ccCreateElement("div",
							{
								id: "ccStorageBar"
							},
							ccCreateElement("div",
							{
								id: "ccStorageFill"
							})
						),
						ccCreateElement("div",
						{
							id: "ccStorageWarn"
						}, "Storage full \u2014 delete a conversation to free space."),
						ccCreateElement("label",
							{
								class: "cc-check",
								title: "If saving fails because storage is full, download a CSV backup of the live conversation (at most once per 5 minutes)"
							},
							ccCreateElement("input",
							{
								type: "checkbox",
								id: "ccAutoExport",
								checked: "checked"
							}),
							"Auto-export when storage is full"
						)
					)
				)
			)
		);

		// Wire up the buttons.
		panel.querySelector("#ccDownload").addEventListener("click", function()
		{
			ccExportConversation(
				document.getElementById("ccConversationSelect").value,
				document.getElementById("ccFormatSelect").value,
				document.getElementById("ccAnonymize").checked
			);
		});
		panel.querySelector("#ccCopy").addEventListener("click", ccCopyConversation);
		panel.querySelector("#ccRename").addEventListener("click", ccRenameConversation);
		panel.querySelector("#ccDelete").addEventListener("click", ccDeleteConversation);
		panel.querySelector("#ccMerge").addEventListener("click", ccMergeConversation);
		panel.querySelector("#ccClose").addEventListener("click", function()
		{
			document.getElementById("ccPanel").style.visibility = "hidden";
		});

		ccMakeDraggable(panel, panel.querySelector("#ccHeader"));

		// Cache the per-mutation status targets once (getElementById on every
		// caption cycle would be wasted work).
		ccStatusLineEl = document.getElementById("ccStatusLine");
		ccStatusPreviewEl = document.getElementById("ccStatusPreview");
	}

	// Park the panel - unless the user has dragged it somewhere themselves, in
	// which case their placement wins. On web we anchor it just left of the
	// account avatar; the desktop app has no avatar, so fall back to the
	// window's top-right corner.
	if (!ccPanelMoved)
	{
		if (ccUserAvatar)
		{
			var rect = ccUserAvatar.getBoundingClientRect();
			panel.style.left = rect.left - panel.offsetWidth + "px";
			panel.style.top = rect.top + "px";
		}
		else
		{
			var margin = 12;
			panel.style.left = document.documentElement.clientWidth - panel.offsetWidth - margin + "px";
			panel.style.top = margin + "px";
		}
	}
	panel.style.visibility = "visible";
}


/* ===========================================================================
 * Logging
 * ======================================================================== */

// Append a timestamped line to the on-screen log (and mirror it to the console).
// The textarea keeps only the newest CC_LOG_MAX_LINES lines, and stays scrolled
// to the bottom.
function ccLog(message)
{
	var now = new Date();
	var date = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0") + " " + String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0") + ":" + String(now.getSeconds()).padStart(2, "0");
	var text = "[" + date + "] " + message;

	console.log("cc - " + text);

	var area = document.getElementById("ccLog");
	if (!area) return;

	var lines = (area.value + text + "\r\n").split("\r\n");
	if (lines.length > CC_LOG_MAX_LINES + 1) // +1: the trailing newline makes an empty last element
	{
		lines = lines.slice(lines.length - (CC_LOG_MAX_LINES + 1));
	}
	area.value = lines.join("\r\n");
	area.scrollTop = area.scrollHeight;
}


/* ===========================================================================
 * Bootstrap
 * ======================================================================== */

// The engine's cross-run globals. Declared at top level (not inside the guard
// below) so every function above can see them, and so re-running the script
// doesn't reset them - the re-init guard relies on ccObserverDocument surviving.
var ccUserAvatar, ccCollection, ccIsVisible, ccActiveConversation,
	ccDesktopBucket, ccEndCheckTimer, ccObserverList, ccObserverDocument,
	ccListEl, ccSearchLast, ccDirtyBuckets, ccActiveCount, ccWarnedDuration,
	ccWarnedStructure, ccQuotaFailing, ccLastAutoExport, ccMeterTick,
	ccPanelMoved, ccStatusLineEl, ccStatusPreviewEl;

// Build the panel and (once) start the capture engine. Pulled into a function so
// it can be deferred until the platform is known (see the launcher below).
function ccBootstrap()
{
	// The web client's account-avatar control is the one element desktop Teams (a
	// Chromium shell) doesn't render: its presence is the "web vs desktop" signal
	// and its position anchors the panel on web.
	ccUserAvatar = document.querySelector(CC_SELECTORS.userAvatar);

	ccCreatePanel();

	// Initialise the capture engine only once, even if the script is run again
	// (re-running just re-shows the panel and keeps whatever has been captured).
	if (typeof ccObserverDocument === "undefined" || ccObserverDocument === null)
	{
		ccLog("Waiting for closed captions...");

		ccCollection = {}; // conversation name -> { time, message: { captionId -> caption } }
		ccIsVisible = false; // whether the caption list is currently mounted
		ccActiveConversation = ""; // bucket name currently being captured (for end check)
		ccActiveCount = 0; // live caption count of the active bucket (for the status line)
		ccDesktopBucket = null; // desktop: the fixed, timestamped name for this window
		ccEndCheckTimer = null; // deferred "did the call end?" check
		ccObserverList = null; // INNER observer (the caption list itself)
		ccListEl = null; // cached caption-list element (outer observer's fast path)
		ccSearchLast = 0; // last document-wide search for the list (throttling)
		ccDirtyBuckets = {}; // conversation names with unsaved in-memory changes
		ccWarnedDuration = false; // one-shot canary: call timer missing
		ccWarnedStructure = false; // one-shot canary: caption markup changed
		ccQuotaFailing = false; // are saves currently failing? (log once per episode)
		ccLastAutoExport = 0; // when the last storage-full backup fired
		ccMeterTick = 0; // slow-cadence counter for the storage meter
		ccPanelMoved = false; // has the user dragged the panel? (then stop re-anchoring)

		// OUTER observer: watches the whole document for the caption list being
		// added or removed. It fires on every Teams mutation all day long, so the
		// attached-state path is a single cached connectivity check, and the
		// document-wide search runs only while unattached, at most ~4x/second.
		ccObserverDocument = new MutationObserver(function()
		{
			if (ccObserverList)
			{
				if (ccListEl && ccListEl.isConnected) return; // fast path: still capturing

				// The list unmounted.
				ccObserverList.disconnect();
				ccObserverList = null;
				ccListEl = null;
				ccSaveToStorage(); // final save while the page is still alive (dirty-gated)
				ccHandleCaptionsGone(); // classify: minimized / toggled-off / ended
				ccUpdateStatusIdle();
				if (ccIsVisible)
				{
					ccLog("Closed captions are no longer visible.");
					ccIsVisible = false;
				}
				return;
			}

			// Not attached: look for the list, throttled.
			var now = Date.now();
			if (now - ccSearchLast < 250) return;
			ccSearchLast = now;

			var list = document.querySelector(CC_SELECTORS.captionList);
			if (!list) return;

			ccListEl = list;
			clearTimeout(ccEndCheckTimer); // captions are back -> cancel any pending end check
			ccObserveList(list);
			if (!ccIsVisible)
			{
				ccLog("Closed captions are visible.");
				ccIsVisible = true;
			}
		});

		ccObserverDocument.observe(document.body,
		{
			childList: true,
			subtree: true
		});

		// Toggle the panel with Ctrl+Shift+0 (main row or numpad). Registered
		// inside this once-only guard so re-running never stacks duplicate
		// listeners. A digit is used because Teams claims many Ctrl+Shift+<letter>
		// combos, and Ctrl+Shift+1..9 switch its app bar - zero is free.
		document.addEventListener("keydown", function(e)
		{
			if (e.ctrlKey && e.shiftKey && (e.code === "Digit0" || e.code === "Numpad0"))
			{
				var p = document.getElementById("ccPanel");
				if (p) p.style.visibility = (p.style.visibility === "hidden") ? "visible" : "hidden";
				e.preventDefault();
			}
		});

		// Persistence safety net (desktop call windows close with no warning, and
		// beforeunload doesn't fire there), so we save continuously instead. The
		// dirty flag makes idle ticks free. The ~3 s cadence bounds how much of
		// the very end could be lost. The storage meter refreshes on a slower
		// cadence because measuring it reads every stored value.
		setInterval(function()
		{
			ccSaveToStorage();
			if (++ccMeterTick >= 5)
			{
				ccMeterTick = 0;
				ccUpdateStorageMeter();
			}
		}, 3000);
	}

	// Runs on EVERY execution: pull saved captures into the dropdown, then refresh
	// the storage meter. This is the recovery step - run the script in any Teams
	// window (e.g. the main one, after a call window has closed) to get its
	// transcript back, then export.
	ccLoadFromStorage();
	ccUpdateStorageMeter();
}

// Launch. As a bookmarklet you click this after Teams has loaded, so we can run
// at once. As a userscript it fires during page load, before the avatar mounts -
// and the avatar is the platform signal, so running too early would misread web
// as desktop. Desktop (WebView2) is identified immediately by window.chrome.webview
// and never shows the avatar, so it launches right away; on web we wait for the
// avatar, giving up after ~8 s as a safety valve.
(function()
{
	var start = Date.now();
	(function ccLaunch()
	{
		var isDesktop = !!(window.chrome && window.chrome.webview);
		if (isDesktop || document.querySelector(CC_SELECTORS.userAvatar) || (Date.now() - start) > 8000)
		{
			ccBootstrap();
			return;
		}
		setTimeout(ccLaunch, 200);
	})();
})();

Save Teams meeting captions to a file

I needed to save the transcript of a Teams meeting so I could hand it to an AI and get a summary back.

There are more polished tools out there with more features, but I wanted something simple that:

  • Works on both the web and desktop versions of Teams, right away.
  • Doesn't need to be installed.
  • Runs even in locked-down workplaces, where you often can't install your own browser or add extensions to the one you're given.

While live captions are turned on in a meeting, the script quietly collects them in the background. When you're done, you pick the meeting, choose a file format, and download the transcript.

Before you start

Turn on live captions in the Teams meeting itself. The script can only save the captions that Teams shows on screen - if they aren't showing, there's nothing to collect.

How to use it

On the web version of Teams:

  • Create a bookmarklet (Guide) in your web browser using the script below, then click it during a meeting to start the script.
  • Or install the Tampermonkey extension and add this as a new script, so it starts on its own every time.

On the desktop version of Teams:

Once the script is running, you can hide or show its window any time by pressing Ctrl+Shift+0.

Good to know

  • Keep the Teams call window visible on screen the whole time - even leaving just a sliver of it showing is enough. If it's completely hidden, your browser slows the page down to save resources (that's the browser's own behavior, not a fault in the script), and some captions can be missed.
  • The script saves your captured meetings in the browser's local storage so nothing is lost if a window closes. That space is limited, so delete meetings you no longer need to free it up.

(Thanks to AI, I was finally able to finish something I'd wanted for a long time but never had the time for)

A quick word on privacy

  • Captions are a record of what people said. Treat saving them the way you would treat recording the meeting: depending on your country and your workplace rules, you may need to let people know first. When in doubt, ask.
  • Pasting a transcript into an AI chat sends the meeting's content to that AI company's servers. Don't do that with confidential meetings unless your workplace allows it. The Anonymize option removes people's names from the file, but everything they said is still in there.

Changelog

[1.1] - 2026-06-10

Added

  • Copy button — puts the transcript straight on the clipboard, ready to paste into an AI chat. If no format is chosen, plain text is used.
  • Merge — combine one conversation into another. Useful when a meeting got split into two entries (caption hiccups, renamed meetings, recurring calls).
  • Anonymize option — replaces speaker names with "Speaker 1", "Speaker 2", ... in downloads and copies.
  • Live status — the panel now shows which conversation is being captured, how many captions so far, and a preview of the newest caption, so you can see at a glance that capture is keeping up.
  • Auto-export when storage is full — if saving fails because the browser's storage limit was reached, a CSV backup of the live conversation downloads automatically (on by default, at most once every 5 minutes).
  • Draggable panel — grab the title bar to move it; it stays where you put it.
  • Dark mode — the panel follows Teams' own theme automatically, including when you switch themes mid-meeting.

Changed

  • Each conversation is now saved under its own storage entry. Saving only touches what changed, and deleting a conversation frees its space immediately.
  • Exported rows are now explicitly sorted by time stamp (previously they relied on insertion order).
  • The activity log keeps the latest 200 lines and scrolls automatically.
  • Every Teams page marker the script depends on now lives in one table at the top of the file, so a Teams redesign can be repaired in a single place.

Fixed

  • The caption preview is cleared when capture stops, instead of keeping the last caption on screen.
  • If Teams ever changes its page structure, the script now reports it in the log instead of failing silently.

[1.0] - 2026-06-09

Initial release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment