/*
* ChatGPT Conversation Toolkit - Selective conversation export
*/
const EXPORT_SELECTION_MODAL_ID = "chatgpt-toolkit-export-selection-modal";
const EXPORT_SELECTION_PAGE_SIZE = 50;
const exportSelectionState = {
isOpen: false,
loading: false,
loadToken: 0,
preparedExport: null,
turns: [],
selectedTurnIds: new Set(),
scope: EXPORT_SCOPE_ALL,
roleFilter: TOOLKIT_EXPORT_ROLE_ALL,
format: TOOLKIT_EXPORT_FORMAT_JSON,
page: 0,
lastSelectedTurnIndex: -1,
error: null,
previousFocus: null,
};
let exportSelectionEscapeBound = false;
const escapeExportSelectionHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const truncateExportSelectionText = (value, maxLength = 180) => {
const text = String(value || "").replace(/\s+/g, " ").trim();
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
};
const getExportSelectionModal = () => document.getElementById(EXPORT_SELECTION_MODAL_ID);
const getExportSelectionConfig = () =>
typeof getToolkitConfig === "function"
? getToolkitConfig()
: {
exportFormat: TOOLKIT_EXPORT_FORMAT,
exportRole: TOOLKIT_EXPORT_ROLE,
};
const resetExportSelectionState = () => {
const config = getExportSelectionConfig();
exportSelectionState.loading = true;
exportSelectionState.preparedExport = null;
exportSelectionState.turns = [];
exportSelectionState.selectedTurnIds = new Set();
exportSelectionState.scope = EXPORT_SCOPE_ALL;
exportSelectionState.roleFilter = TOOLKIT_EXPORT_ROLE_VALUES.includes(config.exportRole)
? config.exportRole
: TOOLKIT_EXPORT_ROLE_ALL;
exportSelectionState.format = TOOLKIT_EXPORT_FORMAT_VALUES.includes(config.exportFormat)
? config.exportFormat
: TOOLKIT_EXPORT_FORMAT_JSON;
exportSelectionState.page = 0;
exportSelectionState.lastSelectedTurnIndex = -1;
exportSelectionState.error = null;
};
const closeExportSelectionModal = () => {
exportSelectionState.isOpen = false;
exportSelectionState.loadToken += 1;
const modal = getExportSelectionModal();
if (modal instanceof HTMLElement) {
modal.classList.remove("is-visible");
}
const previousFocus = exportSelectionState.previousFocus;
exportSelectionState.previousFocus = null;
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) {
requestAnimationFrame(() => previousFocus.focus());
}
};
const getExportSelectionTurnPreview = (turn, role) => {
const entries = role === "assistant" ? turn?.assistantMessages : turn?.userMessages;
const text = (Array.isArray(entries) ? entries : [])
.map((entry) => entry?.message?.text || "")
.filter(Boolean)
.join(" ");
return truncateExportSelectionText(text) || t("exportSelection.emptyPreview");
};
const getExportSelectionResultPreview = () => {
const payload = exportSelectionState.preparedExport?.payload;
if (!payload) {
return null;
}
return applyExportSelection(payload, {
scope: exportSelectionState.scope,
selectedTurnIds: Array.from(exportSelectionState.selectedTurnIds),
roleFilter: exportSelectionState.roleFilter,
});
};
const getExportSelectionPageCount = () =>
Math.max(1, Math.ceil(exportSelectionState.turns.length / EXPORT_SELECTION_PAGE_SIZE));
const clampExportSelectionPage = () => {
exportSelectionState.page = Math.min(
Math.max(0, exportSelectionState.page),
getExportSelectionPageCount() - 1,
);
};
const buildExportSelectionTurnMarkup = () => {
clampExportSelectionPage();
const start = exportSelectionState.page * EXPORT_SELECTION_PAGE_SIZE;
return exportSelectionState.turns
.slice(start, start + EXPORT_SELECTION_PAGE_SIZE)
.map((turn) => {
const checked = exportSelectionState.selectedTurnIds.has(turn.id);
const userPreview = getExportSelectionTurnPreview(turn, "user");
const assistantPreview = getExportSelectionTurnPreview(turn, "assistant");
return `
`;
})
.join("");
};
const renderExportSelectionModal = () => {
const modal = getExportSelectionModal();
if (!(modal instanceof HTMLElement)) {
return;
}
if (exportSelectionState.loading) {
modal.innerHTML = `
`;
return;
}
if (exportSelectionState.error) {
modal.innerHTML = `
`;
return;
}
const resultPreview = getExportSelectionResultPreview();
const isSelectedScope = exportSelectionState.scope === EXPORT_SCOPE_SELECTED;
const pageCount = getExportSelectionPageCount();
const selectedCount = exportSelectionState.selectedTurnIds.size;
const messageCount = resultPreview?.messageCount || 0;
const partialWarning = exportSelectionState.preparedExport?.usedFallback
? `${t("exportSelection.partialWarning")}
`
: "";
const turnSection = `
${buildExportSelectionTurnMarkup()}
`;
modal.innerHTML = `
`;
};
const syncExportSelectionVisibleTurns = () => {
const modal = getExportSelectionModal();
if (!(modal instanceof HTMLElement)) {
return;
}
modal.querySelectorAll("[data-export-turn-checkbox]").forEach((checkbox) => {
if (!(checkbox instanceof HTMLInputElement)) {
return;
}
const checked = exportSelectionState.selectedTurnIds.has(checkbox.dataset.exportTurnCheckbox);
checkbox.checked = checked;
checkbox.closest(".chatgpt-toolkit-export-turn")?.classList.toggle("is-selected", checked);
});
};
const updateExportSelectionInteractiveUi = () => {
const modal = getExportSelectionModal();
if (!(modal instanceof HTMLElement) || exportSelectionState.loading || exportSelectionState.error) {
return;
}
const isSelectedScope = exportSelectionState.scope === EXPORT_SCOPE_SELECTED;
const turnSection = modal.querySelector("[data-export-selection-turn-section]");
if (turnSection instanceof HTMLElement) {
turnSection.hidden = !isSelectedScope;
}
const selectedCount = modal.querySelector("[data-export-selection-selected-count]");
if (selectedCount instanceof HTMLElement) {
selectedCount.textContent = t("exportSelection.selectedCount", {
selected: exportSelectionState.selectedTurnIds.size,
total: exportSelectionState.turns.length,
});
}
const resultPreview = getExportSelectionResultPreview();
const messageCount = resultPreview?.messageCount || 0;
const resultCount = modal.querySelector("[data-export-selection-result-count]");
if (resultCount instanceof HTMLElement) {
resultCount.textContent = t("exportSelection.resultCount", {
turns: resultPreview?.selectedTurnCount || 0,
messages: messageCount,
});
}
const exportButton = modal.querySelector("[data-export-selection-export-button]");
if (exportButton instanceof HTMLButtonElement) {
exportButton.disabled = messageCount <= 0;
}
syncExportSelectionVisibleTurns();
};
const renderExportSelectionPage = () => {
const modal = getExportSelectionModal();
if (!(modal instanceof HTMLElement)) {
return;
}
clampExportSelectionPage();
const turnList = modal.querySelector("[data-export-selection-turn-list]");
if (turnList instanceof HTMLElement) {
turnList.innerHTML = buildExportSelectionTurnMarkup();
turnList.scrollTop = 0;
}
const pageCount = getExportSelectionPageCount();
const previousButton = modal.querySelector('[data-export-selection-action="prev-page"]');
const nextButton = modal.querySelector('[data-export-selection-action="next-page"]');
const pageLabel = modal.querySelector("[data-export-selection-page]");
if (previousButton instanceof HTMLButtonElement) {
previousButton.disabled = exportSelectionState.page <= 0;
}
if (nextButton instanceof HTMLButtonElement) {
nextButton.disabled = exportSelectionState.page >= pageCount - 1;
}
if (pageLabel instanceof HTMLElement) {
pageLabel.textContent = t("exportSelection.page", {
current: exportSelectionState.page + 1,
total: pageCount,
});
}
updateExportSelectionInteractiveUi();
};
const setExportSelectionTurnChecked = (turnIndex, checked, extendRange = false) => {
if (turnIndex < 0 || turnIndex >= exportSelectionState.turns.length) {
return;
}
let start = turnIndex;
let end = turnIndex;
if (extendRange && exportSelectionState.lastSelectedTurnIndex >= 0) {
start = Math.min(turnIndex, exportSelectionState.lastSelectedTurnIndex);
end = Math.max(turnIndex, exportSelectionState.lastSelectedTurnIndex);
}
for (let index = start; index <= end; index += 1) {
const turnId = exportSelectionState.turns[index].id;
if (checked) {
exportSelectionState.selectedTurnIds.add(turnId);
} else {
exportSelectionState.selectedTurnIds.delete(turnId);
}
}
exportSelectionState.lastSelectedTurnIndex = turnIndex;
};
const loadExportSelectionData = async () => {
const token = exportSelectionState.loadToken + 1;
exportSelectionState.loadToken = token;
exportSelectionState.loading = true;
exportSelectionState.error = null;
renderExportSelectionModal();
try {
const preparedExport = await prepareConversationExportPayload({ updateStatus: false });
if (!exportSelectionState.isOpen || token !== exportSelectionState.loadToken) {
return;
}
exportSelectionState.preparedExport = preparedExport;
exportSelectionState.turns = groupExportMessagesIntoTurns(preparedExport.payload.messages);
exportSelectionState.loading = false;
updateStatusByKey("status.exportSelectionReady", "success", {
count: exportSelectionState.turns.length,
});
} catch (error) {
if (!exportSelectionState.isOpen || token !== exportSelectionState.loadToken) {
return;
}
exportSelectionState.loading = false;
exportSelectionState.error = normalizeExportApiError(error, "Export preparation failed.");
updateStatusByKey("status.exportFailed", "warn", {
reason: exportSelectionState.error.message,
});
}
renderExportSelectionModal();
};
const handleExportSelectionAction = async (action) => {
if (action === "close") {
closeExportSelectionModal();
return;
}
if (action === "retry") {
await loadExportSelectionData();
return;
}
if (action === "select-all") {
exportSelectionState.selectedTurnIds = new Set(exportSelectionState.turns.map((turn) => turn.id));
} else if (action === "clear") {
exportSelectionState.selectedTurnIds.clear();
} else if (action === "invert") {
exportSelectionState.selectedTurnIds = new Set(
exportSelectionState.turns
.filter((turn) => !exportSelectionState.selectedTurnIds.has(turn.id))
.map((turn) => turn.id),
);
} else if (action === "prev-page") {
exportSelectionState.page -= 1;
renderExportSelectionPage();
return;
} else if (action === "next-page") {
exportSelectionState.page += 1;
renderExportSelectionPage();
return;
} else if (action === "export") {
const config = {
exportFormat: exportSelectionState.format,
exportRole: exportSelectionState.roleFilter,
};
if (typeof saveToolkitConfig === "function") {
saveToolkitConfig(config);
}
const payload = await exportMessages({
preparedExport: exportSelectionState.preparedExport,
scope: exportSelectionState.scope,
selectedTurnIds: Array.from(exportSelectionState.selectedTurnIds),
roleFilter: exportSelectionState.roleFilter,
format: exportSelectionState.format,
});
if (payload) {
closeExportSelectionModal();
}
return;
}
updateExportSelectionInteractiveUi();
};
const ensureExportSelectionModal = () => {
let modal = getExportSelectionModal();
if (!(modal instanceof HTMLElement)) {
modal = document.createElement("section");
modal.id = EXPORT_SELECTION_MODAL_ID;
modal.className = "chatgpt-toolkit-prompt-modal";
document.body.appendChild(modal);
modal.addEventListener("click", (event) => {
const target = event.target;
if (target instanceof HTMLInputElement && target.dataset.exportTurnCheckbox) {
const turnIndex = exportSelectionState.turns.findIndex(
(turn) => turn.id === target.dataset.exportTurnCheckbox,
);
setExportSelectionTurnChecked(turnIndex, target.checked, event.shiftKey);
updateExportSelectionInteractiveUi();
return;
}
const actionTarget = target instanceof Element
? target.closest("[data-export-selection-action]")
: null;
const action = actionTarget?.dataset?.exportSelectionAction;
if (action) {
void handleExportSelectionAction(action);
}
});
modal.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const field = target.dataset.exportSelectionField;
if (field === "scope") {
exportSelectionState.scope = target.value === EXPORT_SCOPE_SELECTED
? EXPORT_SCOPE_SELECTED
: EXPORT_SCOPE_ALL;
exportSelectionState.page = 0;
renderExportSelectionPage();
return;
} else if (field === "role") {
exportSelectionState.roleFilter = target.value === TOOLKIT_EXPORT_ROLE_ASSISTANT
? TOOLKIT_EXPORT_ROLE_ASSISTANT
: TOOLKIT_EXPORT_ROLE_ALL;
updateExportSelectionInteractiveUi();
} else if (field === "format") {
exportSelectionState.format = TOOLKIT_EXPORT_FORMAT_VALUES.includes(target.value)
? target.value
: TOOLKIT_EXPORT_FORMAT_JSON;
}
});
}
if (!exportSelectionEscapeBound) {
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && exportSelectionState.isOpen) {
closeExportSelectionModal();
}
});
exportSelectionEscapeBound = true;
}
return modal;
};
const refreshExportSelectionLocalization = () => {
if (exportSelectionState.isOpen) {
renderExportSelectionModal();
}
};
const openExportSelectionModal = async () => {
const modal = ensureExportSelectionModal();
exportSelectionState.previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
resetExportSelectionState();
exportSelectionState.isOpen = true;
renderExportSelectionModal();
if (typeof syncToolkitTheme === "function") {
syncToolkitTheme();
}
requestAnimationFrame(() => {
modal.classList.add("is-visible");
modal.querySelector("button, select")?.focus();
});
await loadExportSelectionData();
};
window.addEventListener("__chatgptConversationToolkitRouteChange", () => {
if (exportSelectionState.isOpen) {
closeExportSelectionModal();
}
});