feat(export): select not-exported or updated conversations

This commit is contained in:
tssujt
2026-08-20 17:44:24 +08:00
committed by Pionxzh
parent 7170fe6267
commit 68eee3a91c
11 changed files with 81 additions and 1 deletions
+1
View File
@@ -17,6 +17,7 @@ export const KEY_META_LIST = 'exporter:meta_list'
export const KEY_THINKING_ENABLED = 'exporter:enable_thinking'
export const KEY_SOURCES_ENABLED = 'exporter:enable_sources'
export const KEY_EXPORT_ALL_LIMIT = 'exporter:export_all_limit'
export const KEY_EXPORTED_UPDATE_TIMES = 'exporter:exported_update_times'
export const KEY_OAI_LOCALE = 'oai/apps/locale'
+3
View File
@@ -13,6 +13,9 @@
"Save": "Save",
"Delete": "Delete",
"Select All": "Select All",
"Select...": "Select...",
"Select Not Exported": "Select Not Exported",
"Select Updated": "Select Updated",
"Export": "Export",
"Error": "Error",
"Loading": "Loading",
+3
View File
@@ -13,6 +13,9 @@
"Save": "Guardar",
"Delete": "Borrar",
"Select All": "Seleccionar Todos",
"Select...": "Seleccionar...",
"Select Not Exported": "Seleccionar no exportadas",
"Select Updated": "Seleccionar actualizadas",
"Export": "Exportar",
"Error": "Error",
"Loading": "Cargando",
+3
View File
@@ -13,6 +13,9 @@
"Save": "Enregistrer",
"Delete": "Supprimer",
"Select All": "Tout sélectionner",
"Select...": "Sélectionner...",
"Select Not Exported": "Sélectionner non exportées",
"Select Updated": "Sélectionner mises à jour",
"Export": "Exporter",
"Error": "Erreur",
"Loading": "Chargement",
+3
View File
@@ -13,6 +13,9 @@
"Save": "Simpan",
"Delete": "Hapus",
"Select All": "Pilih Semua",
"Select...": "Pilih...",
"Select Not Exported": "Pilih yang belum diekspor",
"Select Updated": "Pilih yang diperbarui",
"Export": "Ekspor",
"Error": "Kesalahan",
"Loading": "Memuat",
+3
View File
@@ -13,6 +13,9 @@
"Save": "保存",
"Delete": "削除",
"Select All": "すべて選択",
"Select...": "選択...",
"Select Not Exported": "未エクスポートを選択",
"Select Updated": "更新されたものを選択",
"Export": "エクスポート",
"Error": "エラー",
"Loading": "読み込み中",
+3
View File
@@ -13,6 +13,9 @@
"Save": "Сохранить",
"Delete": "Удалить",
"Select All": "Выбрать все",
"Select...": "Выбрать...",
"Select Not Exported": "Выбрать неэкспортированные",
"Select Updated": "Выбрать обновлённые",
"Export": "Экспорт",
"Error": "Ошибка",
"Loading": "Загрузка",
+3
View File
@@ -13,6 +13,9 @@
"Save": "Kaydet",
"Delete": "Sil",
"Select All": "Tümünü Seç",
"Select...": "Seç...",
"Select Not Exported": "Dışa aktarılmamışları seç",
"Select Updated": "Güncellenenleri seç",
"Export": "Dışa Aktar",
"Error": "Hata",
"Loading": "Yükleniyor",
+3
View File
@@ -13,6 +13,9 @@
"Save": "保存",
"Delete": "删除",
"Select All": "全选",
"Select...": "选择…",
"Select Not Exported": "选择未导出",
"Select Updated": "选择有更新",
"Export": "导出",
"Error": "错误",
"Loading": "加载中",
+3
View File
@@ -13,6 +13,9 @@
"Save": "保存",
"Delete": "刪除",
"Select All": "全選",
"Select...": "選取…",
"Select Not Exported": "選取未匯出",
"Select Updated": "選取有更新",
"Export": "匯出",
"Error": "錯誤",
"Loading": "載入中",
+53 -1
View File
@@ -2,11 +2,12 @@ import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { useTranslation } from 'react-i18next'
import { archiveConversation, deleteConversation, fetchAllConversations, fetchConversation, fetchConversationsPage, fetchProjects, probeApi } from '../api'
import { EXPORT_OPERATION_BATCH } from '../constants'
import { EXPORT_OPERATION_BATCH, KEY_EXPORTED_UPDATE_TIMES } from '../constants'
import { exportAllToHtml } from '../exporter/html'
import { exportAllToJson, exportAllToOfficialJson } from '../exporter/json'
import { exportAllToMarkdown } from '../exporter/markdown'
import { RequestQueue } from '../utils/queue'
import { ScriptStorage } from '../utils/storage'
import { sleep } from '../utils/utils'
import { CheckBox } from './CheckBox'
import { IconCross, IconLoading, IconUpload } from './Icons'
@@ -56,6 +57,24 @@ function formatConvDate(time: number | string | undefined): string {
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/** Read the persisted per-conversation last-exported update_time map (conversation id → ms). */
function getExportedUpdateTimes(): Record<string, number> {
const stored = ScriptStorage.get<Record<string, number>>(KEY_EXPORTED_UPDATE_TIMES)
if (stored && typeof stored === 'object') return stored
return {}
}
/** Persist the last-exported update_time for conversations that were actually exported successfully. */
function markExported(conversations: { id: string; update_time?: number | string }[]): void {
if (conversations.length === 0) return
const map = getExportedUpdateTimes()
for (const c of conversations) {
const ms = toMs(c.update_time)
if (ms > (map[c.id] ?? 0)) map[c.id] = ms
}
ScriptStorage.set(KEY_EXPORTED_UPDATE_TIMES, map)
}
/** Text search supporting * and ? wildcards. Falls back to substring. */
function textSearch(title: string, query: string): boolean {
const q = query.trim()
@@ -162,6 +181,20 @@ const ConversationSelect: FC<ConversationSelectProps> = ({
const allFilteredSelected = filtered.length > 0 && filtered.every(c => selected.some(x => x.id === c.id))
const selectByExportStatus = useCallback((status: 'all' | 'not_exported' | 'updated') => {
lastClickedIndex.current = -1
const exportedMap = getExportedUpdateTimes()
if (status === 'all') {
setSelected(filtered)
}
else if (status === 'not_exported') {
setSelected(filtered.filter(c => !(c.id in exportedMap)))
}
else {
setSelected(filtered.filter(c => c.id in exportedMap && exportedMap[c.id] < toMs(c.update_time)))
}
}, [filtered, setSelected])
return (
<>
{/* ── Search input ── */}
@@ -203,6 +236,22 @@ const ConversationSelect: FC<ConversationSelectProps> = ({
>
{t('Last 100')}
</button>
<select
className="Select"
style={{ fontSize: '0.75rem', padding: '2px 5px' }}
disabled={disabled || filtered.length === 0}
value=""
title="Select conversations by export status"
onChange={(e) => {
const val = e.currentTarget.value
if (val) selectByExportStatus(val as 'all' | 'not_exported' | 'updated')
}}
>
<option value="" disabled>{t('Select...')}</option>
<option value="all">{t('Select All')}</option>
<option value="not_exported">{t('Select Not Exported')}</option>
<option value="updated">{t('Select Updated')}</option>
</select>
{/* Resume control: select the next 100 starting at a given offset */}
<input
type="number"
@@ -480,6 +529,8 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
const callback = exportAllOptions.find(o => o.label === exportType)?.callback
if (callback && results.length > 0) {
await callback(format, results, metaList, selectedProject?.display.name, partIndex, totalBatches)
// Only conversations that were actually exported successfully get recorded
markExported(results)
}
if (partIndex < totalBatches) {
await sleep(400)
@@ -550,6 +601,7 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
setProcessing(true)
for (let i = 0; i < chunks.length; i++) {
await callback(format, chunks[i], metaList, selectedProject?.display.name, i + 1, chunks.length)
markExported(chunks[i])
if (i < chunks.length - 1) await sleep(400)
}
setProcessing(false)