fix(import): disambiguate ChatLab reimports

This commit is contained in:
digua committed 2026-07-13 01:01:42 +08:00
1 parent af78be00db
commit 6dc35722c0
14 files changed
+110 -9

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ interface ChatImportResult {
sessionId?: string
error?: string
importMode?: 'created' | 'incremental'
matchedBy?: 'stable-id' | 'trailing-messages'
matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages'
newMessageCount?: number
duplicateCount?: number
diagnostics?: {
+1 -1
View File
@@ -43,7 +43,7 @@ interface ChatImportResult {
sessionId?: string
error?: string
importMode?: 'created' | 'incremental'
matchedBy?: 'stable-id' | 'trailing-messages'
matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages'
newMessageCount?: number
duplicateCount?: number
diagnostics?: ImportDiagnostics
@@ -106,7 +106,12 @@ function parseOptionalArray<T>(value: string | null): T[] | undefined {
}
}
function exportAsJson(db: DatabaseAdapter, messages: MessageRow[], sessionName: string): string {
function exportAsJson(
db: DatabaseAdapter,
messages: MessageRow[],
sessionName: string,
sourceSessionId: string
): string {
const meta = db
.prepare(
`SELECT name,
@@ -147,6 +152,7 @@ function exportAsJson(db: DatabaseAdapter, messages: MessageRow[], sessionName:
groupId: meta.groupId || undefined,
groupAvatar: meta.groupAvatar || undefined,
ownerId: meta.ownerId || undefined,
sourceSessionId,
},
members: members.map((member) => ({
platformId: member.platformId,
@@ -219,7 +225,7 @@ export function exportWithFormat(
content = exportAsTxt(messages, params.sessionName)
break
case 'json':
content = exportAsJson(db, messages, params.sessionName)
content = exportAsJson(db, messages, params.sessionName, params.sessionId)
break
case 'markdown':
content = exportAsMarkdown(messages, params.sessionName)
@@ -32,13 +32,14 @@ function writeChatLabJsonl(
filePath: string,
meta: SourceMeta,
members: SourceMember[],
messages: ParsedMessage[]
messages: ParsedMessage[],
sourceSessionId?: string
): void {
const lines = [
{
_type: 'header',
chatlab: { version: '0.0.2', exportedAt: 1783840000 },
meta,
meta: { ...meta, sourceSessionId },
},
...members.map((member) => ({ _type: 'member', ...member })),
...messages.map((message) => ({
@@ -171,6 +172,77 @@ test('matches a unique private session by stable owner and participant ids', asy
)
})
test('uses a validated ChatLab source session id to disambiguate stable matches', async (t) => {
const tempDir = makeTempDir()
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const meta: SourceMeta = {
name: 'Private chat',
platform: 'wechat',
type: 'private',
ownerId: 'owner',
}
const members = [
{ platformId: 'owner', accountName: 'Owner' },
{ platformId: 'peer', accountName: 'Peer' },
]
const messages = [textMessage('peer', 1783860000, 'hello')]
seedSession(path.join(tempDir, 'source-session.db'), meta, members, messages)
seedSession(path.join(tempDir, 'duplicate-session.db'), meta, members, messages)
writeChatLabJsonl(path.join(tempDir, 'source.jsonl'), meta, members, messages, 'source-session')
assert.deepEqual(
await resolveAutoImportTarget(
path.join(tempDir, 'source.jsonl'),
createDeps(tempDir, ['source-session', 'duplicate-session'])
),
{
action: 'incremental',
sessionId: 'source-session',
matchedBy: 'source-session-id',
}
)
})
test('does not trust a ChatLab source session id that fails identity validation', async (t) => {
const tempDir = makeTempDir()
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const sourceMeta: SourceMeta = {
name: 'Private chat',
platform: 'wechat',
type: 'private',
ownerId: 'owner',
}
const sourceMembers = [
{ platformId: 'owner', accountName: 'Owner' },
{ platformId: 'peer', accountName: 'Peer' },
]
const messages = [textMessage('peer', 1783860000, 'hello')]
seedSession(path.join(tempDir, 'matching-a.db'), sourceMeta, sourceMembers, messages)
seedSession(path.join(tempDir, 'matching-b.db'), sourceMeta, sourceMembers, messages)
seedSession(
path.join(tempDir, 'unrelated.db'),
{ ...sourceMeta, ownerId: 'different-owner' },
[
{ platformId: 'different-owner', accountName: 'Other owner' },
{ platformId: 'different-peer', accountName: 'Other peer' },
],
[textMessage('different-peer', 1783860000, 'hello')]
)
writeChatLabJsonl(path.join(tempDir, 'source.jsonl'), sourceMeta, sourceMembers, messages, 'unrelated')
assert.deepEqual(
await resolveAutoImportTarget(
path.join(tempDir, 'source.jsonl'),
createDeps(tempDir, ['matching-a', 'matching-b', 'unrelated'])
),
{ action: 'create', reason: 'ambiguous' }
)
})
test('matches a unique session when its last five business messages appear consecutively in the source', async (t) => {
const tempDir = makeTempDir()
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
@@ -6,7 +6,7 @@ import type { ImportProgressCallback } from './streaming-importer'
const MATCH_WINDOW_SIZE = 5
export type AutoImportMatchMethod = 'stable-id' | 'trailing-messages'
export type AutoImportMatchMethod = 'source-session-id' | 'stable-id' | 'trailing-messages'
export type AutoImportDecision =
| { action: 'incremental'; sessionId: string; matchedBy: AutoImportMatchMethod }
@@ -137,11 +137,17 @@ export async function resolveAutoImportTarget(
}
if (hasStableIdentity) {
if (meta.sourceSessionId && stableMatches.includes(meta.sourceSessionId)) {
return { action: 'incremental', sessionId: meta.sourceSessionId, matchedBy: 'source-session-id' }
}
if (stableMatches.length === 1) {
return { action: 'incremental', sessionId: stableMatches[0], matchedBy: 'stable-id' }
}
return { action: 'create', reason: stableMatches.length > 1 ? 'ambiguous' : 'no-match' }
}
if (meta.sourceSessionId && trailingMatches.includes(meta.sourceSessionId)) {
return { action: 'incremental', sessionId: meta.sourceSessionId, matchedBy: 'source-session-id' }
}
if (trailingMatches.length === 1) {
return { action: 'incremental', sessionId: trailingMatches[0], matchedBy: 'trailing-messages' }
}
+7 -1
View File
@@ -29,6 +29,7 @@ struct ChatlabMeta {
group_id: Option<String>,
group_avatar: Option<String>,
owner_id: Option<String>,
source_session_id: Option<String>,
/// true: members came from the top-level `members` array (full shape);
/// false: collected from messages (id/name/nickname only), as in TS.
members_from_head: bool,
@@ -87,6 +88,7 @@ struct MetaOut {
group_id: Option<String>,
group_avatar: Option<String>,
owner_id: Option<String>,
source_session_id: Option<String>,
}
fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult<MetaOut> {
@@ -139,6 +141,7 @@ fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult<MetaOut> {
let group_id = optional_str(&meta, "groupId", "meta")?;
let group_avatar = optional_str(&meta, "groupAvatar", "meta")?;
let owner_id = optional_str(&meta, "ownerId", "meta")?;
let source_session_id = optional_str(&meta, "sourceSessionId", "meta")?;
// Filename fallback fires whenever the name resolved to the default,
// mirroring `if (meta.name === '未知群聊')` in the TS parser.
@@ -153,6 +156,7 @@ fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult<MetaOut> {
group_id,
group_avatar,
owner_id,
source_session_id,
})
}
@@ -379,6 +383,7 @@ pub fn parse_chatlab(
group_id: meta.group_id,
group_avatar: meta.group_avatar,
owner_id: meta.owner_id,
source_session_id: meta.source_session_id,
members_from_head,
})
.map_err(|err| strict(format!("meta serialization failed: {err}")))?;
@@ -410,7 +415,7 @@ mod tests {
fn parses_full_document() {
let doc = r#"{
"chatlab": {"version": "0.0.2", "exportedAt": 1700000000},
"meta": {"name": "测试群", "platform": "weixin", "type": "group", "groupId": "g1@chatroom", "groupAvatar": "https://x/y.jpg", "ownerId": "u1"},
"meta": {"name": "测试群", "platform": "weixin", "type": "group", "groupId": "g1@chatroom", "groupAvatar": "https://x/y.jpg", "ownerId": "u1", "sourceSessionId": "source-session"},
"members": [
{"platformId": "u1", "accountName": "Alice", "groupNickname": "小A", "aliases": ["Ally"], "avatar": "https://a", "roles": [{"id": "owner"}, {"id": "admin", "name": "管理"}]},
{"platformId": "u2", "accountName": "Bob"}
@@ -428,6 +433,7 @@ mod tests {
assert_eq!(m["chatType"], "group");
assert_eq!(m["groupId"], "g1@chatroom");
assert_eq!(m["ownerId"], "u1");
assert_eq!(m["sourceSessionId"], "source-session");
assert_eq!(m["membersFromHead"], true);
assert_eq!(out.members.len(), 2);
assert_eq!(
@@ -49,6 +49,7 @@ interface JsonlHeader {
groupId?: string
groupAvatar?: string
ownerId?: string
sourceSessionId?: string
}
}
@@ -171,6 +172,7 @@ async function* parseChatLabJsonl(options: ParseOptions): AsyncGenerator<ParseEv
groupId: parsed.meta.groupId,
groupAvatar: parsed.meta.groupAvatar,
ownerId: parsed.meta.ownerId,
sourceSessionId: parsed.meta.sourceSessionId,
}
headerParsed = true
yield { type: 'meta', data: meta }
+1
View File
@@ -141,6 +141,7 @@ async function* parseChatLab(options: ParseOptions): AsyncGenerator<ParseEvent,
groupId: metaObj.groupId,
groupAvatar: metaObj.groupAvatar,
ownerId: metaObj.ownerId,
sourceSessionId: metaObj.sourceSessionId,
}
}
}
@@ -84,6 +84,7 @@ const CANONICAL_FIXTURE = {
groupId: 'g1@chatroom',
groupAvatar: 'https://example.com/g.jpg',
ownerId: 'u1',
sourceSessionId: 'source-session',
},
members: [
{
@@ -130,6 +131,7 @@ describe('chatlab native parser parity', { skip: !nativeAvailable() && 'native m
assert.equal(nativeResult.meta.platform, 'weixin')
assert.equal(nativeResult.meta.groupId, 'g1@chatroom')
assert.equal(nativeResult.meta.ownerId, 'u1')
assert.equal(nativeResult.meta.sourceSessionId, 'source-session')
assert.equal(nativeResult.members.length, 2)
assert.deepEqual(nativeResult.members[0].aliases, ['Ally'])
assert.equal(nativeResult.members[0].avatar, 'https://example.com/a.jpg')
@@ -21,6 +21,7 @@ interface ChatlabMetaJson {
groupId: string | null
groupAvatar: string | null
ownerId: string | null
sourceSessionId: string | null
/**
* true when members came from the top-level `members` array;
* false when collected from messages (TS emits different object shapes).
@@ -62,6 +63,7 @@ const chatlabAdapter: NativeFormatAdapter = {
groupId: meta.groupId ?? undefined,
groupAvatar: meta.groupAvatar ?? undefined,
ownerId: meta.ownerId ?? undefined,
sourceSessionId: meta.sourceSessionId ?? undefined,
}
},
+1
View File
@@ -17,6 +17,7 @@ export interface ParsedMeta {
groupId?: string // 群ID(群聊类型有值)
groupAvatar?: string // 群头像(base64 Data URL)
ownerId?: string // 所有者/导出者的 platformId
sourceSessionId?: string // ChatLab 导出来源会话 ID,仅用于安全消歧
}
/**
+1 -1
View File
@@ -17,7 +17,7 @@ export interface ImportOptions {
// ==================== 导入结果 ====================
export type AutoImportMode = 'created' | 'incremental'
export type AutoImportMatchMethod = 'stable-id' | 'trailing-messages'
export type AutoImportMatchMethod = 'source-session-id' | 'stable-id' | 'trailing-messages'
export interface ImportResult {
success: boolean
+1
View File
@@ -37,6 +37,7 @@ export interface ChatLabMeta {
groupId?: string // 群ID(可选,仅群聊)
groupAvatar?: string // 群头像(base64 Data URL,可选)
ownerId?: string // 所有者/导出者的 platformId(可选)
sourceSessionId?: string // ChatLab 导出来源会话 ID(可选)
}
/**
@@ -62,6 +62,7 @@ test('exports JSON as ChatLab format that can be parsed for re-import', async ()
const exported = JSON.parse(result.content)
assert.equal(exported.chatlab.version, '0.0.2')
assert.equal(exported.meta.ownerId, 'alice')
assert.equal(exported.meta.sourceSessionId, 'session-1')
assert.deepEqual(exported.members[0].aliases, ['Alice'])
assert.deepEqual(
exported.messages.map((message: { timestamp: number }) => message.timestamp),
@@ -87,6 +88,7 @@ test('exports JSON as ChatLab format that can be parsed for re-import', async ()
groupId: 'group-1',
groupAvatar: 'group-avatar',
ownerId: 'alice',
sourceSessionId: 'session-1',
})
assert.deepEqual(
parsed.members.map((member) => ({