Script that lets you find the final instances of recurring Zoom meetings
// ==UserScript== // @name Zoom Auditor // @description Script that lets you find the final instances of recurring Zoom meetings // @version 3 // @grant none // @include https://zoom.us/* // @include https://*.zoom.us/* // @namespace https://greasyfork.org/users/22981 // @license https://anticapitalist.software/ // ==/UserScript== /* ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4) Copyright © 2022 Adam Novak This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles. Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions: 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 2. The User is one of the following: a. An individual person, laboring for themselves b. A non-profit organization c. An educational institution d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. 4. If the User is an organization, then the User is not law enforcement or military, or working for or under either. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /// Globally cache CSRF token let _csrf_token /// Get the Zoom CSRF token to make requests. async function get_token() { if (!_csrf_token) { // See https://github.com/pozhiloy-enotik/zoom-gta/blob/1982234a066b2ed06277d68765ed2670f042fae6/gif.py#L15 _csrf_token = await fetch('https://zoom.us/csrf_js', { method: 'POST', headers: { 'FETCH-CSRF-TOKEN': '1' } }).then((response) => { if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`) } return response.text() }).then((text) => { let parts = text.split(':') console.log("Token got: " + parts[1]) return parts[1] }) } return _csrf_token } /// Run the given async function on each page of upcoming Zoom meetings async function for_each_page(csrf_token, callback) { let page = 1 let items_seen = 0 let total_items = undefined while (total_items === undefined || total_items > items_seen) { let query = new URLSearchParams({ 'listType': 'upcoming', 'page': page }) let page_data = await fetch('https://zoom.us/rest/meeting/list', { method: 'POST', headers: { 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'en-US,en;q=0.5', 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest, OWASP CSRFGuard Project', 'ZOOM-CSRFTOKEN': csrf_token }, body: query }).then((response) => { if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`) } return response.json() }) if (!page_data.status) { console.log('Status is ', page_data.status) console.log(page_data) throw new Error(`API error! API says: ${JSON.stringify(page_data)}`) } page += 1 total_items = page_data.r###lt.totalRecords items_seen += (page_data.r###lt.meetings || []).length await callback(page_data) if ((page_data.r###lt.meetings || []).length == 0) { // Got an empty page for some reason. Probably done? break } } } async function get_all_events() { console.log("Getting all events") csrf_token = await get_token() all_events = [] await for_each_page(csrf_token, (page_data) => { console.log("Got event page:", page_data) for (let m of page_data.r###lt.meetings) { for (let o of m.list) { all_events.push(o) } } }) return all_events } /// Given events, get a sorted list of objects for final events, with 'name', 'date', and 'link' fields function audit(events) { last_items = [] for (let event of events) { // Find the last ones if (!event.occurrenceTip) { // Not a repeating event continue } let parsed_tip = event.occurrenceTip.match(/([0-9]+) of ([0-9]+)$/) if (parsed_tip[1] != parsed_tip[2]) { // Not a last one continue } last_items.push(event) } // Sort by ending soonest last_items.sort((a, b) => {return a.occurrence > b.occurrence}) let r###lts = [] for (let e of last_items) { r###lts.push({'name': e.topic, 'date': new Date(e.occurrence), 'link': `https://zoom.us/meeting/${e.number}/edit`}) } return r###lts } /// Make an HTML element describing the given final meetings function make_table(final_meetings) { // Set up so we can know how far in advance things are. let now = new Date() const TWO_MONTHS_MS = 60 * 24 * 60 * 60 * 1000 let root = document.createElement('div') root.innerHTML=` <style content-type="text/css"> table.audit { margin: 0.5em; } table.audit td, table.audit th { border: 1px solid black; padding: 2px; } table.audit th { background-color: black; color: white; } table.audit tr.soon { background-color: lemonchiffon; } table.audit tr.soon td.name::after { content: " ⚠️ Expiring Soon!"; color: red; font-size: 10pt; text-align: right; } table.audit td.link { text-align: center; } </style> <p>The following Zoom meetings will run out of occurrences soon.</p> ` let table = document.createElement('table') table.classList.add('audit') let header = document.createElement('tr') header.innerHTML="<th>Name</th><th>Final Occurrence</th><th>Edit</th>" table.appendChild(header) for (let m of final_meetings) { // Make a row for each final meeting let row = document.createElement('tr') let name_cell = document.createElement('td') name_cell.classList.add('name') name_cell.innerText = m.name row.appendChild(name_cell) let date_cell = document.createElement('td') date_cell.innerText = m.date.toLocaleString('en-us') row.appendChild(date_cell) let ms_in_future = m.date - now if (ms_in_future < TWO_MONTHS_MS) { // Mark this as ending soon! row.classList.add('soon') } // Make sure we have a new-tab edit link let link_cell = document.createElement('td') link_cell.classList.add('link') let link = document.createElement('a') link.innerText = '📝' link.setAttribute('href', m.link) link.setAttribute('target', '_blank') link_cell.appendChild(link) row.appendChild(link_cell) table.appendChild(row) } root.appendChild(table) return root } /// Show the given content in a closeable modal dialog function show_dialog(element) { const DIALOG_ID = "zoom_audit_dialog" // Get rid of any old dialogs from the page. let old_dialog = document.getElementById(DIALOG_ID) if (old_dialog) { old_dialog.remove() } let dialog = document.createElement('dialog') dialog.setAttribute('id', DIALOG_ID) dialog.appendChild(element) // Style the dialog dialog.style.padding = '1em' // Center the dialog dialog.style.position = 'fixed' dialog.style.left = '50%' dialog.style.overflow = 'scroll' dialog.style.transform = 'translateX(-50%)' let form = document.createElement('form') form.setAttribute('method', 'dialog') form.innerHTML="<button>Close</button>" dialog.appendChild(form) let body = (document.getElementsByTagName('body') || [])[0] if (body) { body.appendChild(dialog) dialog.showModal() } } /// Make an HTML element which when pressed launches an audit, for the Zoom navbar function make_audit_button() { let item = document.createElement('li') item.setAttribute('role', 'none') let link = document.createElement('a') link.classList.add('light') link.setAttribute('role', 'menuitem') link.setAttribute('href', 'javascript:;') link.innerText = "🌹 AUDIT" // Put a throbber before the link text let throbber = document.createElement('span') throbber.innerText = '⌛' throbber.style.display = 'none' link.prepend(throbber) let auditing = false link.addEventListener('click', async () => { if (!auditing) { // Only run one flow at a time try { auditing = true throbber.style.display = 'inline' await do_audit() } finally { auditing = false throbber.style.display = 'none' } } }) item.appendChild(link) return item } /// Add an element to the right-side Zoom navbar, from a factory callback function add_to_right_navbar(make_element) { let navbar = document.getElementById('navbar') console.log("Navbar is:", navbar) if (!navbar) { throw new Error('Could not find navbar') } // Zoom now has mobile and non-mobile right navbars let right_navbars = (navbar.getElementsByClassName('navbar-right') || []) console.log("Right navbar(s):", right_navbars) if (!right_navbars) { throw new Error('Could not find right navbar') } for (let right_navbar of right_navbars) { let button = make_element() console.log("Made a button", button) right_navbar.appendChild(button) } } /// Hook our UI into the page function setup() { console.log("Hooking in auditing") add_to_right_navbar(make_audit_button) } /// Do an audit and show the dialog async function do_audit() { // Pre-declare variables so we can paste the rest in the console let all_events let final_meetings let report all_events = await get_all_events() final_meetings = audit(all_events) report = make_table(final_meetings) show_dialog(report) } // On page load, hook in setup()