Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Git history and README use Conventional Commits, for example `feat: update conte

Pull requests should include a short summary, validation commands run, linked issues when applicable, and screenshots for visible UI changes. Mention data migration, IndexedDB, localStorage, or bundled question JSON changes explicitly.

Pull request titles and descriptions should be written in Chinese.

## Security & Configuration Tips

Do not commit secrets. Use `.env.example` as the public template, and keep API keys in local browser settings or environment-specific configuration. Treat imported question JSON as untrusted input and validate changes against the existing schema before publishing.
11 changes: 10 additions & 1 deletion scripts/checkExternalServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,14 @@ function createSmokeBackup(now: number): SyncData {
updatedAt: now,
},
],
questionAnswerOverrides: [
{
questionId: 'external-smoke-question',
content: 'External smoke custom answer',
createdAt: now - 1_000,
updatedAt: now,
},
],
questionFlags: [
{
questionId: 'external-smoke-question',
Expand Down Expand Up @@ -319,7 +327,7 @@ async function runGistExternalSmoke(): Promise<void> {
const loaded = await githubFetch<GistResponse>(token, `/gists/${gistId}`)
const loadedContent = await readGistFile(token, loaded, filename)
const parsed = parseGistBackupPayload(loadedContent)
assert('Gist read payload', parsed.version === 6, '真实 Gist 读取到的备份版本不是 v6')
assert('Gist read payload', parsed.version === 7, '真实 Gist 读取到的备份版本不是 v7')
assert(
'Gist read data',
parsed.questionNotes.some((note) => note.questionId === 'external-smoke-question'),
Expand All @@ -328,6 +336,7 @@ async function runGistExternalSmoke(): Promise<void> {
addEvidence('gist.read', {
backupVersion: parsed.version,
noteCount: parsed.questionNotes.length,
answerOverrideCount: parsed.questionAnswerOverrides.length,
starredCount: parsed.questionFlags.filter((flag) => flag.starred).length,
aiSessionCount: parsed.aiSessions.length,
customQuestionCount: parsed.customQuestions.length,
Expand Down
88 changes: 84 additions & 4 deletions scripts/checkGistSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import {
serializeGistBackup,
} from '../src/lib/gistSync.ts'
import type { AISession } from '../src/store/useAIStore.ts'
import type { Question, QuestionFlag, QuestionNote, StudyRecord } from '../src/types'
import type {
Question,
QuestionAnswerOverride,
QuestionFlag,
QuestionNote,
StudyRecord,
} from '../src/types'

interface Failure {
name: string
Expand Down Expand Up @@ -75,6 +81,14 @@ function note(questionId: string, content: string, updatedAt: number): QuestionN
return { questionId, content, createdAt: updatedAt - 100, updatedAt }
}

function answerOverride(
questionId: string,
content: string,
updatedAt: number,
): QuestionAnswerOverride {
return { questionId, content, createdAt: updatedAt - 100, updatedAt }
}

function flag(questionId: string, starred: boolean, updatedAt: number): QuestionFlag {
return { questionId, starred, createdAt: updatedAt - 100, updatedAt }
}
Expand Down Expand Up @@ -126,6 +140,7 @@ const v3Record = record('v3-001', 'mastered', 1700000100000)
const v4Note = note('v4-001', 'v4 note', 1700000200000)
const v5Session = session('v5-001', 'v5 session', 1700000300000)
const v6Flag = flag('v6-001', true, 1700000400000)
const v7AnswerOverride = answerOverride('v7-001', 'v7 custom answer', 1700000500000)

const legacy = parseGistBackupPayload(
JSON.stringify({
Expand All @@ -148,6 +163,11 @@ assert(
'v1 应只保留 custom_ 题目',
)
assert('v1 has no notes', legacy.questionNotes.length === 0, 'v1 不应产生题目笔记')
assert(
'v1 has no answer overrides',
legacy.questionAnswerOverrides.length === 0,
'v1 不应产生自定义答案',
)
assert('v1 has no flags', legacy.questionFlags.length === 0, 'v1 不应产生题目标记')
assert('v1 has no ai sessions', legacy.aiSessions.length === 0, 'v1 不应产生 AI 会话')
assert(
Expand All @@ -169,6 +189,11 @@ const v3 = parseGistBackupPayload(

assert('v3 decodes compact records', v3.studyRecords[0]?.questionId === 'v3-001', 'v3 记录解码失败')
assert('v3 has no notes', v3.questionNotes.length === 0, 'v3 不应产生题目笔记')
assert(
'v3 has no answer overrides',
v3.questionAnswerOverrides.length === 0,
'v3 不应产生自定义答案',
)
assert('v3 has no flags', v3.questionFlags.length === 0, 'v3 不应产生题目标记')
assert('v3 has no ai sessions', v3.aiSessions.length === 0, 'v3 不应产生 AI 会话')

Expand Down Expand Up @@ -221,6 +246,27 @@ const v6 = parseGistBackupPayload(

assert('v6 keeps flags', v6.questionFlags[0]?.questionId === 'v6-001', 'v6 题目标记未恢复')
assert('v6 keeps ai sessions', v6.aiSessions[0]?.questionId === 'v5-001', 'v6 AI 会话未恢复')

const v7 = parseGistBackupPayload(
JSON.stringify({
version: 7,
exportedAt: '2026-01-04T00:00:00.000Z',
records: compact([v3Record]),
questionNotes: [v4Note],
questionAnswerOverrides: [v7AnswerOverride],
questionFlags: [v6Flag],
aiSessions: [v5Session],
customQuestions: [question('custom_sync_v7')],
customCategories: {},
customSources: ['v7-source'],
}),
)

assert(
'v7 keeps answer overrides',
v7.questionAnswerOverrides[0]?.content === 'v7 custom answer',
'v7 自定义答案未恢复',
)
assertThrows(
'future version rejected',
() => parseGistBackupPayload(JSON.stringify({ version: 999 })),
Expand All @@ -231,6 +277,10 @@ assertThrows('invalid json rejected', () => parseGistBackupPayload('{'), 'invali
const local: SyncData = {
studyRecords: [record('same-record', 'mastered', 3000), record('local-record', 'review', 4000)],
questionNotes: [note('same-note', 'local newer', 5000), note('local-note', 'local only', 4500)],
questionAnswerOverrides: [
answerOverride('same-answer', 'local newer', 5500),
answerOverride('local-answer', 'local only', 5600),
],
questionFlags: [flag('same-flag', false, 7000), flag('local-flag', true, 7200)],
aiSessions: [
session('same-session', 'local newer', 6000),
Expand All @@ -251,6 +301,10 @@ const remote = {
note('same-note', 'remote older', 4000),
note('remote-note', 'remote only', 8000),
],
questionAnswerOverrides: [
answerOverride('same-answer', 'remote older', 5000),
answerOverride('remote-answer', 'remote only', 8100),
],
questionFlags: [flag('same-flag', true, 6500), flag('remote-flag', true, 8200)],
aiSessions: [
session('same-session', 'remote older', 5000),
Expand All @@ -267,6 +321,9 @@ const remote = {
const merged = mergeGistBackupData(local, remote)
const mergedRecord = merged.backup.studyRecords.find((item) => item.questionId === 'same-record')
const mergedNote = merged.backup.questionNotes.find((item) => item.questionId === 'same-note')
const mergedAnswerOverride = merged.backup.questionAnswerOverrides.find(
(item) => item.questionId === 'same-answer',
)
const mergedFlag = merged.backup.questionFlags.find((item) => item.questionId === 'same-flag')
const mergedSession = merged.backup.aiSessions.find((item) => item.questionId === 'same-session')

Expand All @@ -290,6 +347,16 @@ assert(
merged.backup.questionNotes.some((item) => item.questionId === 'remote-note'),
'云端新笔记未合并',
)
assert(
'merge keeps newer local answer override',
mergedAnswerOverride?.content === 'local newer',
'较新的本地自定义答案被覆盖',
)
assert(
'merge adds remote answer override',
merged.backup.questionAnswerOverrides.some((item) => item.questionId === 'remote-answer'),
'云端新自定义答案未合并',
)
assert(
'merge keeps newer local flag',
mergedFlag?.starred === false,
Expand Down Expand Up @@ -332,6 +399,11 @@ assert(
const stats: SyncMergeStats = merged.stats
assert('merge stats records', stats.remoteRecordsApplied === 1, '云端记录合并计数不正确')
assert('merge stats notes', stats.remoteNotesApplied === 1, '云端笔记合并计数不正确')
assert(
'merge stats answer overrides',
stats.remoteAnswerOverridesApplied === 1,
'云端自定义答案合并计数不正确',
)
assert('merge stats flags', stats.remoteFlagsApplied === 1, '云端重点题标记合并计数不正确')
assert('merge stats ai sessions', stats.remoteAISessionsApplied === 1, '云端 AI 会话合并计数不正确')
assert('merge stats questions', stats.remoteQuestionsAdded === 1, '云端自定义题合并计数不正确')
Expand All @@ -340,7 +412,7 @@ assert('merge stats categories', stats.remoteCategoriesAdded === 2, '云端分

const serialized = serializeGistBackup(local, '2026-01-06T00:00:00.000Z')
const serializedPayload = JSON.parse(serialized)
assert('serialized payload version', serializedPayload.version === 6, '写入 payload 版本应为 v6')
assert('serialized payload version', serializedPayload.version === 7, '写入 payload 版本应为 v7')
assert(
'serialized payload compacts records',
serializedPayload.records?.ids?.includes('same-record') &&
Expand All @@ -352,6 +424,13 @@ assert(
serializedPayload.questionFlags?.some((item: QuestionFlag) => item.questionId === 'local-flag'),
'写入 payload 未包含重点题标记',
)
assert(
'serialized payload keeps answer overrides',
serializedPayload.questionAnswerOverrides?.some(
(item: QuestionAnswerOverride) => item.questionId === 'local-answer',
),
'写入 payload 未包含自定义答案',
)
assert(
'serialized payload keeps ai sessions',
serializedPayload.aiSessions?.some((session: AISession) => session.questionId === 'same-session'),
Expand Down Expand Up @@ -561,7 +640,8 @@ const createdPayload = createdContent ? JSON.parse(createdContent) : null
assert('gist create uses private gist', createBody?.public === false, '创建 Gist 必须是 private')
assert(
'gist create writes backup file',
createdPayload?.version === 6 &&
createdPayload?.version === 7 &&
createdPayload?.questionAnswerOverrides?.length === 2 &&
createdPayload?.questionFlags?.length === 2 &&
createdPayload?.aiSessions?.length === 2,
`创建 Gist 文件内容错误:${createdContent}`,
Expand Down Expand Up @@ -629,5 +709,5 @@ if (failures.length > 0) {
}

console.log(
'Gist 同步兼容检查通过:v1-v6 解析、未来版本拒绝、双端合并和 mock GitHub API 读写路径正常',
'Gist 同步兼容检查通过:v1-v7 解析、未来版本拒绝、双端合并和 mock GitHub API 读写路径正常',
)
Loading
Loading