diff --git a/packages/parser/src/chatlab-validator.test.ts b/packages/parser/src/chatlab-validator.test.ts index 2b21dfbf..b6b4362b 100644 --- a/packages/parser/src/chatlab-validator.test.ts +++ b/packages/parser/src/chatlab-validator.test.ts @@ -72,6 +72,34 @@ test('validates a complete ChatLab JSONL file without exposing message content', } }) +test('rejects an ownerId outside inferred JSONL members', async () => { + const root = makeTempDir() + const filePath = path.join(root, 'unknown-owner.jsonl') + writeJsonl(filePath, [ + { + _type: 'header', + chatlab: { version: CHATLAB_FORMAT_VERSION, exportedAt: 1_711_468_800 }, + meta: { name: 'Example', platform: 'custom', type: 'group', ownerId: 'missing' }, + }, + { + _type: 'message', + sender: 'alice', + accountName: 'Alice', + timestamp: 1_711_468_800, + type: 0, + content: 'Hello', + }, + ]) + + try { + const report = await validateChatLabFile(filePath) + assert.equal(report.valid, false) + assert.ok(report.issues.some((issue) => issue.code === 'UNKNOWN_OWNER')) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + test('accepts the initial ChatLab JSON format version', async () => { const root = makeTempDir() const filePath = path.join(root, 'legacy.json') diff --git a/packages/parser/src/chatlab-validator.ts b/packages/parser/src/chatlab-validator.ts index d7227b3c..ebacd177 100644 --- a/packages/parser/src/chatlab-validator.ts +++ b/packages/parser/src/chatlab-validator.ts @@ -357,12 +357,17 @@ function finalizeState( } } - if (state.memberIds.size > 0) { + const ownerMemberIds = + state.memberIds.size > 0 ? state.memberIds : requireExplicitMembers ? undefined : state.senderIds + if (ownerMemberIds) { for (const ownerId of state.ownerIds) { - if (!state.memberIds.has(ownerId)) { - addIssue(state, 'error', 'UNKNOWN_OWNER', 'meta.ownerId must reference an explicit member platformId.') + if (!ownerMemberIds.has(ownerId)) { + addIssue(state, 'error', 'UNKNOWN_OWNER', 'meta.ownerId must reference a member platformId.') } } + } + + if (state.memberIds.size > 0) { for (const senderId of state.senderIds) { if (!state.memberIds.has(senderId)) { addIssue( diff --git a/packages/parser/src/formats/chatlab-jsonl.test.ts b/packages/parser/src/formats/chatlab-jsonl.test.ts index 058d8eae..2dfb4d9c 100644 --- a/packages/parser/src/formats/chatlab-jsonl.test.ts +++ b/packages/parser/src/formats/chatlab-jsonl.test.ts @@ -5,7 +5,7 @@ import path from 'node:path' import test from 'node:test' import { MessageType } from '@openchatlab/shared-types' -import { findEntryFileInDirectory, parseFileWithFormat } from '../index' +import { detectFormat, findEntryFileInDirectory, parseFileWithFormat } from '../index' function makeTempDir(): string { const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) @@ -96,3 +96,46 @@ test('directory entry detection accepts a top-level ChatLab JSONL file', () => { fs.rmSync(root, { recursive: true, force: true }) } }) + +test('auto-detects ChatLab JSONL after leading comments and blank lines', () => { + const root = makeTempDir() + const filePath = path.join(root, 'commented-chat.jsonl') + writeChatLabJsonl(filePath, 1) + fs.writeFileSync(filePath, `# generated locally\n\n${fs.readFileSync(filePath, 'utf8')}`, 'utf8') + + try { + assert.equal(detectFormat(filePath)?.id, 'chatlab-jsonl') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('auto-detects ChatLab JSONL regardless of header field order', () => { + const root = makeTempDir() + const filePath = path.join(root, 'reordered-header.jsonl') + writeChatLabJsonl(filePath, 1) + + const [headerLine, ...remainingLines] = fs.readFileSync(filePath, 'utf8').trimEnd().split('\n') + const header = JSON.parse(headerLine) as Record + const reorderedHeader = { chatlab: header.chatlab, meta: header.meta, _type: header._type } + fs.writeFileSync(filePath, `${JSON.stringify(reorderedHeader)}\n${remainingLines.join('\n')}\n`, 'utf8') + + try { + assert.equal(detectFormat(filePath)?.id, 'chatlab-jsonl') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('auto-detects ChatLab JSONL with a UTF-8 BOM', () => { + const root = makeTempDir() + const filePath = path.join(root, 'bom-chat.jsonl') + writeChatLabJsonl(filePath, 1) + fs.writeFileSync(filePath, `\uFEFF${fs.readFileSync(filePath, 'utf8')}`, 'utf8') + + try { + assert.equal(detectFormat(filePath)?.id, 'chatlab-jsonl') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/parser/src/formats/chatlab-jsonl.ts b/packages/parser/src/formats/chatlab-jsonl.ts index 26f212c4..6fc97229 100644 --- a/packages/parser/src/formats/chatlab-jsonl.ts +++ b/packages/parser/src/formats/chatlab-jsonl.ts @@ -117,8 +117,10 @@ export const feature: FormatFeature = { priority: 51, // 低优先级,让其他格式先匹配 extensions: ['.jsonl'], signatures: { - // 第一行必须是 header 类型,包含 chatlab 信息 - head: [/^\s*\{.*"_type"\s*:\s*"header".*"chatlab"/], + // 跳过格式允许的空行和注释后,分别匹配首条数据中的 header 类型和 chatlab 对象。 + head: [ + /^\uFEFF?(?:[ \t]*(?:#[^\r\n]*)?\r?\n)*[ \t]*\{(?=[^\r\n]*"_type"\s*:\s*"header")(?=[^\r\n]*"chatlab"\s*:)[^\r\n]*\}/, + ], }, } diff --git a/scripts/build-chatlab-skill-validator.mjs b/scripts/build-chatlab-skill-validator.mjs index e8558e48..2e7968e6 100644 --- a/scripts/build-chatlab-skill-validator.mjs +++ b/scripts/build-chatlab-skill-validator.mjs @@ -100,7 +100,7 @@ try { '--target=node20', '--minify', `--outfile=${outputPath}`, - '--banner:js=#!/usr/bin/env node\n// Generated from the canonical ChatLab validator. Do not edit directly.', + '--banner:js=#!/usr/bin/env node\n// Generated from the canonical ChatLab validator. Do not edit directly.\n/* eslint-disable */', ], { cwd: rootDir, stdio: 'inherit' } ) diff --git a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs b/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs index e70b88a4..1b54322f 100644 --- a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs +++ b/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node // Generated from the canonical ChatLab validator. Do not edit directly. -import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,r,n,t,o={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,o){c(r,n)&&typeof r[n]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(o,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&rtypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),c(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((o,i)=>{let p=`${n}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,o.id,`${p}.id`,t),m(e,o,"name",`${p}.name`,t)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!d(r)){a(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp0){for(let o of e.ownerIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference an explicit member platformId.");for(let o of e.senderIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.")}for(let o of e.replyTargetIds)e.messageIds.has(o)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let t=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,t-e.issues.length),stats:e.stats}}async function F(e,r){let n=A("json",r),t;try{t=JSON.parse(await P(e,"utf8"))}catch{return a(n,"error","INVALID_JSON","The file is not valid JSON."),b(n,!0,!1)}return d(t)?(x(n,t.chatlab,t.meta,{}),Array.isArray(t.members)?t.members.forEach((o,i)=>N(n,o,`members[${i}]`,{})):a(n,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(t.messages)?t.messages.forEach((o,i)=>_(n,o,`messages[${i}]`,{})):a(n,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(n,!0)):(a(n,"error","INVALID_ROOT","Expected a top-level JSON object."),b(n,!0,!1))}async function $(e,r){let n=A("jsonl",r),t=O(e,{encoding:"utf8"}),o=D({input:t,crlfDelay:1/0}),i=0,p=0,R=!1;for await(let w of o){i++;let I=w.trim();if(!I){n.stats.blankLineCount++;continue}if(I.startsWith("#")){n.stats.commentLineCount++;continue}p++;let l;try{l=JSON.parse(I)}catch{a(n,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(n,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(p===1&&l._type!=="header"&&a(n,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":n.stats.headerCount>0&&a(n,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),x(n,l.chatlab,l.meta,{line:i});break;case"member":R&&a(n,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),N(n,l,`line[${i}]`,{line:i});break;case"message":R=!0,_(n,l,`line[${i}]`,{line:i});break;default:a(n,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(n,!1)}async function T(e){let r=await k(e),n=V(e).toLowerCase();if(n===".json")return F(e,r.size);if(n===".jsonl")return $(e,r.size);let t=A("unknown",r.size);return a(t,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(t,!1,!1)}var L=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} +/* eslint-disable */ +import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function o(e,r,n,t,a={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(o(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,a){d(r,n)&&typeof r[n]!="string"&&o(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(a,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&rtypeof a!="string"))&&o(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),d(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((a,i)=>{let p=`${n}.roles[${i}]`;if(!c(a)){o(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,a.id,`${p}.id`,t),m(e,a,"name",`${p}.name`,t)}):o(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!c(r)){o(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp0?e.memberIds:r?void 0:e.senderIds;if(t)for(let i of e.ownerIds)t.has(i)||o(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)e.memberIds.has(i)||o(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||o(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let a=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,a-e.issues.length),stats:e.stats}}async function F(e,r){let n=A("json",r),t;try{t=JSON.parse(await P(e,"utf8"))}catch{return o(n,"error","INVALID_JSON","The file is not valid JSON."),b(n,!0,!1)}return c(t)?(x(n,t.chatlab,t.meta,{}),Array.isArray(t.members)?t.members.forEach((a,i)=>N(n,a,`members[${i}]`,{})):o(n,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(t.messages)?t.messages.forEach((a,i)=>_(n,a,`messages[${i}]`,{})):o(n,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(n,!0)):(o(n,"error","INVALID_ROOT","Expected a top-level JSON object."),b(n,!0,!1))}async function $(e,r){let n=A("jsonl",r),t=O(e,{encoding:"utf8"}),a=D({input:t,crlfDelay:1/0}),i=0,p=0,R=!1;for await(let w of a){i++;let I=w.trim();if(!I){n.stats.blankLineCount++;continue}if(I.startsWith("#")){n.stats.commentLineCount++;continue}p++;let l;try{l=JSON.parse(I)}catch{o(n,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!c(l)){o(n,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(p===1&&l._type!=="header"&&o(n,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":n.stats.headerCount>0&&o(n,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),x(n,l.chatlab,l.meta,{line:i});break;case"member":R&&o(n,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),N(n,l,`line[${i}]`,{line:i});break;case"message":R=!0,_(n,l,`line[${i}]`,{line:i});break;default:o(n,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(n,!1)}async function T(e){let r=await k(e),n=V(e).toLowerCase();if(n===".json")return F(e,r.size);if(n===".jsonl")return $(e,r.size);let t=A("unknown",r.size);return o(t,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(t,!1,!1)}var L=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} `)}if(!L)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=z.resolve(L);if(!U(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let r=await T(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} diff --git a/skills/chatlab-convert/scripts/validate-chatlab.mjs b/skills/chatlab-convert/scripts/validate-chatlab.mjs index e70b88a4..1b54322f 100644 --- a/skills/chatlab-convert/scripts/validate-chatlab.mjs +++ b/skills/chatlab-convert/scripts/validate-chatlab.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node // Generated from the canonical ChatLab validator. Do not edit directly. -import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,r,n,t,o={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,o){c(r,n)&&typeof r[n]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(o,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&rtypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),c(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((o,i)=>{let p=`${n}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,o.id,`${p}.id`,t),m(e,o,"name",`${p}.name`,t)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!d(r)){a(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp0){for(let o of e.ownerIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference an explicit member platformId.");for(let o of e.senderIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.")}for(let o of e.replyTargetIds)e.messageIds.has(o)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let t=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,t-e.issues.length),stats:e.stats}}async function F(e,r){let n=A("json",r),t;try{t=JSON.parse(await P(e,"utf8"))}catch{return a(n,"error","INVALID_JSON","The file is not valid JSON."),b(n,!0,!1)}return d(t)?(x(n,t.chatlab,t.meta,{}),Array.isArray(t.members)?t.members.forEach((o,i)=>N(n,o,`members[${i}]`,{})):a(n,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(t.messages)?t.messages.forEach((o,i)=>_(n,o,`messages[${i}]`,{})):a(n,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(n,!0)):(a(n,"error","INVALID_ROOT","Expected a top-level JSON object."),b(n,!0,!1))}async function $(e,r){let n=A("jsonl",r),t=O(e,{encoding:"utf8"}),o=D({input:t,crlfDelay:1/0}),i=0,p=0,R=!1;for await(let w of o){i++;let I=w.trim();if(!I){n.stats.blankLineCount++;continue}if(I.startsWith("#")){n.stats.commentLineCount++;continue}p++;let l;try{l=JSON.parse(I)}catch{a(n,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(n,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(p===1&&l._type!=="header"&&a(n,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":n.stats.headerCount>0&&a(n,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),x(n,l.chatlab,l.meta,{line:i});break;case"member":R&&a(n,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),N(n,l,`line[${i}]`,{line:i});break;case"message":R=!0,_(n,l,`line[${i}]`,{line:i});break;default:a(n,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(n,!1)}async function T(e){let r=await k(e),n=V(e).toLowerCase();if(n===".json")return F(e,r.size);if(n===".jsonl")return $(e,r.size);let t=A("unknown",r.size);return a(t,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(t,!1,!1)}var L=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} +/* eslint-disable */ +import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function o(e,r,n,t,a={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(o(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,a){d(r,n)&&typeof r[n]!="string"&&o(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(a,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&rtypeof a!="string"))&&o(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),d(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((a,i)=>{let p=`${n}.roles[${i}]`;if(!c(a)){o(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,a.id,`${p}.id`,t),m(e,a,"name",`${p}.name`,t)}):o(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!c(r)){o(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp0?e.memberIds:r?void 0:e.senderIds;if(t)for(let i of e.ownerIds)t.has(i)||o(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)e.memberIds.has(i)||o(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||o(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let a=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,a-e.issues.length),stats:e.stats}}async function F(e,r){let n=A("json",r),t;try{t=JSON.parse(await P(e,"utf8"))}catch{return o(n,"error","INVALID_JSON","The file is not valid JSON."),b(n,!0,!1)}return c(t)?(x(n,t.chatlab,t.meta,{}),Array.isArray(t.members)?t.members.forEach((a,i)=>N(n,a,`members[${i}]`,{})):o(n,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(t.messages)?t.messages.forEach((a,i)=>_(n,a,`messages[${i}]`,{})):o(n,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(n,!0)):(o(n,"error","INVALID_ROOT","Expected a top-level JSON object."),b(n,!0,!1))}async function $(e,r){let n=A("jsonl",r),t=O(e,{encoding:"utf8"}),a=D({input:t,crlfDelay:1/0}),i=0,p=0,R=!1;for await(let w of a){i++;let I=w.trim();if(!I){n.stats.blankLineCount++;continue}if(I.startsWith("#")){n.stats.commentLineCount++;continue}p++;let l;try{l=JSON.parse(I)}catch{o(n,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!c(l)){o(n,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(p===1&&l._type!=="header"&&o(n,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":n.stats.headerCount>0&&o(n,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),x(n,l.chatlab,l.meta,{line:i});break;case"member":R&&o(n,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),N(n,l,`line[${i}]`,{line:i});break;case"message":R=!0,_(n,l,`line[${i}]`,{line:i});break;default:o(n,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(n,!1)}async function T(e){let r=await k(e),n=V(e).toLowerCase();if(n===".json")return F(e,r.size);if(n===".jsonl")return $(e,r.size);let t=A("unknown",r.size);return o(t,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(t,!1,!1)}var L=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} `)}if(!L)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=z.resolve(L);if(!U(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let r=await T(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}