Skip to content

Instantly share code, notes, and snippets.

@bruvv
Last active June 16, 2026 20:47
Show Gist options
  • Select an option

  • Save bruvv/c25a168271f7bda197b9a0422fdb80aa to your computer and use it in GitHub Desktop.

Select an option

Save bruvv/c25a168271f7bda197b9a0422fdb80aa to your computer and use it in GitHub Desktop.
Soft-delete ChatGPT conversations older than x months via backend API.
// ==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