Last active
June 16, 2026 20:47
-
-
Save bruvv/c25a168271f7bda197b9a0422fdb80aa to your computer and use it in GitHub Desktop.
Soft-delete ChatGPT conversations older than x months via backend API.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name ChatGPT Soft-Delete Chats Older Than x Months | |
| // @namespace https://chatgpt.com/ | |
| // @version 1.3.0 | |
| // @description Preview and soft-delete ChatGPT conversations older than a configurable number of months. | |
| // @match https://chatgpt.com/* | |
| // @match https://chat.openai.com/* | |
| // @homepageURL https://gist.github.com/bruvv/c25a168271f7bda197b9a0422fdb80aa | |
| // @supportURL https://gist.github.com/bruvv/c25a168271f7bda197b9a0422fdb80aa | |
| // @updateURL https://gist.githubusercontent.com/bruvv/c25a168271f7bda197b9a0422fdb80aa/raw/chatgpt-delete-old-chats.user.js | |
| // @downloadURL https://gist.githubusercontent.com/bruvv/c25a168271f7bda197b9a0422fdb80aa/raw/chatgpt-delete-old-chats.user.js | |
| // @run-at document-end | |
| // @grant none | |
| // @noframes | |
| // ==/UserScript== | |
| (function () { | |
| "use strict"; | |
| const DEFAULT_CONFIG = Object.freeze({ | |
| dryRun: true, | |
| useUpdateTime: true, | |
| allowTimestampFallback: false, | |
| includeArchived: false, | |
| timeframeMonths: 5, | |
| pageLimit: 100, | |
| maxPagesPerPass: 1000, | |
| debugDeleteLimit: null, | |
| logDetails: false, | |
| }); | |
| const DELETE_DELAY_MS = 300; | |
| const MAX_RETRIES = 3; | |
| const RETRY_BACKOFF_MS = [500, 1000, 2000]; | |
| const MAX_RETRY_DELAY_MS = 30000; | |
| const UI_CONTAINER_ID = "tm-delete-old-chatgpt-ui"; | |
| const BUTTON_ID = "tm-delete-old-chatgpt-chats-btn"; | |
| const CANCEL_BUTTON_ID = "tm-delete-old-chatgpt-cancel-btn"; | |
| const SETTINGS_PANEL_ID = "tm-delete-old-chatgpt-settings-panel"; | |
| const UI_STYLE_ID = "tm-delete-old-chatgpt-styles"; | |
| const SETTINGS_STORAGE_KEY = "tm_delete_old_chats_settings_v2"; | |
| const LEGACY_SETTINGS_STORAGE_KEY = "tm_delete_old_chats_settings_v1"; | |
| const BOOTSTRAP_FLAG = "__tm_delete_old_chats_bootstrapped_v2__"; | |
| const LOG_PREFIX = "[DeleteOldChats]"; | |
| let settings = loadSettings(); | |
| let isRunning = false; | |
| let activeAbortController = null; | |
| let ensureScheduled = false; | |
| function createAbortError(message = "Operation cancelled.") { | |
| try { | |
| return new DOMException(message, "AbortError"); | |
| } catch (_err) { | |
| const error = new Error(message); | |
| error.name = "AbortError"; | |
| return error; | |
| } | |
| } | |
| function isAbortError(error) { | |
| return Boolean(error && error.name === "AbortError"); | |
| } | |
| function throwIfAborted(signal) { | |
| if (signal && signal.aborted) { | |
| throw createAbortError(); | |
| } | |
| } | |
| function sleep(ms, signal) { | |
| return new Promise((resolve, reject) => { | |
| throwIfAborted(signal); | |
| const timer = setTimeout(() => { | |
| cleanup(); | |
| resolve(); | |
| }, ms); | |
| const onAbort = () => { | |
| clearTimeout(timer); | |
| cleanup(); | |
| reject(createAbortError()); | |
| }; | |
| const cleanup = () => { | |
| if (signal) { | |
| signal.removeEventListener("abort", onAbort); | |
| } | |
| }; | |
| if (signal) { | |
| signal.addEventListener("abort", onAbort, { once: true }); | |
| } | |
| }); | |
| } | |
| function monthLabel(months) { | |
| return `${months} month${months === 1 ? "" : "s"}`; | |
| } | |
| function getButtonIdleText() { | |
| return `Soft-delete chats older than ${monthLabel(settings.timeframeMonths)}`; | |
| } | |
| function normalizeInteger(value, fallback, minValue, maxValue) { | |
| const number = Number(value); | |
| if (!Number.isFinite(number)) { | |
| return fallback; | |
| } | |
| const integer = Math.floor(number); | |
| if (integer < minValue || integer > maxValue) { | |
| return fallback; | |
| } | |
| return integer; | |
| } | |
| function normalizeSettings(input) { | |
| const raw = input && typeof input === "object" ? input : {}; | |
| let debugDeleteLimit = null; | |
| if ( | |
| raw.debugDeleteLimit !== null && | |
| raw.debugDeleteLimit !== undefined && | |
| raw.debugDeleteLimit !== "" | |
| ) { | |
| debugDeleteLimit = normalizeInteger( | |
| raw.debugDeleteLimit, | |
| null, | |
| 1, | |
| 100000, | |
| ); | |
| } | |
| return { | |
| dryRun: | |
| typeof raw.dryRun === "boolean" | |
| ? raw.dryRun | |
| : DEFAULT_CONFIG.dryRun, | |
| useUpdateTime: | |
| typeof raw.useUpdateTime === "boolean" | |
| ? raw.useUpdateTime | |
| : DEFAULT_CONFIG.useUpdateTime, | |
| allowTimestampFallback: | |
| typeof raw.allowTimestampFallback === "boolean" | |
| ? raw.allowTimestampFallback | |
| : DEFAULT_CONFIG.allowTimestampFallback, | |
| includeArchived: | |
| typeof raw.includeArchived === "boolean" | |
| ? raw.includeArchived | |
| : DEFAULT_CONFIG.includeArchived, | |
| timeframeMonths: normalizeInteger( | |
| raw.timeframeMonths, | |
| DEFAULT_CONFIG.timeframeMonths, | |
| 1, | |
| 1200, | |
| ), | |
| pageLimit: normalizeInteger( | |
| raw.pageLimit, | |
| DEFAULT_CONFIG.pageLimit, | |
| 1, | |
| 100, | |
| ), | |
| maxPagesPerPass: normalizeInteger( | |
| raw.maxPagesPerPass, | |
| DEFAULT_CONFIG.maxPagesPerPass, | |
| 1, | |
| 5000, | |
| ), | |
| debugDeleteLimit, | |
| logDetails: | |
| typeof raw.logDetails === "boolean" | |
| ? raw.logDetails | |
| : DEFAULT_CONFIG.logDetails, | |
| }; | |
| } | |
| function loadSettings() { | |
| try { | |
| const current = localStorage.getItem(SETTINGS_STORAGE_KEY); | |
| if (current) { | |
| return normalizeSettings(JSON.parse(current)); | |
| } | |
| const legacy = localStorage.getItem(LEGACY_SETTINGS_STORAGE_KEY); | |
| if (legacy) { | |
| return normalizeSettings(JSON.parse(legacy)); | |
| } | |
| } catch (error) { | |
| console.warn(`${LOG_PREFIX} Could not read saved settings.`, error); | |
| } | |
| return normalizeSettings(DEFAULT_CONFIG); | |
| } | |
| function persistSettings() { | |
| try { | |
| localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); | |
| } catch (error) { | |
| console.warn(`${LOG_PREFIX} Could not save settings.`, error); | |
| } | |
| } | |
| function applySettings(nextSettings) { | |
| settings = normalizeSettings(nextSettings); | |
| persistSettings(); | |
| refreshMainButtonLabel(); | |
| } | |
| function refreshMainButtonLabel() { | |
| const button = document.getElementById(BUTTON_ID); | |
| if (button && !isRunning) { | |
| button.textContent = getButtonIdleText(); | |
| } | |
| } | |
| function subtractCalendarMonths(date, months) { | |
| const result = new Date(date.getTime()); | |
| const originalDay = result.getDate(); | |
| result.setDate(1); | |
| result.setMonth(result.getMonth() - months); | |
| const lastDayOfTargetMonth = new Date( | |
| result.getFullYear(), | |
| result.getMonth() + 1, | |
| 0, | |
| ).getDate(); | |
| result.setDate(Math.min(originalDay, lastDayOfTargetMonth)); | |
| return result; | |
| } | |
| function normalizeUnixSeconds(value) { | |
| if (value === null || value === undefined) { | |
| return null; | |
| } | |
| if (typeof value === "number") { | |
| if (!Number.isFinite(value) || value <= 0) { | |
| return null; | |
| } | |
| return value > 1e12 ? Math.floor(value / 1000) : value; | |
| } | |
| if (typeof value === "string") { | |
| const trimmed = value.trim(); | |
| if (!trimmed) { | |
| return null; | |
| } | |
| if (/^-?\d+(\.\d+)?$/.test(trimmed)) { | |
| const number = Number(trimmed); | |
| if (!Number.isFinite(number) || number <= 0) { | |
| return null; | |
| } | |
| return number > 1e12 ? Math.floor(number / 1000) : number; | |
| } | |
| const parsedMilliseconds = Date.parse(trimmed); | |
| if (!Number.isFinite(parsedMilliseconds) || parsedMilliseconds <= 0) { | |
| return null; | |
| } | |
| return Math.floor(parsedMilliseconds / 1000); | |
| } | |
| return null; | |
| } | |
| function extractTimestamp(conversation, baseField) { | |
| const aliases = | |
| baseField === "update_time" | |
| ? [ | |
| "update_time", | |
| "updated_time", | |
| "updateTime", | |
| "updatedAt", | |
| "updated_at", | |
| "last_update_time", | |
| "lastUpdatedAt", | |
| ] | |
| : [ | |
| "create_time", | |
| "created_time", | |
| "createTime", | |
| "createdAt", | |
| "created_at", | |
| "conversation_create_time", | |
| ]; | |
| const containers = [conversation]; | |
| if ( | |
| conversation && | |
| conversation.conversation && | |
| typeof conversation.conversation === "object" | |
| ) { | |
| containers.push(conversation.conversation); | |
| } | |
| for (const container of containers) { | |
| for (const key of aliases) { | |
| if (!Object.prototype.hasOwnProperty.call(container, key)) { | |
| continue; | |
| } | |
| const timestamp = normalizeUnixSeconds(container[key]); | |
| if (timestamp !== null) { | |
| return { | |
| timestamp, | |
| field: container === conversation ? key : `conversation.${key}`, | |
| }; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function formatLocalDate(timestampSeconds) { | |
| return new Date(timestampSeconds * 1000).toLocaleString(); | |
| } | |
| function getConversationTitle(conversation) { | |
| const directTitle = conversation && conversation.title; | |
| if (typeof directTitle === "string" && directTitle.trim()) { | |
| return directTitle.trim(); | |
| } | |
| const nestedTitle = | |
| conversation && | |
| conversation.conversation && | |
| conversation.conversation.title; | |
| if (typeof nestedTitle === "string" && nestedTitle.trim()) { | |
| return nestedTitle.trim(); | |
| } | |
| return "(untitled)"; | |
| } | |
| function createHttpError(message, status, retryAfterMs = null, details = "") { | |
| const error = new Error(message); | |
| error.status = status; | |
| error.retryAfterMs = retryAfterMs; | |
| error.details = details; | |
| return error; | |
| } | |
| function isRetryableError(error) { | |
| if (error && error.isNetworkError) { | |
| return true; | |
| } | |
| const status = | |
| error && typeof error.status === "number" ? error.status : null; | |
| return status === 429 || (status !== null && status >= 500 && status <= 599); | |
| } | |
| function parseRetryAfterMilliseconds(response) { | |
| const rawValue = response.headers.get("Retry-After"); | |
| if (!rawValue) { | |
| return null; | |
| } | |
| const seconds = Number(rawValue); | |
| if (Number.isFinite(seconds) && seconds >= 0) { | |
| return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS); | |
| } | |
| const dateMilliseconds = Date.parse(rawValue); | |
| if (!Number.isFinite(dateMilliseconds)) { | |
| return null; | |
| } | |
| return Math.min( | |
| Math.max(0, dateMilliseconds - Date.now()), | |
| MAX_RETRY_DELAY_MS, | |
| ); | |
| } | |
| function retryDelayMilliseconds(attempt, retryAfterMs) { | |
| if (Number.isFinite(retryAfterMs) && retryAfterMs >= 0) { | |
| return retryAfterMs; | |
| } | |
| const baseDelay = | |
| RETRY_BACKOFF_MS[attempt] ?? | |
| RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1] ?? | |
| 1000; | |
| const jitter = Math.floor(Math.random() * Math.max(100, baseDelay * 0.25)); | |
| return Math.min(baseDelay + jitter, MAX_RETRY_DELAY_MS); | |
| } | |
| async function responseToHttpError(response, context) { | |
| let details = ""; | |
| try { | |
| details = (await response.text()).trim().slice(0, 500); | |
| } catch (_err) { | |
| details = ""; | |
| } | |
| return createHttpError( | |
| `${context} failed (HTTP ${response.status}).`, | |
| response.status, | |
| parseRetryAfterMilliseconds(response), | |
| details, | |
| ); | |
| } | |
| async function fetchWithRetry(url, options, context) { | |
| const signal = options && options.signal; | |
| let lastError = null; | |
| for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { | |
| throwIfAborted(signal); | |
| try { | |
| const response = await fetch(url, options); | |
| if (response.ok) { | |
| return response; | |
| } | |
| const error = await responseToHttpError(response, context); | |
| lastError = error; | |
| if (!isRetryableError(error) || attempt === MAX_RETRIES) { | |
| throw error; | |
| } | |
| const delay = retryDelayMilliseconds(attempt, error.retryAfterMs); | |
| console.warn( | |
| `${LOG_PREFIX} ${context} returned HTTP ${error.status}. ` + | |
| `Retrying in ${delay} ms (${attempt + 1}/${MAX_RETRIES}).`, | |
| ); | |
| await sleep(delay, signal); | |
| } catch (error) { | |
| if (isAbortError(error)) { | |
| throw error; | |
| } | |
| if (error && typeof error.status === "number") { | |
| throw error; | |
| } | |
| const networkError = new Error(`${context} failed due to a network error.`); | |
| networkError.isNetworkError = true; | |
| networkError.cause = error; | |
| lastError = networkError; | |
| if (attempt === MAX_RETRIES) { | |
| throw networkError; | |
| } | |
| const delay = retryDelayMilliseconds(attempt, null); | |
| console.warn( | |
| `${LOG_PREFIX} ${context} hit a network error. ` + | |
| `Retrying in ${delay} ms (${attempt + 1}/${MAX_RETRIES}).`, | |
| ); | |
| await sleep(delay, signal); | |
| } | |
| } | |
| throw lastError || new Error(`${context} failed.`); | |
| } | |
| async function getAccessToken(signal) { | |
| const response = await fetchWithRetry( | |
| "/api/auth/session", | |
| { | |
| method: "GET", | |
| credentials: "include", | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| signal, | |
| }, | |
| "Fetching the authentication session", | |
| ); | |
| let data; | |
| try { | |
| data = await response.json(); | |
| } catch (_err) { | |
| throw new Error("The authentication session response was not valid JSON."); | |
| } | |
| const token = | |
| data && typeof data.accessToken === "string" ? data.accessToken : ""; | |
| if (!token) { | |
| throw new Error( | |
| "No access token was found. Refresh ChatGPT and make sure you are signed in.", | |
| ); | |
| } | |
| return token; | |
| } | |
| async function authorizedFetch( | |
| url, | |
| options, | |
| authState, | |
| context, | |
| signal, | |
| ) { | |
| let refreshedToken = false; | |
| while (true) { | |
| throwIfAborted(signal); | |
| const headers = new Headers(options.headers || {}); | |
| headers.set("Authorization", `Bearer ${authState.token}`); | |
| try { | |
| return await fetchWithRetry( | |
| url, | |
| { | |
| ...options, | |
| credentials: "include", | |
| headers, | |
| signal, | |
| }, | |
| context, | |
| ); | |
| } catch (error) { | |
| if (error && error.status === 401 && !refreshedToken) { | |
| console.info(`${LOG_PREFIX} Access token expired. Refreshing it once.`); | |
| authState.token = await getAccessToken(signal); | |
| refreshedToken = true; | |
| continue; | |
| } | |
| throw error; | |
| } | |
| } | |
| } | |
| async function fetchConversationsPage( | |
| { offset, limit, isArchived }, | |
| authState, | |
| signal, | |
| ) { | |
| const parameters = new URLSearchParams({ | |
| offset: String(offset), | |
| limit: String(limit), | |
| order: "updated", | |
| }); | |
| if (isArchived) { | |
| parameters.set("is_archived", "true"); | |
| } | |
| const response = await authorizedFetch( | |
| `/backend-api/conversations?${parameters.toString()}`, | |
| { | |
| method: "GET", | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| authState, | |
| isArchived | |
| ? "Fetching archived conversations" | |
| : "Fetching conversations", | |
| signal, | |
| ); | |
| let data; | |
| try { | |
| data = await response.json(); | |
| } catch (_err) { | |
| throw new Error("The conversations response was not valid JSON."); | |
| } | |
| let items = []; | |
| let hasMore = null; | |
| if (Array.isArray(data)) { | |
| items = data; | |
| } else if (data && typeof data === "object") { | |
| if (Array.isArray(data.items)) { | |
| items = data.items; | |
| } else if (Array.isArray(data.conversations)) { | |
| items = data.conversations; | |
| } | |
| if (typeof data.has_more === "boolean") { | |
| hasMore = data.has_more; | |
| } else if (typeof data.hasMore === "boolean") { | |
| hasMore = data.hasMore; | |
| } else if (typeof data.has_missing_conversations === "boolean") { | |
| hasMore = data.has_missing_conversations; | |
| } else if (Number.isFinite(Number(data.total))) { | |
| hasMore = offset + items.length < Number(data.total); | |
| } | |
| } | |
| return { items, hasMore }; | |
| } | |
| async function fetchConversationPass(authState, isArchived, signal) { | |
| const conversations = []; | |
| const seenIds = new Set(); | |
| let offset = 0; | |
| let pagesScanned = 0; | |
| while (true) { | |
| throwIfAborted(signal); | |
| if (pagesScanned >= settings.maxPagesPerPass) { | |
| return { | |
| conversations, | |
| complete: false, | |
| pagesScanned, | |
| reason: `Reached maxPagesPerPass=${settings.maxPagesPerPass}.`, | |
| }; | |
| } | |
| const page = await fetchConversationsPage( | |
| { | |
| offset, | |
| limit: settings.pageLimit, | |
| isArchived, | |
| }, | |
| authState, | |
| signal, | |
| ); | |
| pagesScanned += 1; | |
| const items = Array.isArray(page.items) ? page.items : []; | |
| if (items.length === 0) { | |
| return { | |
| conversations, | |
| complete: true, | |
| pagesScanned, | |
| reason: "Reached an empty page.", | |
| }; | |
| } | |
| let newItemCount = 0; | |
| for (const item of items) { | |
| if (!item || !item.id) { | |
| continue; | |
| } | |
| const id = String(item.id); | |
| if (seenIds.has(id)) { | |
| continue; | |
| } | |
| seenIds.add(id); | |
| conversations.push(item); | |
| newItemCount += 1; | |
| } | |
| if (newItemCount === 0) { | |
| return { | |
| conversations, | |
| complete: false, | |
| pagesScanned, | |
| reason: "Pagination returned no new conversation IDs.", | |
| }; | |
| } | |
| offset += items.length; | |
| if (page.hasMore === false) { | |
| return { | |
| conversations, | |
| complete: true, | |
| pagesScanned, | |
| reason: "The API reported that no further pages exist.", | |
| }; | |
| } | |
| // If hasMore is unknown, continue until an empty page is received. | |
| // This avoids assuming that the server honored the requested page size. | |
| } | |
| } | |
| async function fetchAllConversations(authState, includeArchived, signal) { | |
| const byId = new Map(); | |
| const scanDetails = []; | |
| const standard = await fetchConversationPass(authState, false, signal); | |
| scanDetails.push({ type: "standard", ...standard }); | |
| for (const conversation of standard.conversations) { | |
| if (conversation && conversation.id) { | |
| byId.set(String(conversation.id), conversation); | |
| } | |
| } | |
| let complete = standard.complete; | |
| if (includeArchived) { | |
| try { | |
| const archived = await fetchConversationPass(authState, true, signal); | |
| scanDetails.push({ type: "archived", ...archived }); | |
| complete = complete && archived.complete; | |
| for (const conversation of archived.conversations) { | |
| if (conversation && conversation.id) { | |
| byId.set(String(conversation.id), conversation); | |
| } | |
| } | |
| } catch (error) { | |
| if (isAbortError(error)) { | |
| throw error; | |
| } | |
| complete = false; | |
| scanDetails.push({ | |
| type: "archived", | |
| conversations: [], | |
| complete: false, | |
| pagesScanned: 0, | |
| reason: error && error.message ? error.message : "Unknown error.", | |
| }); | |
| } | |
| } | |
| return { | |
| conversations: Array.from(byId.values()), | |
| complete, | |
| scanDetails, | |
| }; | |
| } | |
| function buildCandidates( | |
| conversations, | |
| cutoffDate, | |
| useUpdateTime, | |
| allowTimestampFallback, | |
| ) { | |
| const cutoffTimestamp = cutoffDate.getTime() / 1000; | |
| const primaryField = useUpdateTime ? "update_time" : "create_time"; | |
| const fallbackField = useUpdateTime ? "create_time" : "update_time"; | |
| const candidates = []; | |
| let skippedMissingTimestamp = 0; | |
| let fallbackUsedCount = 0; | |
| for (const conversation of conversations) { | |
| if (!conversation || !conversation.id) { | |
| continue; | |
| } | |
| const primary = extractTimestamp(conversation, primaryField); | |
| let selected = primary; | |
| if (!selected && allowTimestampFallback) { | |
| selected = extractTimestamp(conversation, fallbackField); | |
| if (selected) { | |
| fallbackUsedCount += 1; | |
| } | |
| } | |
| if (!selected) { | |
| skippedMissingTimestamp += 1; | |
| continue; | |
| } | |
| if (selected.timestamp < cutoffTimestamp) { | |
| candidates.push({ | |
| id: String(conversation.id), | |
| title: getConversationTitle(conversation), | |
| selectedField: selected.field, | |
| selectedTimestamp: selected.timestamp, | |
| selectedDate: formatLocalDate(selected.timestamp), | |
| }); | |
| } | |
| } | |
| candidates.sort( | |
| (left, right) => left.selectedTimestamp - right.selectedTimestamp, | |
| ); | |
| return { | |
| candidates, | |
| skippedMissingTimestamp, | |
| fallbackUsedCount, | |
| }; | |
| } | |
| async function softDeleteConversation(id, authState, signal) { | |
| await authorizedFetch( | |
| `/backend-api/conversation/${encodeURIComponent(id)}`, | |
| { | |
| method: "PATCH", | |
| headers: { | |
| Accept: "application/json", | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ is_visible: false }), | |
| }, | |
| authState, | |
| `Soft-deleting conversation ${id}`, | |
| signal, | |
| ); | |
| } | |
| function setButtonState(button, { disabled, text, title }) { | |
| if (!button) { | |
| return; | |
| } | |
| if (typeof disabled === "boolean") { | |
| button.disabled = disabled; | |
| } | |
| if (typeof text === "string") { | |
| button.textContent = text; | |
| } | |
| if (typeof title === "string") { | |
| button.title = title; | |
| } | |
| } | |
| function setRunningUi(running) { | |
| const button = document.getElementById(BUTTON_ID); | |
| const cancelButton = document.getElementById(CANCEL_BUTTON_ID); | |
| const panel = document.getElementById(SETTINGS_PANEL_ID); | |
| if (button) { | |
| button.disabled = running; | |
| } | |
| if (cancelButton) { | |
| cancelButton.hidden = !running; | |
| cancelButton.disabled = !running; | |
| } | |
| if (panel) { | |
| panel.style.pointerEvents = running ? "none" : "auto"; | |
| panel.style.opacity = running ? "0.65" : "1"; | |
| } | |
| } | |
| function scanFailureSummary(scanDetails) { | |
| return scanDetails | |
| .filter((detail) => !detail.complete) | |
| .map( | |
| (detail) => | |
| `${detail.type}: ${detail.reason || "Incomplete scan."}`, | |
| ) | |
| .join("\n"); | |
| } | |
| async function runCleanup(button) { | |
| if (isRunning) { | |
| return; | |
| } | |
| isRunning = true; | |
| activeAbortController = new AbortController(); | |
| const { signal } = activeAbortController; | |
| setRunningUi(true); | |
| setButtonState(button, { | |
| disabled: true, | |
| text: "Preparing...", | |
| title: "Cleanup is running", | |
| }); | |
| try { | |
| const authState = { | |
| token: await getAccessToken(signal), | |
| }; | |
| setButtonState(button, { text: "Scanning chats..." }); | |
| const scan = await fetchAllConversations( | |
| authState, | |
| settings.includeArchived, | |
| signal, | |
| ); | |
| const cutoff = subtractCalendarMonths( | |
| new Date(), | |
| settings.timeframeMonths, | |
| ); | |
| const result = buildCandidates( | |
| scan.conversations, | |
| cutoff, | |
| settings.useUpdateTime, | |
| settings.allowTimestampFallback, | |
| ); | |
| const alternativeResult = buildCandidates( | |
| scan.conversations, | |
| cutoff, | |
| !settings.useUpdateTime, | |
| false, | |
| ); | |
| const hasDebugLimit = | |
| Number.isInteger(settings.debugDeleteLimit) && | |
| settings.debugDeleteLimit > 0; | |
| const deleteCandidates = hasDebugLimit | |
| ? result.candidates.slice(0, settings.debugDeleteLimit) | |
| : result.candidates; | |
| const modeLabel = settings.useUpdateTime | |
| ? "update_time" | |
| : "create_time"; | |
| console.info( | |
| `${LOG_PREFIX} Scanned ${scan.conversations.length} conversations. ` + | |
| `Found ${result.candidates.length} candidate(s) older than ` + | |
| `${monthLabel(settings.timeframeMonths)} using ${modeLabel}.`, | |
| ); | |
| if (settings.logDetails) { | |
| console.table( | |
| result.candidates.map((candidate) => ({ | |
| id: candidate.id, | |
| title: candidate.title, | |
| date: candidate.selectedDate, | |
| time_field_used: candidate.selectedField, | |
| })), | |
| ); | |
| } | |
| setButtonState(button, { | |
| title: `Preview count: ${result.candidates.length}`, | |
| }); | |
| if (!scan.complete) { | |
| const failures = scanFailureSummary(scan.scanDetails); | |
| alert( | |
| [ | |
| "The conversation scan was incomplete.", | |
| "No deletions were executed.", | |
| "", | |
| failures || "The API did not return a complete result set.", | |
| "", | |
| "Increase Max pages per pass only if you intentionally set it too low.", | |
| ].join("\n"), | |
| ); | |
| return; | |
| } | |
| if ( | |
| result.candidates.length === 0 && | |
| alternativeResult.candidates.length > 0 | |
| ) { | |
| const suggestedField = settings.useUpdateTime | |
| ? "create_time" | |
| : "update_time"; | |
| console.warn( | |
| `${LOG_PREFIX} No candidates matched ${modeLabel}, but ` + | |
| `${alternativeResult.candidates.length} matched ${suggestedField}.`, | |
| ); | |
| } | |
| if (settings.dryRun) { | |
| alert( | |
| [ | |
| `DRY RUN: ${result.candidates.length} chat(s) would be soft-deleted.`, | |
| `Cutoff: before ${cutoff.toLocaleString()}.`, | |
| `Scanned: ${scan.conversations.length} chat(s).`, | |
| `Timestamp field: ${modeLabel}.`, | |
| `Timestamp fallback: ${ | |
| settings.allowTimestampFallback ? "enabled" : "disabled" | |
| }.`, | |
| `Fallback used: ${result.fallbackUsedCount}.`, | |
| `Skipped without a usable timestamp: ${result.skippedMissingTimestamp}.`, | |
| hasDebugLimit | |
| ? `Debug limit: only the oldest ${deleteCandidates.length} candidate(s) would be processed.` | |
| : "Debug limit: disabled.", | |
| "", | |
| "No deletions were executed.", | |
| ].join("\n"), | |
| ); | |
| return; | |
| } | |
| if (deleteCandidates.length === 0) { | |
| alert("No chats matched the cutoff. Nothing was deleted."); | |
| return; | |
| } | |
| const confirmed = confirm( | |
| [ | |
| `Soft-delete ${deleteCandidates.length} conversation(s)?`, | |
| `Cutoff: before ${cutoff.toLocaleString()}.`, | |
| `Scanned: ${scan.conversations.length}.`, | |
| `Candidates before debug limit: ${result.candidates.length}.`, | |
| `Timestamp field: ${modeLabel}.`, | |
| `Timestamp fallback: ${ | |
| settings.allowTimestampFallback ? "enabled" : "disabled" | |
| }.`, | |
| "", | |
| 'This sends PATCH requests with {"is_visible":false} to an internal ChatGPT endpoint.', | |
| "This behavior is not an official or stable API contract.", | |
| "", | |
| "Continue?", | |
| ].join("\n"), | |
| ); | |
| if (!confirmed) { | |
| return; | |
| } | |
| let successCount = 0; | |
| let failureCount = 0; | |
| let stoppedByAuthorizationError = false; | |
| const total = deleteCandidates.length; | |
| for (let index = 0; index < total; index += 1) { | |
| throwIfAborted(signal); | |
| const candidate = deleteCandidates[index]; | |
| setButtonState(button, { | |
| text: `Soft-deleting ${index + 1}/${total}`, | |
| }); | |
| try { | |
| await softDeleteConversation(candidate.id, authState, signal); | |
| successCount += 1; | |
| } catch (error) { | |
| if (isAbortError(error)) { | |
| throw error; | |
| } | |
| failureCount += 1; | |
| const status = | |
| error && typeof error.status === "number" | |
| ? `HTTP ${error.status}` | |
| : "network or unknown error"; | |
| console.warn( | |
| `${LOG_PREFIX} Failed to soft-delete ${candidate.id}: ${status}.`, | |
| ); | |
| if (error && (error.status === 401 || error.status === 403)) { | |
| stoppedByAuthorizationError = true; | |
| break; | |
| } | |
| } | |
| if (index < total - 1) { | |
| await sleep(DELETE_DELAY_MS, signal); | |
| } | |
| } | |
| alert( | |
| [ | |
| "Soft-delete run complete.", | |
| `Succeeded: ${successCount}`, | |
| `Failed: ${failureCount}`, | |
| stoppedByAuthorizationError | |
| ? "Stopped after an authentication or permission error." | |
| : "All selected candidates were processed.", | |
| "Refresh the page to update the visible chat list.", | |
| ].join("\n"), | |
| ); | |
| } catch (error) { | |
| if (isAbortError(error)) { | |
| console.info(`${LOG_PREFIX} Operation cancelled by the user.`); | |
| alert("The operation was cancelled. No further chats were processed."); | |
| } else { | |
| const message = | |
| error && error.message ? error.message : "Unknown error."; | |
| console.error(`${LOG_PREFIX} ${message}`, error); | |
| alert(`Error: ${message}`); | |
| } | |
| } finally { | |
| isRunning = false; | |
| activeAbortController = null; | |
| setRunningUi(false); | |
| setButtonState(button, { | |
| disabled: false, | |
| text: getButtonIdleText(), | |
| title: "Preview count: not computed yet", | |
| }); | |
| } | |
| } | |
| function createMainButton() { | |
| const button = document.createElement("button"); | |
| button.id = BUTTON_ID; | |
| button.type = "button"; | |
| button.textContent = getButtonIdleText(); | |
| button.title = "Preview count: not computed yet"; | |
| button.addEventListener("click", () => { | |
| void runCleanup(button); | |
| }); | |
| return button; | |
| } | |
| function createCancelButton() { | |
| const button = document.createElement("button"); | |
| button.id = CANCEL_BUTTON_ID; | |
| button.type = "button"; | |
| button.textContent = "Cancel"; | |
| button.hidden = true; | |
| button.disabled = true; | |
| button.addEventListener("click", () => { | |
| if (activeAbortController && !activeAbortController.signal.aborted) { | |
| activeAbortController.abort(); | |
| } | |
| }); | |
| return button; | |
| } | |
| function getPanelField(panel, name) { | |
| return panel.querySelector(`[data-setting="${name}"]`); | |
| } | |
| function setPanelValues(panel) { | |
| const fieldNames = [ | |
| "dryRun", | |
| "useUpdateTime", | |
| "allowTimestampFallback", | |
| "includeArchived", | |
| "timeframeMonths", | |
| "pageLimit", | |
| "maxPagesPerPass", | |
| "debugDeleteLimit", | |
| "logDetails", | |
| ]; | |
| for (const fieldName of fieldNames) { | |
| const field = getPanelField(panel, fieldName); | |
| if (!field) { | |
| continue; | |
| } | |
| if (field.type === "checkbox") { | |
| field.checked = Boolean(settings[fieldName]); | |
| } else if (fieldName === "debugDeleteLimit") { | |
| field.value = | |
| settings.debugDeleteLimit === null | |
| ? "" | |
| : String(settings.debugDeleteLimit); | |
| } else { | |
| field.value = String(settings[fieldName]); | |
| } | |
| } | |
| } | |
| function getPanelSettings(panel) { | |
| const readCheckbox = (name, fallback) => { | |
| const field = getPanelField(panel, name); | |
| return field ? Boolean(field.checked) : fallback; | |
| }; | |
| const readValue = (name, fallback) => { | |
| const field = getPanelField(panel, name); | |
| return field ? field.value : fallback; | |
| }; | |
| const debugValue = String( | |
| readValue("debugDeleteLimit", ""), | |
| ).trim(); | |
| return { | |
| dryRun: readCheckbox("dryRun", settings.dryRun), | |
| useUpdateTime: readCheckbox( | |
| "useUpdateTime", | |
| settings.useUpdateTime, | |
| ), | |
| allowTimestampFallback: readCheckbox( | |
| "allowTimestampFallback", | |
| settings.allowTimestampFallback, | |
| ), | |
| includeArchived: readCheckbox( | |
| "includeArchived", | |
| settings.includeArchived, | |
| ), | |
| logDetails: readCheckbox("logDetails", settings.logDetails), | |
| timeframeMonths: readValue( | |
| "timeframeMonths", | |
| settings.timeframeMonths, | |
| ), | |
| pageLimit: readValue("pageLimit", settings.pageLimit), | |
| maxPagesPerPass: readValue( | |
| "maxPagesPerPass", | |
| settings.maxPagesPerPass, | |
| ), | |
| debugDeleteLimit: debugValue === "" ? null : debugValue, | |
| }; | |
| } | |
| function setPanelStatus(panel, message) { | |
| const status = panel.querySelector("[data-status]"); | |
| if (!status) { | |
| return; | |
| } | |
| status.textContent = message; | |
| setTimeout(() => { | |
| if (status.textContent === message) { | |
| status.textContent = ""; | |
| } | |
| }, 2500); | |
| } | |
| function createSettingsPanel() { | |
| const panel = document.createElement("details"); | |
| panel.id = SETTINGS_PANEL_ID; | |
| panel.open = false; | |
| panel.innerHTML = ` | |
| <summary>Soft-delete settings</summary> | |
| <div class="tm-doc-body"> | |
| <p class="tm-doc-muted"> | |
| Settings are stored only in this browser. Dry run is enabled by default. | |
| </p> | |
| <div class="tm-doc-toggles"> | |
| <label class="tm-doc-toggle"> | |
| <span>Dry run</span> | |
| <input data-setting="dryRun" type="checkbox"> | |
| </label> | |
| <label class="tm-doc-toggle"> | |
| <span>Use update_time</span> | |
| <input data-setting="useUpdateTime" type="checkbox"> | |
| </label> | |
| <label class="tm-doc-toggle"> | |
| <span>Include archived</span> | |
| <input data-setting="includeArchived" type="checkbox"> | |
| </label> | |
| <label class="tm-doc-toggle"> | |
| <span>Allow timestamp fallback</span> | |
| <input data-setting="allowTimestampFallback" type="checkbox"> | |
| </label> | |
| <label class="tm-doc-toggle"> | |
| <span>Log chat details</span> | |
| <input data-setting="logDetails" type="checkbox"> | |
| </label> | |
| </div> | |
| <div class="tm-doc-grid"> | |
| <label class="tm-doc-field tm-doc-field-full"> | |
| <span>Timeframe in months</span> | |
| <input data-setting="timeframeMonths" type="number" min="1" max="1200" step="1"> | |
| </label> | |
| </div> | |
| <details class="tm-doc-advanced"> | |
| <summary>Advanced settings</summary> | |
| <div class="tm-doc-grid tm-doc-advanced-grid"> | |
| <label class="tm-doc-field"> | |
| <span>Page limit, max 100</span> | |
| <input data-setting="pageLimit" type="number" min="1" max="100" step="1"> | |
| </label> | |
| <label class="tm-doc-field"> | |
| <span>Max pages per pass</span> | |
| <input data-setting="maxPagesPerPass" type="number" min="1" max="5000" step="1"> | |
| </label> | |
| <label class="tm-doc-field tm-doc-field-full"> | |
| <span>Debug delete limit</span> | |
| <input data-setting="debugDeleteLimit" type="number" min="1" max="100000" step="1" placeholder="blank = disabled"> | |
| </label> | |
| </div> | |
| </details> | |
| <div class="tm-doc-actions"> | |
| <button type="button" class="tm-doc-btn tm-doc-btn-primary" data-action="save">Save</button> | |
| <button type="button" class="tm-doc-btn tm-doc-btn-secondary" data-action="reset">Reset</button> | |
| <span class="tm-doc-status" data-status></span> | |
| </div> | |
| </div> | |
| `; | |
| const saveButton = panel.querySelector('[data-action="save"]'); | |
| const resetButton = panel.querySelector('[data-action="reset"]'); | |
| if (saveButton) { | |
| saveButton.addEventListener("click", () => { | |
| applySettings(getPanelSettings(panel)); | |
| setPanelValues(panel); | |
| setPanelStatus(panel, "Saved"); | |
| }); | |
| } | |
| if (resetButton) { | |
| resetButton.addEventListener("click", () => { | |
| applySettings(DEFAULT_CONFIG); | |
| setPanelValues(panel); | |
| setPanelStatus(panel, "Reset"); | |
| }); | |
| } | |
| setPanelValues(panel); | |
| return panel; | |
| } | |
| function ensureUiStyles() { | |
| if (document.getElementById(UI_STYLE_ID)) { | |
| return; | |
| } | |
| const style = document.createElement("style"); | |
| style.id = UI_STYLE_ID; | |
| style.textContent = ` | |
| #${UI_CONTAINER_ID} { | |
| position: fixed; | |
| top: 16px; | |
| right: 16px; | |
| z-index: 2147483647; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; | |
| } | |
| #${BUTTON_ID}, | |
| #${CANCEL_BUTTON_ID} { | |
| border: 1px solid rgba(148, 163, 184, 0.28); | |
| border-radius: 12px; | |
| color: #f8fafc; | |
| padding: 10px 14px; | |
| font: 700 13px/1.2 "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; | |
| box-shadow: 0 12px 30px rgba(2, 6, 23, 0.34); | |
| cursor: pointer; | |
| transition: transform 0.15s ease, filter 0.15s ease, opacity 0.15s ease; | |
| } | |
| #${BUTTON_ID} { | |
| background: linear-gradient(135deg, rgba(15, 23, 42, 0.97), rgba(2, 6, 23, 0.97)); | |
| } | |
| #${CANCEL_BUTTON_ID} { | |
| background: linear-gradient(180deg, #b91c1c, #991b1b); | |
| } | |
| #${BUTTON_ID}:not(:disabled):hover, | |
| #${CANCEL_BUTTON_ID}:not(:disabled):hover { | |
| transform: translateY(-1px); | |
| filter: brightness(1.08); | |
| } | |
| #${BUTTON_ID}:disabled, | |
| #${CANCEL_BUTTON_ID}:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.72; | |
| } | |
| #${SETTINGS_PANEL_ID} { | |
| position: fixed; | |
| top: 66px; | |
| right: 16px; | |
| z-index: 2147483647; | |
| width: min(440px, calc(100vw - 24px)); | |
| overflow: hidden; | |
| border: 1px solid rgba(148, 163, 184, 0.25); | |
| border-radius: 14px; | |
| background: linear-gradient(140deg, rgba(15, 23, 42, 0.97), rgba(3, 7, 18, 0.98)); | |
| color: #e2e8f0; | |
| box-shadow: 0 18px 42px rgba(2, 6, 23, 0.42); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; | |
| transition: opacity 0.15s ease; | |
| } | |
| #${SETTINGS_PANEL_ID} > summary, | |
| #${SETTINGS_PANEL_ID} .tm-doc-advanced > summary { | |
| cursor: pointer; | |
| user-select: none; | |
| font-weight: 700; | |
| } | |
| #${SETTINGS_PANEL_ID} > summary { | |
| padding: 12px 14px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-body { | |
| display: grid; | |
| gap: 12px; | |
| padding: 12px 14px 14px; | |
| border-top: 1px solid rgba(148, 163, 184, 0.2); | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-muted { | |
| margin: 0; | |
| color: rgba(203, 213, 225, 0.85); | |
| font-size: 11px; | |
| line-height: 1.4; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-toggles { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 8px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-toggle { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 8px 9px; | |
| border: 1px solid rgba(148, 163, 184, 0.24); | |
| border-radius: 10px; | |
| background: rgba(15, 23, 42, 0.48); | |
| font-size: 11px; | |
| font-weight: 600; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-toggle input { | |
| width: 16px; | |
| height: 16px; | |
| accent-color: #3b82f6; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 10px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-field { | |
| display: grid; | |
| gap: 6px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-field-full { | |
| grid-column: 1 / -1; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-field > span { | |
| color: #cbd5e1; | |
| font-size: 11px; | |
| font-weight: 600; | |
| } | |
| #${SETTINGS_PANEL_ID} input[type="number"] { | |
| width: 100%; | |
| box-sizing: border-box; | |
| padding: 8px 10px; | |
| border: 1px solid rgba(148, 163, 184, 0.28); | |
| border-radius: 10px; | |
| outline: none; | |
| background: rgba(15, 23, 42, 0.68); | |
| color: #f8fafc; | |
| font-size: 12px; | |
| font-weight: 600; | |
| } | |
| #${SETTINGS_PANEL_ID} input[type="number"]:focus { | |
| border-color: rgba(96, 165, 250, 0.85); | |
| box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.22); | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-advanced { | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| border-radius: 10px; | |
| background: rgba(15, 23, 42, 0.35); | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-advanced > summary { | |
| padding: 9px 10px; | |
| font-size: 11px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-advanced-grid { | |
| padding: 0 10px 10px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-btn { | |
| padding: 8px 12px; | |
| border: 1px solid rgba(148, 163, 184, 0.24); | |
| border-radius: 10px; | |
| color: #f8fafc; | |
| cursor: pointer; | |
| font-size: 12px; | |
| font-weight: 700; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-btn-primary { | |
| background: linear-gradient(180deg, #2563eb, #1d4ed8); | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-btn-secondary { | |
| background: rgba(51, 65, 85, 0.72); | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-status { | |
| min-height: 1em; | |
| color: #86efac; | |
| font-size: 11px; | |
| font-weight: 700; | |
| } | |
| @media (max-width: 720px) { | |
| #${UI_CONTAINER_ID} { | |
| top: 10px; | |
| right: 10px; | |
| left: 10px; | |
| align-items: stretch; | |
| flex-direction: column; | |
| } | |
| #${BUTTON_ID}, | |
| #${CANCEL_BUTTON_ID} { | |
| width: 100%; | |
| } | |
| #${SETTINGS_PANEL_ID} { | |
| top: 62px; | |
| right: 10px; | |
| left: 10px; | |
| width: auto; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-toggles, | |
| #${SETTINGS_PANEL_ID} .tm-doc-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| #${SETTINGS_PANEL_ID} .tm-doc-field-full { | |
| grid-column: auto; | |
| } | |
| } | |
| `; | |
| (document.head || document.documentElement).appendChild(style); | |
| } | |
| function ensureUi() { | |
| if (!document.body) { | |
| return; | |
| } | |
| let container = document.getElementById(UI_CONTAINER_ID); | |
| if (!container || !container.isConnected) { | |
| container = document.createElement("div"); | |
| container.id = UI_CONTAINER_ID; | |
| container.appendChild(createMainButton()); | |
| container.appendChild(createCancelButton()); | |
| document.body.appendChild(container); | |
| } else if (!isRunning) { | |
| refreshMainButtonLabel(); | |
| } | |
| let panel = document.getElementById(SETTINGS_PANEL_ID); | |
| if (!panel || !panel.isConnected) { | |
| panel = createSettingsPanel(); | |
| document.body.appendChild(panel); | |
| } | |
| } | |
| function scheduleEnsureUi() { | |
| if (ensureScheduled) { | |
| return; | |
| } | |
| ensureScheduled = true; | |
| setTimeout(() => { | |
| ensureScheduled = false; | |
| ensureUiStyles(); | |
| ensureUi(); | |
| }, 50); | |
| } | |
| function installSpaGuards() { | |
| ensureUiStyles(); | |
| ensureUi(); | |
| setInterval(() => { | |
| ensureUiStyles(); | |
| ensureUi(); | |
| }, 2000); | |
| const root = document.documentElement || document.body; | |
| if (!root) { | |
| return; | |
| } | |
| const observer = new MutationObserver(() => { | |
| scheduleEnsureUi(); | |
| }); | |
| observer.observe(root, { childList: true, subtree: true }); | |
| } | |
| if (window[BOOTSTRAP_FLAG]) { | |
| return; | |
| } | |
| window[BOOTSTRAP_FLAG] = true; | |
| installSpaGuards(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment